Skip to content

DDC

Digital Down-Converter — shifts a carrier to baseband and decimates in one call, backed by ddc_state_t and ddcr_state_t.

Source: src/doppler/ddc/__init__.py


Which class to use

Two types, one per input dtype — and each has a matched flavor built by a different constructor over the same C state:

Class Input Terminal stage Use when
DDC CF32 IQ Kaiser anti-alias complex ADC, already at fs
MatchedDDC CF32 IQ matched-filter bank recovering symbols from IQ
Ddcr float32 real Kaiser anti-alias real ADC, direct-sampling SDR
MatchedDdcr float32 real matched-filter bank recovering symbols from a real ADC

All four produce CF32 IQ at the decimated output rate, share the same methods, and take an optional caller-provided output buffer (y = ddc.execute(x, out)) when allocation and buffer reuse need to be explicit.


DDC — complex input

Signal chain: LO mix → polyphase resample. Built-in Kaiser bank (60 dB rejection) — no filter design required.

Frequency convention: norm_freq = -f_carrier shifts a carrier at f_carrier (normalised to fs) down to DC.

from doppler.ddc import DDC
import numpy as np

# Tune to a tone at +0.1·fs, decimate 4×
ddc = DDC(norm_freq=-0.1, rate=0.25)

x = np.random.randn(4096).astype(np.complex64)
y = ddc.execute(x)          # CF32 output, len(y) ≈ 4096 * 0.25 = 1024

print(f"in={len(x)}  out={len(y)}")

Retune without reset

ddc.norm_freq = -0.2        # LO retuned; resampler history preserved
next_block = np.random.randn(4096).astype(np.complex64)
y = ddc.execute(next_block)

Phase-continuous across blocks

ddc = DDC(-0.1, 0.25)
iq_stream = [np.random.randn(4096).astype(np.complex64) for _ in range(3)]

def process(chunk):         # your per-block consumer
    return chunk

for block in iq_stream:     # generator of CF32 arrays
    out = ddc.execute(block)
    process(out)

Matched mode

MatchedDDC is the same object as DDC built by a different constructor: the cascade's terminal stage carries the matched filter instead of the default Kaiser anti-alias bank, so the same dot products that mix and decimate also matched-filter, and that stage's polyphase arm becomes the fractional timing delay a loop steers. It is a straight passthrough to RateConverter — the DDC adds the mix in front of it. MatchedDdcr is the identical flavor of the real-input chain.

from doppler.ddc import MatchedDDC
import numpy as np

# 16 samples/symbol in, 2 out; carrier at +0.09375·fs wiped off on the way.
rx = MatchedDDC(norm_freq=-0.09375, rate=2 / 16, pulse="rrc", beta=0.35,
                span=8, pulse_sps=2.0)

x = (0.25 * np.random.randn(4096)).astype(np.complex64)
symbols = rx.execute(x)          # matched-filtered, 2 samples/symbol
print(len(symbols), rx.clipped)  # clipped: has any CIC stage clipped?

pulse_sps is the pulse's period in output samples, so a caller wanting m samples per symbol at sps input samples per symbol asks for rate = m/sps and pulse_sps = m. CIC droop compensation is unconditional on this path — the fold costs six taps per arm and is worth 28 dB.

Two control ports

A matched DDC is steerable on two accumulators, and they are duals of each other:

port steers units loop it closes
freq_ctrl the LO's phase accumulator cycles/sample at the input rate carrier
rate_ctrl the terminal stage's accumulator output periods per terminal input timing

Neither deviation is persisted — norm_freq and rate never move — so a tracking loop passes its full filter output on every call and the DDC holds no loop state of its own. execute_ctrl applies both across a block (open loop: a fixed Doppler offset, a rate trim); execute_ctrl_push applies them one input at a time, which is the only form a closed loop can use, since each correction is computed from the outputs already emitted.

# Carrier recovery: a tone 0.01 cycles/sample off where the LO is tuned.
f0, tuned, rate, mu = 0.05, -0.04, 0.25, 0.01
d = MatchedDDC(tuned, rate, pulse="rrc")
x = (0.25 * np.exp(2j * np.pi * f0 * np.arange(8192))).astype(np.complex64)

freq_ctrl, prev = 0.0, None
for i, v in enumerate(x):
    for y in d.execute_ctrl_push(complex(v), 0.0, freq_ctrl):
        if prev is not None and i > 1024:       # let the cascade prime
            e = float(np.angle(y * np.conj(prev)) / (2 * np.pi))
            freq_ctrl -= mu * e * rate          # e is per OUTPUT sample
        prev = y

assert abs(f0 + tuned + freq_ctrl) < 1e-6       # parked on the mistune
assert d.norm_freq == tuned                     # the centre never moved

The loop gain is small for a structural reason: the loop closes around the matched filter, so its dead time is that filter's group delay. Measured on this configuration, mu of 0.01–0.02 converges, 0.05 is marginal and 0.1 diverges — the same reason a real receiver's carrier-loop bandwidth is a small fraction of the symbol rate.

The timing port is the other half: wrap a detector and a loop filter around rate_ctrl and you have track.RateSync, which does exactly that over a RateConverter.


Ddcr — real input (Architecture D2)

Signal chain: halfband R2C (2:1, embedded −fs/4 shift, zero extra multiplications) → LO mix at fs/2 → polyphase resample.

