DDC
Digital Down-Converter — shifts a carrier to baseband and decimates in
one call, backed by dp_ddc_t and dp_ddc_real_t.
Source:
src/doppler/ddc/__init__.py
Which class to use
| Class | Input | Cost | Use when |
|---|---|---|---|
Ddc |
CF32 IQ | baseline | complex ADC, already at fs |
DDCR |
float32 real | ~2× cheaper | real ADC, direct-sampling SDR |
Both produce CF32 IQ at the decimated output rate.
Ddc — complex input
Chains: NCO frequency shift → DPMFS polyphase decimator. Built-in M=3 N=19 Kaiser-DPMFS bank — no filter design required.
Frequency convention: norm_freq = -f_tone shifts a tone at
f_tone (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, num_in=4096, 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)} max_out={ddc.max_out}")
Retune without reset
ddc.set_freq(-0.2) # NCO retuned; delay-line history preserved
y = ddc.execute(next_block)
Phase-continuous across blocks
ddc = Ddc(-0.1, 4096, 0.25)
for block in iq_stream: # generator of 4096-sample CF32 arrays
out = ddc.execute(block)
process(out)
DDCR — real input (Architecture D2)
Chains: halfband 2:1 decimation with embedded −fs/4 shift (free) → fine NCO at the fs/2 intermediate rate → DPMFS decimation.
~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.
Frequency convention: norm_freq = 2*f_tone + 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_tone=0.1·fs; real ADC at 4096 samples/block
# norm_freq = 2*0.1 + 0.5 = 0.7
ddc = DDCR(norm_freq=0.7, num_in=4096, rate=0.25)
x = np.random.randn(4096).astype(np.float32) # real ADC samples
y = ddc.execute(x) # CF32 output, len(y) ≈ 4096/2 * 0.25 = 512
Output size
Both classes expose max_out (worst-case allocation) and nout (actual
count from the last call).
buf = np.empty(ddc.max_out, dtype=np.complex64)
y = ddc.execute(x)
assert len(y) == ddc.nout
Ddc
Complex-input Digital Down-Converter.
Wraps dp_ddc_t. Chains an NCO (frequency shift to DC) with a
DPMFS polyphase resampler using built-in M=3 N=19 Kaiser-DPMFS
coefficients — no filter design required.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
norm_freq
|
float
|
NCO frequency normalised to fs. Use |
required |
num_in
|
int
|
Fixed input block size in complex samples. |
required |
rate
|
float
|
Output rate relative to input ( |
required |
Examples:
>>> import numpy as np
>>> from doppler.ddc import Ddc
>>> ddc = Ddc(-0.1, 4096, 0.25)
>>> x = np.zeros(4096, dtype=np.complex64)
>>> y = ddc.execute(x)
>>> y.dtype
dtype('complex64')
>>> len(y) <= ddc.max_out
True
max_out
property
max_out: int
Worst-case output length for one execute call.
nout
property
nout: int
Actual output length from the last execute call.
execute
execute(x: NDArray[complex64]) -> NDArray[np.complex64]
Frequency-shift and decimate one block of CF32 samples.
set_freq
set_freq(norm_freq: float) -> None
Retune the NCO without resetting the delay-line history.
get_freq
get_freq() -> float
Return the current NCO normalised frequency.
reset
reset() -> None
Zero all internal state (delay lines, NCO phase).
DDCR
Architecture D2 Digital Down-Converter (real input).
Wraps dp_ddc_real_t. Accepts real float32 ADC samples and
produces CF32 IQ at the output rate. Internally chains:
- Halfband 2:1 decimation with embedded fs/4 shift → CF32 at fs/2
- Fine NCO + DPMFS decimation from fs/2 to
rate * fs/2
~2× cheaper than :class:Ddc for any real-ADC source.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
norm_freq
|
float
|
Fine NCO frequency at the fs/2 intermediate rate.
Convention: use |
required |
num_in
|
int
|
Fixed input block size in real samples at fs. |
required |
rate
|
float
|
|
required |
Examples:
>>> import numpy as np
>>> from doppler.ddc import DDCR
>>> ddc = DDCR(0.5, 4096, 0.25)
>>> x = np.zeros(4096, dtype=np.float32)
>>> y = ddc.execute(x)
>>> y.dtype
dtype('complex64')
>>> len(y) <= ddc.max_out
True
max_out
property
max_out: int
Maximum output samples per :meth:execute call.
nout
property
nout: int
Actual output count from the last :meth:execute call.
execute
execute(x: ndarray) -> np.ndarray
Down-convert a block of real float32 samples.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
ndarray
|
Real input samples, dtype float32, length |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
CF32 output, length |
set_freq
set_freq(norm_freq: float) -> None
Retune the fine NCO without resetting phase.
get_freq
get_freq() -> float
Return the current fine NCO normalised frequency.
reset
reset() -> None
Zero NCO phase, halfband history, and resampler state.
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) ──────►│ NCO mix ──► [HB ÷2] ──► DPMFS resample │──► out (fs_out)
└─────────────────────────────────────────────┘
Three stages, each optional or reorderable:
| Stage | C type | Purpose |
|---|---|---|
| NCO mix | dp_nco_t |
Multiply by e^{j2πf_n·t} — shift carrier to DC |
| Halfband ÷2 | dp_hbdecim_cf32_t |
Cheap factor-of-2 decimation |
| DPMFS resample | dp_resamp_dpmfs_t |
Continuously-variable rate conversion |
The default dp_ddc_create chains NCO + DPMFS with built-in M=3 N=19
Kaiser-DPMFS coefficients (passband ≤ 0.4·fs_out, stopband ≥ 0.6·fs_out,
60 dB rejection).
Architecture A — Plain DDC (default)
CF32 in ──► NCO ──► DPMFS (0.4/0.6, M=3 N=19, rate r) ──► CF32 out
dp_ddc_create(norm_freq, num_in, rate) with default coefficients.
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 ──► NCO ──► DPMFS (0.4/0.6, M=3 N=19, rate 2r) ──► CF32 out
The halfband (N=19, 60 dB) decimates by 2 first. The DPMFS 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 NCO (at fs/2) ──► DPMFS ──► 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 → DPMFS):
| 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 NCO split (high decimation)
For decimation > 38×, embedding the coarse NCO into the DPMFS filter taps and running only a fine correction NCO 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 DPMFS variant
(dp_resamp_dpmfs_cf32_create) — 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 NCO into DPMFS 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
dp_ddc_t *ddc = dp_ddc_create(-0.1f, 4096, 0.25);
dp_cf32_t out[dp_ddc_max_out(ddc)];
size_t n = dp_ddc_execute(ddc, in, 4096, out, dp_ddc_max_out(ddc));
dp_ddc_destroy(ddc);
Architecture B — halfband then DDC
dp_hbdecim_cf32_t *hb = dp_hbdecim_cf32_create(N_hb, h_fir);
dp_ddc_t *ddc = dp_ddc_create(norm_freq, num_in / 2, rate * 2.0);
dp_cf32_t mid[num_in / 2 + N_hb + 2];
dp_cf32_t out[dp_ddc_max_out(ddc)];
size_t n_mid = dp_hbdecim_cf32_execute(hb, in, num_in, mid,
sizeof mid / sizeof mid[0]);
size_t n_out = dp_ddc_execute(ddc, mid, n_mid, out, dp_ddc_max_out(ddc));
dp_hbdecim_cf32_destroy(hb);
dp_ddc_destroy(ddc);