File ddc_core.h¶
FileList > ddc > ddc_core.h
Go to the source code of this file
Digital Down-Converter — composes LO + RateConverter cascade. More...
#include <complex.h>#include <stdbool.h>#include <stddef.h>#include "lo/lo_core.h"#include "RateConverter/RateConverter_core.h"#include "resamp/resamp_core.h"#include "hbdecim/hbdecim_core.h"#include "cic/cic_core.h"#include "fir/fir_core.h"#include "resample/resample_core.h"#include "agc/agc_core.h"#include "dp_tlm/dp_tlm_core.h"
Classes¶
| Type | Name |
|---|---|
| struct | ddc_extra_t |
| struct | ddc_state Ddc state — an LO and the cascade it feeds. |
Public Types¶
| Type | Name |
|---|---|
| typedef struct ddc_state | ddc_state_t Ddc state — an LO and the cascade it feeds. |
Public Functions¶
| Type | Name |
|---|---|
| ddc_state_t * | ddc_create (double norm_freq, double rate) 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. |
| ddc_state_t * | ddc_create_matched (double norm_freq, double rate, int pulse, double beta, size_t span, double pulse_sps, size_t num_phases) Create a DDC whose cascade's terminal stage IS a matched filter. |
| void | ddc_destroy (ddc_state_t * state) Free all resources held by a DDC instance. Releases the RateConverter and LO substructures, then the struct itself. Passing NULL is a no-op. |
| size_t | ddc_execute (ddc_state_t * state, const float _Complex * x, size_t x_len, float _Complex * out, size_t max_out) Mix and resample a block of CF32 samples. Multiplies each input sample by the current LO phasor (advancing the NCO phase per sample), then feeds the mixed block into the RateConverter. The resampler maintains history across calls, so arbitrary block sizes produce contiguous output with no edge artefacts. Output length ≈ x_len * rate (varies by ±1 due to polyphase indexing). |
| size_t | ddc_execute_ctrl (ddc_state_t * state, const float _Complex * x, size_t x_len, double rate_ctrl, double freq_ctrl, float _Complex * out, size_t max_out) Mix and resample a block, steering both control ports. |
| size_t | ddc_execute_ctrl_max_out (ddc_state_t * state, size_t x_len) |
| size_t | ddc_execute_ctrl_push (ddc_state_t * state, float _Complex x, double rate_ctrl, double freq_ctrl, float _Complex * out, size_t max_out) Push ONE input sample; emit whatever outputs it completes. |
| size_t | ddc_execute_ctrl_push_max_out (ddc_state_t * state) |
| size_t | ddc_execute_ctrl_push_tap (ddc_state_t * state, float _Complex x, double rate_ctrl, double freq_ctrl, float _Complex * out, size_t max_out, float _Complex * lo_out, int * n_lo) ddc_execute_ctrl_push() that also hands back the post-LO sample. |
| size_t | ddc_execute_ctrl_push_tap2 (ddc_state_t * state, float _Complex x, double rate_ctrl, double freq_ctrl, float _Complex * out, size_t max_out, float _Complex * lo_out, int * n_lo, float _Complex * pre_out, int * n_pre) ddc_execute_ctrl_push_tap() , plus the PRE-TERMINAL tap. |
| size_t | ddc_execute_max_out (ddc_state_t * state, size_t x_len) Maximum output samples one execute() of x_len inputs can produce. |
| double | ddc_get_bank_sps (const ddc_state_t * state) Samples per symbol of the pre-terminal tap; a planner outcome. |
| bool | ddc_get_clipped (const ddc_state_t * state) Has the cascade's CIC clipped its input since the last reset? |
| bool | ddc_get_narrow_pulse (const ddc_state_t * state) Is this object's rectangular matched filter degenerately narrow? |
| double | ddc_get_norm_freq (const ddc_state_t * state) Return the current LO normalised frequency (cycles/sample). |
| double | ddc_get_rate (const ddc_state_t * state) 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. |
| void | ddc_get_state (const ddc_state_t * state, void * blob) Serialize state's LO + RateConverter state intoblob . |
| void | ddc_reset (ddc_state_t * state) Zero LO phase and resampler history. After reset, the next execute call produces the same output as the first execute after create — useful for reproducible block-by-block processing or looped test fixtures. |
| size_t | ddc_run (ddc_state_t * state, const void * state_in, void * state_out, const float _Complex * in, size_t n_in, float _Complex * out, size_t max_out) Pure run: (state_in, input) -> (state_out, output) ; either blob may be NULL (NULL in = current; NULL out = discard). |
| void | ddc_set_norm_freq (ddc_state_t * state, double val) Retune the LO without resetting phase or resampler history. Updates the NCO phase increment atomically so the carrier shift changes seamlessly across block boundaries. The resampler history and LO phase accumulator are left intact, avoiding the transient that a full reset would cause. |
| int | ddc_set_state (ddc_state_t * state, const void * blob) Restore LO + RateConverter state from blob . |
| int | ddc_set_telemetry (ddc_state_t * state, dp_tlm_t * tlm, const char * prefix, uint32_t decim) Attach (or detach) a telemetry context on the cascade's AGC. |
| size_t | ddc_state_bytes (const ddc_state_t * state) Byte size of state's blob (envelope + extra + lo + rc). |
Macros¶
| Type | Name |
|---|---|
| define | DDC_STATE_MAGIC [**DP\_FOURCC**](dp__state_8h.md#define-dp_fourcc) ('D', 'D', 'C', '\_') |
| define | DDC_STATE_VERSION 1u |
Detailed Description¶
Two types:
Ddc — LO mix → RateConverter (the plain flavor) MatchedDDC — the same, with the pulse on the cascade's terminal stage
Streaming: any block size per execute call. The real-input twin lives in ddcr/ddcr_core.h (halfband R2C → LO mix → RateConverter); it is the same chain behind a real-to-complex front end.
RateConverter selects the cheapest cascade (CIC + optional halfband + polyphase resampler) for the requested rate at create time. This makes large-ratio decimation (e.g., 100:1) significantly cheaper than a single polyphase stage.
Ddc signal chain¶
norm_freq: NCO normalised frequency (cycles/sample at fs_in). Set to -f_carrier to shift a carrier at f_carrier to DC.
Pulse and the two control ports¶
Both this type and its real-input twin have a matched flavor (ddc_create_matched / ddcr_create_matched), which is passed straight through to the cascade: the terminal stage carries a matched-filter bank instead of the default Kaiser one, so the chain mixes, decimates and matched-filters in the same dot products it was already doing (see RateConverter_create_matched()).
That makes a DDC steerable on two ports, which are duals of each other:
freq_ctrl ──> LO phase accumulator (carrier, at the INPUT rate)
rate_ctrl ──> terminal stage accumulator (timing, at the OUTPUT rate)
Both are per-input deviations added on top of the configured centre value for that sample only, so a tracking loop supplies its full filter output every time and the DDC holds no loop state. A receiver therefore closes a carrier loop and a timing loop with the same loop_filter, one per port — the object itself contains no loop.
The LO sits at the input rate (the intermediate rate fs_in/2 for DdcR), which is where predetection de-rotation belongs: the carrier is wiped off before any filter narrows the band around it.
Retuning vs. rebuilding¶
- Retune (centre-frequency change): call ddc_set_norm_freq / ddcr_set_norm_freq. Cheap — updates the LO phase increment without disturbing the resampler history. Seamless across block boundaries.
- Rate change (span / decimation change): destroy and recreate the DDC for the new rate.
Usage¶
// Complex DDC: shift a carrier at +0.1·fs to DC, decimate by 4
ddc_state_t *ddc = ddc_create(-0.1, 0.25);
float _Complex out[4096];
size_t n = ddc_execute(ddc, in, 1024, out, 4096);
ddc_destroy(ddc);
Public Types Documentation¶
typedef ddc_state_t¶
Ddc state — an LO and the cascade it feeds.
Do not initialise directly; use ddc_create() or ddc_create_matched().
Public Functions Documentation¶
function ddc_create¶
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:
norm_freqLO 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.rateOutput rate / input rate. Must be > 0. Values >= 1 are up-sampling; typical use is decimation (0 < rate < 1).
Returns:
Non-NULL on success, NULL on OOM or invalid args.
>>> from doppler.ddc import DDC
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> ddc.norm_freq
-0.1
>>> ddc.rate
0.25
function ddc_create_matched¶
Create a DDC whose cascade's terminal stage IS a matched filter.
ddc_state_t * ddc_create_matched (
double norm_freq,
double rate,
int pulse,
double beta,
size_t span,
double pulse_sps,
size_t num_phases
)
The matched flavor of the same object — same state, same methods, one different constructor (Python: MatchedDDC). The pulse is a straight passthrough to the cascade, so everything RateConverter_create_matched() documents holds here unchanged: the terminal fractional stage always exists, the bank is sized by the POST-decimation rate, and the CIC droop folds into the bank rather than costing a stage. What this layer adds is the mix in front of it, and with it the second control port — ddc_execute_ctrl() steers the matched filter's polyphase arm (timing) and the LO's phase accumulator (carrier) together.
Droop compensation is not a parameter because it is unconditional here: the fold is worth 28 dB of EVM for six taps per arm and no extra pass over the data, so no operating point wants it off. (The plain ddc_create() path is unchanged and uncompensated.)
Parameters:
norm_freqLO frequency in cycles/sample at the input rate, as ddc_create().rateOutput-to-input sample rate ratio. Rate-agnostic: a caller wantingmoutputs per symbol asks forrate = m/sps; the cascade never learns about symbols.pulseRC_PULSE_RRC / RC_PULSE_IANDD. RC_PULSE_NONE is invalid here — use ddc_create() for a plain down-conversion.betaRRC roll-off in[0, 1](ignored for the rectangle).spanOne-sided RRC span in symbols (ignored for the rectangle, whose support is exactly one symbol).pulse_spsThe pulse's period in output samples (2 = two samples per symbol out).num_phasesTerminal-stage arms; a power of two. Sets the timing resolution to1/num_phasesof an output period.
Returns:
Non-NULL on success, NULL on a bad parameter or OOM.
>>> from doppler.ddc import MatchedDDC
>>> rx = MatchedDDC(norm_freq=-0.1, rate=2 / 16, pulse="rrc")
>>> rx.rate
0.125
function ddc_destroy¶
Free all resources held by a DDC instance. Releases the RateConverter and LO substructures, then the struct itself. Passing NULL is a no-op.
>>> from doppler.ddc import DDC
>>> ddc = DDC(norm_freq=0.0, rate=0.25)
>>> ddc.destroy() # releases C memory immediately
function ddc_execute¶
Mix and resample a block of CF32 samples. Multiplies each input sample by the current LO phasor (advancing the NCO phase per sample), then feeds the mixed block into the RateConverter. The resampler maintains history across calls, so arbitrary block sizes produce contiguous output with no edge artefacts. Output length ≈ x_len * rate (varies by ±1 due to polyphase indexing).
size_t ddc_execute (
ddc_state_t * state,
const float _Complex * x,
size_t x_len,
float _Complex * out,
size_t max_out
)
Parameters:
stateMust be non-NULL.xCF32 input block; accepted as float32 (auto-cast).x_lenNumber of input samples (C-only, hidden from Python).outCF32 output buffer (C-only, hidden from Python).max_outOutput buffer capacity (C-only, hidden from Python).
Returns:
Number of output samples written (C-only).
>>> 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
function ddc_execute_ctrl¶
Mix and resample a block, steering both control ports.
size_t ddc_execute_ctrl (
ddc_state_t * state,
const float _Complex * x,
size_t x_len,
double rate_ctrl,
double freq_ctrl,
float _Complex * out,
size_t max_out
)
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:
stateMust be non-NULL.xCF32 input block.x_lenNumber of input samples.rate_ctrlRate 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.freq_ctrlFrequency deviation added to the LO, in cycles/sample at the INPUT rate (any sign).outCF32 output buffer.max_outCapacity ofoutin samples.
Returns:
Number of output samples written.
>>> 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
function ddc_execute_ctrl_max_out¶
function ddc_execute_ctrl_push¶
Push ONE input sample; emit whatever outputs it completes.
size_t ddc_execute_ctrl_push (
ddc_state_t * state,
float _Complex x,
double rate_ctrl,
double freq_ctrl,
float _Complex * out,
size_t max_out
)
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:
stateMust be non-NULL.xOne CF32 input sample.rate_ctrlRate deviation for this input (terminal-stage rate).freq_ctrlFrequency deviation for this input, cycles/sample at the input rate.outOutput buffer for any emitted samples.max_outCapacity ofout(emission stops at this bound).
Returns:
Number of outputs written (0, 1, or more).
>>> 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]
function ddc_execute_ctrl_push_max_out¶
function ddc_execute_ctrl_push_tap¶
ddc_execute_ctrl_push() that also hands back the post-LO sample.
size_t ddc_execute_ctrl_push_tap (
ddc_state_t * state,
float _Complex x,
double rate_ctrl,
double freq_ctrl,
float _Complex * out,
size_t max_out,
float _Complex * lo_out,
int * n_lo
)
Identical in every respect, plus a tap on the signal between the mix and the cascade — de-rotated, but not yet decimated or matched-filtered.
The tap exists because a carrier discriminator's unambiguous frequency range is set by the rate it UPDATES at: an M-th-power detector running at rate F can only see |df| < F/(2M). Take it from the terminal stage's on-time strobe and that rate is the symbol rate, which is the cleanest possible input and the narrowest possible pull-in. Take it here and the rate is the full input rate — sps times wider — at the cost of no matched filtering, so a caller wanting SNR back must run its own arm filter over this stream. That trade is the caller's to make, which is why this is a tap rather than a mode.
Parameters:
stateMust be non-NULL.xOne CF32 input sample.rate_ctrlRate deviation for this input (terminal-stage rate).freq_ctrlFrequency deviation for this input, cycles/sample at the input rate.outOutput buffer for any emitted outputs.max_outCapacity ofout(emission stops at this bound).lo_outReceives the post-LO, pre-cascade sample whenn_locomes back 1. May be NULL.n_loReceives 1 (this front end mixes every input, so always 1 here; the real-input twin gates on its halfband and can return 0). May be NULL.
Returns:
Number of terminal outputs written (0, 1, or more).
function ddc_execute_ctrl_push_tap2¶
ddc_execute_ctrl_push_tap() , plus the PRE-TERMINAL tap.
size_t ddc_execute_ctrl_push_tap2 (
ddc_state_t * state,
float _Complex x,
double rate_ctrl,
double freq_ctrl,
float _Complex * out,
size_t max_out,
float _Complex * lo_out,
int * n_lo,
float _Complex * pre_out,
int * n_pre
)
Two taps, at the two points a carrier discriminator can read without symbol timing, and they are not equivalent:
| tap | where | cost |
|---|---|---|
lo_out |
post-LO, pre-cascade | full input noise BW |
pre_out |
post-cascade, post-AGC, pre-MF | none of the above |
pre_out is the better-conditioned of the two for the reasons docs/design/mpsk.md §3.3 gives: the cascade's own filters have already band-limited it and the AGC has already levelled it, so a half-symbol arm filter bolted onto lo_out is a hand-rolled approximation of what this node gives for free. Its rate is ddc_get_bank_sps() samples per symbol.
Note:
"Better conditioned" is not "more accurate", and the distinction is measured rather than assumed. The retired tap sweep found no residual-frequency-error advantage for this node over the symbol-rate strobe — three taps carrying one loop bandwidth over one signal settle to the same jitter. What it buys is a usable discriminator with no symbol timing and no arm filter; see doppler#766 for the pull-in-range question that would actually separate them.
Parameters:
stateMust be non-NULL.xOne CF32 input sample.rate_ctrlRate deviation for this input (terminal-stage rate).freq_ctrlFrequency deviation for this input, cycles/sample at the input rate.outOutput buffer for any emitted outputs.max_outCapacity ofout(emission stops at this bound).lo_outReceives the post-LO, pre-cascade sample whenn_locomes back 1. May be NULL.n_loReceives 1 (this front end mixes every input, so always 1 here). May be NULL.pre_outReceives the pre-terminal sample; may be NULL.n_preReceives 1 ifpre_outwas written, else 0; may be NULL. A non-terminal stage swallows inputs between its decimation strobes, so this is 0 on those calls.
Returns:
Number of terminal outputs written (0, 1, or more).
function ddc_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:
stateMust be non-NULL.x_lenNumber of input samples the matching execute() call sees.
Returns:
x_len (a safe upper bound on the produced samples).
function ddc_get_bank_sps¶
Samples per symbol of the pre-terminal tap; a planner outcome.
function ddc_get_clipped¶
Has the cascade's CIC clipped its input since the last reset?
Forwarded from RateConverter_get_clipped(): a CIC bounds its input to |Re|, |Im| <= 2.0 (CIC_PAPR_HEADROOM, 6 dB above unity — see cic_core.h) and clips silently past it — the output stays finite and plausible, merely distorted, at a cost of ~25 dB of EVM that no downstream metric attributes to the front end. Sticky until ddc_reset(); always false for a plan with no CIC stage, which is the honest answer since those plans are scale-free.
function ddc_get_narrow_pulse¶
Is this object's rectangular matched filter degenerately narrow?
True only for the matched flavor built with pulse = RC_PULSE_IANDD and fewer than four output samples per symbol: the rectangle is exactly one symbol wide, so its matched filter is a 2-3 tap sum there. It works, it just barely opens the eye — measured on the timing loop this feeds, a lock statistic of -0.34 at two samples per symbol against +0.95 at four. The RRC spans many symbols and is never affected. Construction also raises a UserWarning, so this is the pull half of the same diagnostic.
function ddc_get_norm_freq¶
Return the current LO normalised frequency (cycles/sample).
function ddc_get_rate¶
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.
function ddc_get_state¶
Serialize state's LO + RateConverter state intoblob .
function ddc_reset¶
Zero LO phase and resampler history. After reset, the next execute call produces the same output as the first execute after create — useful for reproducible block-by-block processing or looped test fixtures.
>>> 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
function ddc_run¶
Pure run: (state_in, input) -> (state_out, output) ; either blob may be NULL (NULL in = current; NULL out = discard).
size_t ddc_run (
ddc_state_t * state,
const void * state_in,
void * state_out,
const float _Complex * in,
size_t n_in,
float _Complex * out,
size_t max_out
)
function ddc_set_norm_freq¶
Retune the LO without resetting phase or resampler history. Updates the NCO phase increment atomically so the carrier shift changes seamlessly across block boundaries. The resampler history and LO phase accumulator are left intact, avoiding the transient that a full reset would cause.
Parameters:
stateMust be non-NULL.valNew normalised frequency (cycles/sample at input rate).
>>> from doppler.ddc import DDC
>>> ddc = DDC(norm_freq=-0.1, rate=0.25)
>>> ddc.norm_freq = -0.2
>>> ddc.norm_freq
-0.2
function ddc_set_state¶
Restore LO + RateConverter state from blob .
Returns:
DP_OK, or DP_ERR_INVALID if the envelope/rate rejects.
function ddc_set_telemetry¶
Attach (or detach) a telemetry context on the cascade's AGC.
Forwarded verbatim to RateConverter_set_telemetry(): the mixer and the fixed stages have no loop to report, so the one instrumented child is the cascade's pre-terminal AGC ("<prefix>.gain_db" and "<prefix>.level_db"). DP_OK with no probes when the cascade has no AGC enabled. Setup path, never hot; the context is borrowed and must outlive the attachment.
Parameters:
stateMust be non-NULL.tlmTelemetry context to attach, or NULL to detach.prefixProbe-name prefix, e.g. "rx.agc".decimEmit every decim-th gain update; >= 1.
Returns:
DP_OK, or DP_ERR_INVALID when the probe table cannot take the AGC's probes (the attach fails whole).
function ddc_state_bytes¶
Byte size of state's blob (envelope + extra + lo + rc).
Macro Definition Documentation¶
define DDC_STATE_MAGIC¶
define DDC_STATE_VERSION¶
The documentation for this class was generated from the following file native/inc/ddc/ddc_core.h