~2× cheaper than DDC for any real-ADC source because the halfband operates at half the sample rate and the embedded mix costs zero extra multiplications.

Ddcr wraps ddcr_state_t. execute() returns its own array, or fills a caller-provided writable complex64 buffer and returns the trimmed view out[:n_out] when one is passed — so allocation and buffer reuse can be made explicit for streaming and sharded-worker designs.

Frequency convention: norm_freq = -(2*f_carrier + 0.5) The −0.5 cancels the halfband's embedded −fs/4 shift.

from doppler.ddc import Ddcr
import numpy as np

# Tune to a tone at f_carrier=0.1·fs; real ADC, decimate 4×
#   norm_freq at intermediate rate: -(2 * 0.1 + 0.5) = -0.7
ddcr = Ddcr(norm_freq=-0.7, rate=0.25)

x = np.random.randn(4096).astype(np.float32)   # real ADC samples
out = np.empty(len(x), dtype=np.complex64)     # caller buffer (reuse across calls)
y = ddcr.execute(x, out)    # CF32 view out[:n_out], len(y) ≈ 4096/2 * 0.25 = 512

DDC

Create a complex-input Digital Down-Converter. Allocates internal state for the LO and RateConverter cascade. The RateConverter selects the cheapest multi-stage decimation chain (CIC + optional halfband + polyphase resampler) for the given rate.

Parameters:

Name Type Description Default
norm_freq float

LO frequency in cycles/sample at the input rate. Set to -f_carrier to shift a carrier at f_carrier to DC. Any real value is accepted.

0.0
rate float

Output rate / input rate. Must be > 0. Values >= 1 are up-sampling; typical use is decimation (0 < rate < 1).

0.25

Raises:

Type Description
ValueError

If construction fails. The exception message is DDC: invalid parameter (need rate > 0, 0 <= beta <= 1, span >= 1, pulse_sps > 0, num_phases a power of two >= 2).

Examples:

>>> from doppler.ddc import DDC
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> ddc.norm_freq
-0.1
>>> ddc.rate
0.25

norm_freq property writable

norm_freq: float

Return the current LO normalised frequency (cycles/sample).

rate property

rate: float

Return the configured output/input rate ratio (read-only). The rate is fixed at create time; change it by destroying and recreating the DDC with the new value.

clipped property

clipped: bool

Has the cascade's CIC clipped its input since the last reset?

narrow_pulse property

narrow_pulse: bool

Is this object's rectangular matched filter degenerately narrow?

execute

execute(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Mix input block with LO, then rate-convert.

Parameters:

Name Type Description Default
x NDArray[complex64]

CF32 input block; accepted as float32 (auto-cast).

required
out NDArray[complex64] | None

CF32 output buffer (C-only, hidden from Python).

None

Returns:

Type Description
NDArray[complex64]

Number of output samples written (C-only).

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> t = np.arange(4096)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> y = ddc.execute(x)
>>> y.shape
(1024,)
>>> y.dtype
dtype('complex64')
>>> round(float(abs(y[500])), 2)   # shifted to DC; amplitude ≈ 1
1.0

execute_max_out

execute_max_out(x_len: int) -> int

Maximum output samples one execute() of x_len inputs can produce.

A DDC decimates (or passes at unity), so the output never exceeds the

input length: returns x_len. The binding sizes the output buffer to this

per-call bound and resizes down to the actual count (gh-607).

Parameters:

Name Type Description Default
x_len int

Number of input samples the matching execute() call sees.

required

Returns:

Type Description
int

x_len (a safe upper bound on the produced samples).

execute_ctrl

execute_ctrl(
    x: NDArray[complex64],
    rate_ctrl: float,
    freq_ctrl: float,
) -> NDArray[np.complex64]

Mix and resample a block, steering both control ports.

The control-port form of ddc_execute(): the LO advances by phase_inc + freq_ctrl on every sample of this block, and the cascade's terminal stage runs at stage_rate + rate_ctrl. Neither deviation is persisted — the centre norm_freq and rate are untouched — so a tracking loop passes its full filter output on every call and the DDC holds no loop state of its own.

Feeding a stream through ddc_execute_ctrl_push() one sample at a time reproduces this call bit-for-bit when both controls are held constant, so the cheap block form stays correct for open-loop use (a fixed Doppler offset, a rate trim) and the push form is what a closed loop uses.

Parameters:

Name Type Description Default
x NDArray[complex64]

CF32 input block.

required
rate_ctrl float

Rate deviation added to the terminal Resampler stage's rate. Referenced to the terminal (post-decimation) rate, not the overall rate; ignored by a plan whose last stage is an integer HB/CIC with nothing to steer.

required
freq_ctrl float

Frequency deviation added to the LO, in cycles/sample at the INPUT rate (any sign).

required

Returns:

Type Description
NDArray[complex64]

Number of output samples written.

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=0.0, rate=0.25)   # LO centred at DC
>>> t = np.arange(4096)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> y = ddc.execute_ctrl(x, 0.0, -0.1)    # freq_ctrl steers +0.1 to DC
>>> y.shape
(1024,)
>>> round(float(abs(y[100:].mean())), 2)  # settled output sits at DC
1.0

execute_ctrl_push

