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 |
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
¶
Return the current LO normalised frequency (cycles/sample).
rate
property
¶
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.
narrow_pulse
property
¶
Is this object's rectangular matched filter degenerately narrow?
execute
¶
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
¶
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
¶
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
¶
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
¶
state_bytes
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
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 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 |
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.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
|
1024
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
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
¶
Return the current LO normalised frequency (cycles/sample).
rate
property
¶
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.
narrow_pulse
property
¶
Is this object's rectangular matched filter degenerately narrow?
execute
¶
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
¶
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
¶
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
¶
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
¶
state_bytes
¶
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
¶
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, |
set_state
¶
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 |
required |
destroy
¶
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 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 |
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
¶
Return the current fine NCO normalised frequency at the intermediate rate (fs_in/2, cycles/sample).
rate
property
¶
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.
narrow_pulse
property
¶
Is this object's rectangular matched filter degenerately narrow?
execute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
close
¶
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
¶
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 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.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 |
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
¶
Return the current fine NCO normalised frequency at the intermediate rate (fs_in/2, cycles/sample).
rate
property
¶
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.
narrow_pulse
property
¶
Is this object's rectangular matched filter degenerately narrow?
execute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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, |
set_state
¶
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 |
required |
close
¶
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
¶
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 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)¶
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)¶
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 | 2× | 61 MSa/s | 335 MSa/s | 70 MSa/s |
| 0.25 | 4× | 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);
Related pages¶
Gallery — Ddcr — Real Passband to Baseband, Gallery, M-PSK Receiver — Pull-in, Lock, and BER
Guides — Power Spectra & Measurements, Checkpoint & Resume
Design — Design — 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