execute_ctrl_push(
    x: complex,
    rate_ctrl: float,
    freq_ctrl: float,
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Push ONE input sample; emit whatever outputs it completes.

The per-input streaming form of ddc_execute_ctrl(), and the only form a closed loop can use: a block call has to know its whole control history up front, whereas a carrier or timing loop computes each correction from the outputs already emitted. Both loops close once per symbol, so both ports need this form.

The mix costs one LO step per input; the cascade then emits 0 outputs (the common decimating case, between strobes), 1, or several.

Parameters:

Name Type Description Default
x complex

One CF32 input sample.

required
rate_ctrl float

Rate deviation for this input (terminal-stage rate).

required
freq_ctrl float

Frequency deviation for this input, cycles/sample at the input rate.

required
out NDArray[complex64] | None

Output buffer for any emitted samples.

None

Returns:

Type Description
NDArray[complex64]

Number of outputs written (0, 1, or more).

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> t = np.arange(64)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> outs = [ddc.execute_ctrl_push(complex(s), 0.0, 0.0) for s in x]
>>> int(sum(len(o) for o in outs))   # 64 inputs, rate 1/4 -> 16 outs
16
>>> [len(o) for o in outs[:4]]        # 0 outs until a strobe completes
[0, 0, 0, 1]

execute_ctrl_push_max_out

execute_ctrl_push_max_out() -> int

Largest number of samples execute_ctrl_push() can return in the current state.

Size an out= buffer with this before calling execute_ctrl_push(), or use it to allocate one up front. The bound is this object's own: what it depends on is a property of the algorithm, so a header block on execute_ctrl_push_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

reset

reset() -> None

Zero LO phase and filter history.

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=0.0, rate=0.25)
>>> x = np.ones(64, dtype=np.complex64)
>>> y1 = ddc.execute(x)
>>> ddc.reset()
>>> y2 = ddc.execute(x)
>>> bool(np.array_equal(y1, y2))
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the DDC has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the DDC has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the DDC has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> DDC

Enter a context manager, returning this object.

Lets a DDC be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
DDC

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the DDC.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

MatchedDDC

Create a DDC whose cascade's terminal stage IS a matched filter.

Parameters:

Name Type Description Default
norm_freq float

LO frequency in cycles/sample at the input rate, as ddc_create().

0.0
rate float

Output-to-input sample rate ratio. Rate-agnostic: a caller wanting m outputs per symbol asks for rate = m/sps; the cascade never learns about symbols.

0.25
pulse Literal[iandd, rrc]

RC_PULSE_RRC / RC_PULSE_IANDD. RC_PULSE_NONE is invalid here — use ddc_create() for a plain down-conversion.

"rrc"
beta float

RRC roll-off in [0, 1] (ignored for the rectangle).

0.35
span int

One-sided RRC span in symbols (ignored for the rectangle, whose support is exactly one symbol).

8
pulse_sps float

The pulse's period in output samples (2 = two samples per symbol out).

2.0
num_phases int

Terminal-stage arms; a power of two. Sets the timing resolution to 1/num_phases of an output period.

1024

Raises:

Type Description
ValueError

If construction fails. The exception message is DDC: invalid parameter (need rate > 0, 0 <= beta <= 1, span >= 1, pulse_sps > 0, num_phases a power of two >= 2).

Examples:

>>> from doppler.ddc import MatchedDDC
>>> rx = MatchedDDC(norm_freq=-0.1, rate=2 / 16, pulse="rrc")
>>> rx.rate
0.125

norm_freq property writable

norm_freq: float

Return the current LO normalised frequency (cycles/sample).

rate property

rate: float

Return the configured output/input rate ratio (read-only). The rate is fixed at create time; change it by destroying and recreating the DDC with the new value.

clipped property

clipped: bool

Has the cascade's CIC clipped its input since the last reset?

narrow_pulse property

narrow_pulse: bool

Is this object's rectangular matched filter degenerately narrow?

execute

execute(
    x: NDArray[complex64],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Mix input block with LO, then rate-convert.

Parameters:

Name Type Description Default
x NDArray[complex64]

CF32 input block; accepted as float32 (auto-cast).

required
out NDArray[complex64] | None

CF32 output buffer (C-only, hidden from Python).

None

Returns:

Type Description
NDArray[complex64]

Number of output samples written (C-only).

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> t = np.arange(4096)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> y = ddc.execute(x)
>>> y.shape
(1024,)
>>> y.dtype
dtype('complex64')
>>> round(float(abs(y[500])), 2)   # shifted to DC; amplitude ≈ 1
1.0

execute_max_out

execute_max_out(x_len: int) -> int

Maximum output samples one execute() of x_len inputs can produce.

A DDC decimates (or passes at unity), so the output never exceeds the

input length: returns x_len. The binding sizes the output buffer to this

per-call bound and resizes down to the actual count (gh-607).

Parameters:

Name Type Description Default
x_len int

Number of input samples the matching execute() call sees.

required

Returns:

Type Description
int

x_len (a safe upper bound on the produced samples).

execute_ctrl

execute_ctrl(
    x: NDArray[complex64],
    rate_ctrl: float,
    freq_ctrl: float,
) -> NDArray[np.complex64]

Mix and resample a block, steering both control ports.

The control-port form of ddc_execute(): the LO advances by phase_inc + freq_ctrl on every sample of this block, and the cascade's terminal stage runs at stage_rate + rate_ctrl. Neither deviation is persisted — the centre norm_freq and rate are untouched — so a tracking loop passes its full filter output on every call and the DDC holds no loop state of its own.

Feeding a stream through ddc_execute_ctrl_push() one sample at a time reproduces this call bit-for-bit when both controls are held constant, so the cheap block form stays correct for open-loop use (a fixed Doppler offset, a rate trim) and the push form is what a closed loop uses.

Parameters:

Name Type Description Default
x NDArray[complex64]

CF32 input block.

required
rate_ctrl float

Rate deviation added to the terminal Resampler stage's rate. Referenced to the terminal (post-decimation) rate, not the overall rate; ignored by a plan whose last stage is an integer HB/CIC with nothing to steer.

required
freq_ctrl float

Frequency deviation added to the LO, in cycles/sample at the INPUT rate (any sign).

required

Returns:

Type Description
NDArray[complex64]

Number of output samples written.

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=0.0, rate=0.25)   # LO centred at DC
>>> t = np.arange(4096)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> y = ddc.execute_ctrl(x, 0.0, -0.1)    # freq_ctrl steers +0.1 to DC
>>> y.shape
(1024,)
>>> round(float(abs(y[100:].mean())), 2)  # settled output sits at DC
1.0

execute_ctrl_push

execute_ctrl_push(
    x: complex,
    rate_ctrl: float,
    freq_ctrl: float,
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Push ONE input sample; emit whatever outputs it completes.

The per-input streaming form of ddc_execute_ctrl(), and the only form a closed loop can use: a block call has to know its whole control history up front, whereas a carrier or timing loop computes each correction from the outputs already emitted. Both loops close once per symbol, so both ports need this form.

The mix costs one LO step per input; the cascade then emits 0 outputs (the common decimating case, between strobes), 1, or several.

Parameters:

Name Type Description Default
x complex

One CF32 input sample.

required
rate_ctrl float

Rate deviation for this input (terminal-stage rate).

required
freq_ctrl float

Frequency deviation for this input, cycles/sample at the input rate.

required
out NDArray[complex64] | None

Output buffer for any emitted samples.

None

Returns:

Type Description
NDArray[complex64]

Number of outputs written (0, 1, or more).

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> t = np.arange(64)
>>> x = np.exp(1j * 2 * np.pi * 0.1 * t).astype(np.complex64)
>>> outs = [ddc.execute_ctrl_push(complex(s), 0.0, 0.0) for s in x]
>>> int(sum(len(o) for o in outs))   # 64 inputs, rate 1/4 -> 16 outs
16
>>> [len(o) for o in outs[:4]]        # 0 outs until a strobe completes
[0, 0, 0, 1]

execute_ctrl_push_max_out

execute_ctrl_push_max_out() -> int

Largest number of samples execute_ctrl_push() can return in the current state.

Size an out= buffer with this before calling execute_ctrl_push(), or use it to allocate one up front. The bound is this object's own: what it depends on is a property of the algorithm, so a header block on execute_ctrl_push_max_out() replaces this text.

Returns:

Type Description
int

Upper bound on the output length; the actual call may return fewer.

reset

reset() -> None

Zero LO phase and filter history.

Examples:

>>> from doppler.ddc import DDC
>>> import numpy as np
>>> ddc = DDC(norm_freq=0.0, rate=0.25)
>>> x = np.ones(64, dtype=np.complex64)
>>> y1 = ddc.execute(x)
>>> ddc.reset()
>>> y2 = ddc.execute(x)
>>> bool(np.array_equal(y1, y2))
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the MatchedDDC has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the MatchedDDC has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the MatchedDDC has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> MatchedDDC

Enter a context manager, returning this object.

Lets a MatchedDDC be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
MatchedDDC

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the MatchedDDC.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Ddcr

Create a real-input Digital Down-Converter (Architecture D2). The signal chain is: halfband R2C (2:1, bakes in +fs/4 shift) -> fine LO mix at the intermediate rate (fs_in/2) -> RateConverter -> CF32 output. The halfband stage uses +-1/0 coefficients (no multiplications) and puts the fine LO and the cascade at fs_in/2. That is worth ~1.1-1.7x in a whole receiver (it halves the rate ahead of the polyphase matched filter, so the gain grows with samples/symbol) and close to nothing for the front end alone -- see the file header for the measurements. Use it because the input IS real.

Parameters:

Name Type Description Default
norm_freq float

Fine NCO frequency at the intermediate rate (fs_in/2, cycles/sample). To tune a real tone at normalised input frequency f_c to DC, set norm_freq = -(2*f_c + 0.5).

0.0
rate float

Total output/input rate. Must be in (0, 0.5) because the halfband pre-decimates by 2.

0.25

Raises:

Type Description
ValueError

If construction fails. The exception message is Ddcr: invalid parameter (need 0 < rate < 0.5, 0 <= beta <= 1, span >= 1, pulse_sps > 0, num_phases a power of two >= 2).

Examples:

>>> from doppler.ddc import Ddcr
>>> ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
>>> ddcr.norm_freq
-0.7
>>> ddcr.rate
0.25

norm_freq property writable

norm_freq: float

Return the current fine NCO normalised frequency at the intermediate rate (fs_in/2, cycles/sample).

rate property

rate: float

Return the total configured rate (fs_out / fs_in, read-only). This is the end-to-end ratio from ADC input to CF32 output. Change it by destroying and recreating the DDCR.

clipped property

clipped: bool

Has the cascade's CIC clipped its input since the last reset?

narrow_pulse property

narrow_pulse: bool

Is this object's rectangular matched filter degenerately narrow?

execute

execute(
    x: NDArray[float32],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Down-convert a block of real float32 samples to CF32 baseband.

Parameters:

Name Type Description Default
x NDArray[float32]

Input.

required
out NDArray[complex64] | None

CF32 output buffer (C-only, hidden from Python).

None

Returns:

Type Description
NDArray[complex64]

Number of output samples written (C-only).

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
>>> t = np.arange(4096)
>>> x = np.cos(2 * np.pi * 0.1 * t).astype(np.float32)
>>> out = np.empty(len(x), dtype=np.complex64)
>>> y = ddcr.execute(x, out)
>>> y.shape
(1024,)
>>> y.dtype
dtype('complex64')
>>> round(float(abs(y[500])), 2)   # analytic signal of a unit cosine
1.0

execute_max_out

execute_max_out() -> int

Upper bound on one execute call's output, or 0 to let the caller size it from the input block (a decimator never exceeds its input).

Returns:

Type Description
int

Output.

execute_ctrl

execute_ctrl(
    x: NDArray[float32], rate_ctrl: float, freq_ctrl: float
) -> NDArray[np.complex64]

Process a real block, steering both control ports.

The control-port form of ddcr_execute(); see ddc_execute_ctrl() for the semantics, which are identical except for where the LO lives.

Parameters:

Name Type Description Default
x NDArray[float32]

Real float32 input block.

required
rate_ctrl float

Rate deviation added to the terminal Resampler stage's rate (referenced to the terminal, post-decimation rate).

required
freq_ctrl float

Frequency deviation added to the fine LO, in cycles/sample at the INTERMEDIATE rate (fs_in/2) — the halfband has already decimated by two by the time the mix happens, so a discriminator working in cycles per ADC sample must be doubled before it lands here.

required

Returns:

Type Description
NDArray[complex64]

Number of output samples written.

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.5, rate=0.25)  # LO 0.2 short of tune
>>> t = np.arange(4096)
>>> x = np.cos(2 * np.pi * 0.1 * t).astype(np.float32)
>>> y = ddcr.execute_ctrl(x, 0.0, -0.2)     # ctrl completes the tune
>>> y.shape
(1024,)
>>> round(float(abs(y[100:].mean())), 2)    # real tone -> DC, amp 1.0
1.0

execute_ctrl_push

execute_ctrl_push(
    x: float,
    rate_ctrl: float,
    freq_ctrl: float,
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Push ONE real input sample; emit whatever outputs it completes.

The per-input streaming form of ddcr_execute_ctrl(), for a closed loop. The halfband consumes two inputs per intermediate sample, so every other push does no mixing and emits nothing at all — the LO advances (and its control is applied) once per intermediate sample, which is the rate the LO runs at.

Parameters:

Name Type Description Default
x float

One real float32 input sample.

required
rate_ctrl float

Rate deviation for this input (terminal-stage rate).

required
freq_ctrl float

Frequency deviation, cycles/sample at fs_in/2.

required
out NDArray[complex64] | None

Output buffer for any emitted samples.

None

Returns:

Type Description
NDArray[complex64]

Number of outputs written (0, 1, or more).

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
>>> x = np.cos(2 * np.pi * 0.1 * np.arange(128)).astype(np.float32)
>>> outs = [ddcr.execute_ctrl_push(float(s), 0.0, 0.0) for s in x]
>>> int(sum(len(o) for o in outs))  # 128 real inputs, rate 1/4 -> 32
32
>>> [len(o) for o in outs[:4]]      # halfband: 0 until a strobe
[0, 0, 0, 1]

execute_ctrl_push_max_out

execute_ctrl_push_max_out() -> int

Bound for ONE pushed input: ceil(rate) + 1 output periods. Non-zero because the push form has no input block to size from.

Returns:

Type Description
int

Output.

reset

reset() -> None

Zero halfband history, LO phase and filter history.

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=0.0, rate=0.25)
>>> x = np.ones(64, dtype=np.float32)
>>> out = np.empty(64, dtype=np.complex64)
>>> y1 = ddcr.execute(x, out).copy()
>>> ddcr.reset()
>>> y2 = ddcr.execute(x, out)
>>> bool(np.array_equal(y1, y2))
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the Ddcr has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the Ddcr has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the Ddcr has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

close

close() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Ddcr

Enter a context manager, returning this object.

Lets a Ddcr be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Ddcr

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Ddcr.

Equivalent to calling close(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

MatchedDdcr

Create a real-input DDC whose terminal stage IS a matched filter.

Parameters:

Name Type Description Default
norm_freq float

Fine NCO frequency at the INTERMEDIATE rate (fs_in/2) — the same reference ddcr_create() uses.

0.0
rate float

Total output/input rate; must be in (0, 0.5).

0.25
pulse Literal[iandd, rrc]

RC_PULSE_RRC / RC_PULSE_IANDD (RC_PULSE_NONE is invalid here — use ddcr_create()).

"rrc"
beta float

RRC roll-off in [0, 1] (ignored for the rectangle).

0.35
span int

One-sided RRC span in symbols (ignored for the rectangle).

8
pulse_sps float

The pulse's period in output samples.

2.0
num_phases int

Terminal-stage arms; a power of two.

1024

Raises:

Type Description
ValueError

If construction fails. The exception message is Ddcr: invalid parameter (need 0 < rate < 0.5, 0 <= beta <= 1, span >= 1, pulse_sps > 0, num_phases a power of two >= 2).

Examples:

>>> from doppler.ddc import MatchedDdcr
>>> rx = MatchedDdcr(norm_freq=-0.6875, rate=2 / 16, pulse="rrc")
>>> rx.rate
0.125

norm_freq property writable

norm_freq: float

Return the current fine NCO normalised frequency at the intermediate rate (fs_in/2, cycles/sample).

rate property

rate: float

Return the total configured rate (fs_out / fs_in, read-only). This is the end-to-end ratio from ADC input to CF32 output. Change it by destroying and recreating the DDCR.

clipped property

clipped: bool

Has the cascade's CIC clipped its input since the last reset?

narrow_pulse property

narrow_pulse: bool

Is this object's rectangular matched filter degenerately narrow?

execute

execute(
    x: NDArray[float32],
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Down-convert a block of real float32 samples to CF32 baseband.

Parameters:

Name Type Description Default
x NDArray[float32]

Input.

required
out NDArray[complex64] | None

CF32 output buffer (C-only, hidden from Python).

None

Returns:

Type Description
NDArray[complex64]

Number of output samples written (C-only).

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
>>> t = np.arange(4096)
>>> x = np.cos(2 * np.pi * 0.1 * t).astype(np.float32)
>>> out = np.empty(len(x), dtype=np.complex64)
>>> y = ddcr.execute(x, out)
>>> y.shape
(1024,)
>>> y.dtype
dtype('complex64')
>>> round(float(abs(y[500])), 2)   # analytic signal of a unit cosine
1.0

execute_max_out

execute_max_out() -> int

Upper bound on one execute call's output, or 0 to let the caller size it from the input block (a decimator never exceeds its input).

Returns:

Type Description
int

Output.

execute_ctrl

execute_ctrl(
    x: NDArray[float32], rate_ctrl: float, freq_ctrl: float
) -> NDArray[np.complex64]

Process a real block, steering both control ports.

The control-port form of ddcr_execute(); see ddc_execute_ctrl() for the semantics, which are identical except for where the LO lives.

Parameters:

Name Type Description Default
x NDArray[float32]

Real float32 input block.

required
rate_ctrl float

Rate deviation added to the terminal Resampler stage's rate (referenced to the terminal, post-decimation rate).

required
freq_ctrl float

Frequency deviation added to the fine LO, in cycles/sample at the INTERMEDIATE rate (fs_in/2) — the halfband has already decimated by two by the time the mix happens, so a discriminator working in cycles per ADC sample must be doubled before it lands here.

required

Returns:

Type Description
NDArray[complex64]

Number of output samples written.

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.5, rate=0.25)  # LO 0.2 short of tune
>>> t = np.arange(4096)
>>> x = np.cos(2 * np.pi * 0.1 * t).astype(np.float32)
>>> y = ddcr.execute_ctrl(x, 0.0, -0.2)     # ctrl completes the tune
>>> y.shape
(1024,)
>>> round(float(abs(y[100:].mean())), 2)    # real tone -> DC, amp 1.0
1.0

execute_ctrl_push

execute_ctrl_push(
    x: float,
    rate_ctrl: float,
    freq_ctrl: float,
    out: NDArray[complex64] | None = None,
) -> NDArray[np.complex64]

Push ONE real input sample; emit whatever outputs it completes.

The per-input streaming form of ddcr_execute_ctrl(), for a closed loop. The halfband consumes two inputs per intermediate sample, so every other push does no mixing and emits nothing at all — the LO advances (and its control is applied) once per intermediate sample, which is the rate the LO runs at.

Parameters:

Name Type Description Default
x float

One real float32 input sample.

required
rate_ctrl float

Rate deviation for this input (terminal-stage rate).

required
freq_ctrl float

Frequency deviation, cycles/sample at fs_in/2.

required
out NDArray[complex64] | None

Output buffer for any emitted samples.

None

Returns:

Type Description
NDArray[complex64]

Number of outputs written (0, 1, or more).

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
>>> x = np.cos(2 * np.pi * 0.1 * np.arange(128)).astype(np.float32)
>>> outs = [ddcr.execute_ctrl_push(float(s), 0.0, 0.0) for s in x]
>>> int(sum(len(o) for o in outs))  # 128 real inputs, rate 1/4 -> 32
32
>>> [len(o) for o in outs[:4]]      # halfband: 0 until a strobe
[0, 0, 0, 1]

execute_ctrl_push_max_out

execute_ctrl_push_max_out() -> int

Bound for ONE pushed input: ceil(rate) + 1 output periods. Non-zero because the push form has no input block to size from.

Returns:

Type Description
int

Output.

reset

reset() -> None

Zero halfband history, LO phase and filter history.

Examples:

>>> from doppler.ddc import Ddcr
>>> import numpy as np
>>> ddcr = Ddcr(norm_freq=0.0, rate=0.25)
>>> x = np.ones(64, dtype=np.float32)
>>> out = np.empty(64, dtype=np.complex64)
>>> y1 = ddcr.execute(x, out).copy()
>>> ddcr.reset()
>>> y2 = ddcr.execute(x, out)
>>> bool(np.array_equal(y1, y2))
True

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the MatchedDdcr has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the MatchedDdcr has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the MatchedDdcr has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

close

close() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> MatchedDdcr

Enter a context manager, returning this object.

Lets a MatchedDdcr be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
MatchedDdcr

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the MatchedDdcr.

Equivalent to calling close(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Ddcr usage patterns

Supplying the output buffer makes allocation strategy and buffer reuse entirely explicit — ideal for streaming and sharded-worker designs.

Match the buffer's dtype

An out= buffer of the wrong dtype is not written: the binding casts it into a temporary, so the returned array is correct but the caller's buffer stays untouched. Always allocate complex64.

Buffer sizing

A decimating Ddcr never produces more output samples than it consumes. An output buffer of len(x) elements is always sufficient; execute returns the trimmed zero-copy view out[:n_out]:

out = np.empty(len(x), dtype=np.complex64)
y = ddcr.execute(x, out)            # len(y) <= len(x), zero-copy view of out

Streaming loop — no per-call allocation

import numpy as np
from doppler.ddc import Ddcr

ddcr = Ddcr(norm_freq=-0.7, rate=0.25)
out = np.empty(4096, dtype=np.complex64)   # allocate once, reuse

def real_adc_stream():                     # your ADC source
    for _ in range(3):
        yield np.random.randn(4096).astype(np.float32)

for x in real_adc_stream():                # x: float32, len 4096
    y = ddcr.execute(x, out)               # y is out[:n_out], zero-copy
    process(y)

Retune mid-stream

new_carrier = 0.15
ddcr.norm_freq = -(2 * new_carrier + 0.5)  # writable property; phase-continuous
y = ddcr.execute(x, out)

rate is read-only (fixed at construction); norm_freq is live-writable.

Multiple independent streams

num_channels, N = 4, 4096
x = [np.random.randn(N).astype(np.float32) for _ in range(num_channels)]
y = [None] * num_channels
chans = [Ddcr(-0.7, 0.25) for _ in range(num_channels)]
bufs  = [np.empty(N, dtype=np.complex64) for _ in range(num_channels)]

for ch, (d, buf) in enumerate(zip(chans, bufs)):
    y[ch] = d.execute(x[ch], buf).copy()   # copy if it must outlive the next call

Throughput

Ddcr and DDC call the same C kernel; the per-call Python overhead is under 1 µs on 65 536-sample blocks.

Threading — the GIL is released across the kernel

Ddcr.execute (like DDC.execute) runs the pure-C kernel with the GIL released (Py_BEGIN_ALLOW_THREADS; numpy accessors hoisted out first). So a thread-per-shard worker — each thread owning its own Ddcr and output buffer — scales across cores instead of serialising on the GIL:

import threading

N = 4096
shards = [[np.random.randn(N).astype(np.float32)] for _ in range(4)]

def worker(ddcr, blocks, out):
    for x in blocks:                 # this thread's own handle + buffer
        process(ddcr.execute(x, out))

threads = [
    threading.Thread(target=worker, args=(Ddcr(-0.7, 0.25),
                                          shard, np.empty(N, np.complex64)))
    for shard in shards
]
for t in threads: t.start()
for t in threads: t.join()

Measured ~5–8× across 8–12 cores (then memory-bandwidth bound). Contract: one Ddcr per stream — never share a handle across threads concurrently (no internal lock). This is the basis of the sharded-microservice model; see the Ddcr gallery walkthrough.

Lifecycle and memory safety

Ddcr is an RAII handle: close() (or a with block) releases the C state deterministically; otherwise the destructor frees it.

Scenario Safe?
GC without close() Yes — destructor frees state
close() then GC Yes — destructor skips already-freed state
Live view after close() Yes — view lives in caller's out buffer, not in state
Second call to close() No-op (idempotent)
Any call after close() Raises RuntimeError
x = np.random.randn(4096).astype(np.float32)
out = np.empty(4096, dtype=np.complex64)
with Ddcr(-0.7, 0.25) as ddcr:      # state released on exit
    y = ddcr.execute(x, out)
    process(y)

DDC Architecture

A DDC shifts a signal from a carrier frequency to DC and optionally decimates it. This section documents the practical architectures, the trade-offs between them, and measured throughput so you can pick the right one.

Signal chain overview

                    ┌─────────────────────────────────────────────┐
  in (fs_in) ──────►│  LO mix ──► [HB ÷2] ──► polyphase resample │──► out (fs_out)
                    └─────────────────────────────────────────────┘

Three stages, each optional or reorderable:

Stage C type Purpose
LO mix lo_state_t Multiply by e^{j2πf_n·t} — shift carrier to DC
Halfband ÷2 hbdecim_state_t Cheap factor-of-2 decimation
Polyphase resample resamp_state_t Continuously-variable rate conversion

ddc_create(norm_freq, rate) chains LO + polyphase resampler with built-in Kaiser coefficients (passband ≤ 0.4·fs_out, stopband ≥ 0.6·fs_out, 60 dB rejection); ddc_create_matched(norm_freq, rate, pulse, …) puts a matched-filter bank on the terminal stage instead — see Matched mode.


Architecture A — Plain DDC (default)

CF32 in ──► LO ──► polyphase resample (0.4/0.6, rate r) ──► CF32 out

ddc_create(norm_freq, rate) with built-in Kaiser bank. No design step required. One allocation, no intermediate buffers.

Best for: prototype, any decimation rate, single-stage simplicity.


Architecture B — Halfband → DDC (complex input)

CF32 in ──► HB ÷2 ──► LO ──► polyphase resample (0.4/0.6, rate 2r) ──► CF32 out

The halfband (N=19, 60 dB) decimates by 2 first. The resampler then runs on half the samples. Architecture B wins at every decimation rate.

Best for: complex IQ input, decimation ≥ 2×. Dominant choice.


Architecture D2 — Real input: zero-multiply band capture + fine NCO

Real in ──► Modified HB (fs/4 shift embedded) ──► Fine LO (at fs/2) ──► resample ──► CF32 out
              zero extra multiplications              arbitrary carrier tune

This is the optimal architecture for any real ADC input. Mixing by fs/4 then decimating by 2 is a lossless real-to-complex conversion — the fs/4 mix multiplies by {1, −j, −1, +j, …} (sign negations only, no multiplications) and is embedded into the halfband tap weights at construction time.

Fine NCO frequency convention: norm_freq = 2*f_tone + 0.5 The +0.5 cancels the halfband's embedded −fs/4 shift.

Cost vs Architecture D (NCO → complex HB → polyphase resample):

Stage Arch D Arch D2
Full-rate NCO 2 MACs
Halfband N/2 MACs (complex) N/4 MACs (real modified)
Fine NCO at fs/2 1 MAC (effective)
Total (N=19) ≈ 11.5 MACs ≈ 5.75 MACs

Architecture D2 is approximately 2× cheaper than Architecture D for real input at any carrier or decimation rate. This is what Ddcr implements.


Architecture E — Coarse/fine LO split (high decimation)

For decimation > 38×, embedding the coarse LO into the polyphase filter taps and running only a fine correction LO at the output rate becomes worthwhile.

Break-even: D ≈ 38× decimation.

Decimation Arch B MACs/input Arch E MACs/input Δ
10× 9.6 15.8 B wins
38× 4.0 4.2 break-even
100× 2.8 1.6 E +43%

Implementation requires a complex-coefficient polyphase variant — planned; not yet in the library.


Performance (Release build, x86-64)

Block = 65536 samples × 200 iterations, M=3 N=19.

Rate Decimation Arch A Arch B Arch C
0.50 61 MSa/s 335 MSa/s 70 MSa/s
0.25 70 MSa/s 76 MSa/s 62 MSa/s
0.10 10× 72 MSa/s 97 MSa/s 74 MSa/s
0.01 100× 85 MSa/s 116 MSa/s 80 MSa/s

Architecture B wins at every rate.


Decision guide

Is your input real (single ADC channel)?
  YES ─► Ddcr / Architecture D2
  │        ~2× cheaper at any carrier, any decimation rate
  │        └─ Decimation > 38× after the HB?
  │              ─► Architecture E (planned): embed LO into polyphase taps
  NO (complex IQ)
  ├─ Total decimation = 1× ─► DDC without resampler (plain NCO mix)
  ├─ Total decimation 2× – 38× ─► DDC / Architecture B (dominant)
  └─ Total decimation > 38× ─► Architecture E (planned)

C code examples

Architecture A — one call

#include <ddc/ddc_core.h>

int main(void)
{
  float _Complex in[4096]  = { 0 };   /* fill with your samples */
  float _Complex out[4096];

  ddc_state_t *ddc = ddc_create(-0.1, 0.25);
  size_t n = ddc_execute(ddc, in, 4096, out, 4096);
  (void)n;
  ddc_destroy(ddc);
  return 0;
}

Architecture B — halfband then DDC

hbdecim_create needs real Kaiser-designed halfband taps (see native/src/resample/resample_ext_extra.c for the reference composition — tap design is Python-side, via kaiser_window), so this sketch omits that step to keep the shape of the composition visible:

hbdecim_state_t *hb  = hbdecim_create(num_taps, h);   /* h: Kaiser-designed taps */
ddc_state_t     *ddc = ddc_create(norm_freq, rate * 2.0);

float _Complex mid[num_in / 2 + 32];
float _Complex out[num_in];

size_t n_mid = hbdecim_execute(hb, in, num_in, mid,
                               sizeof mid / sizeof mid[0]);
size_t n_out = ddc_execute(ddc, mid, n_mid, out, num_in);

hbdecim_destroy(hb);
ddc_destroy(ddc);

GalleryDdcr — Real Passband to Baseband, Gallery, M-PSK Receiver — Pull-in, Lock, and BER GuidesPower Spectra & Measurements, Checkpoint & Resume DesignDesign — pure-functional acquisition kernel (elastic fleet), API taxonomy: the DSP building-block hierarchy and its naming axis, AsyncDsssReceiver — the continuous DSSS receiver, from spec to object, BurstBank — the coarse-Doppler bank as one C object, BurstCapture: acquisition's output, turned into bursts, CoarseChannel — is a channel an object, or a slice of the bank?, DsssBurstReceiver: the burst chain, composed in C, MPSK Receiver, The NCO, Symbol Timing on a Rate Cascade, State Serialization — the standard bytes interface