File dll_core.h¶
FileList > dll > dll_core.h
Go to the source code of this file
Delay-lock loop (DLL) — non-coherent early/prompt/late code tracking. More...
#include "clib_common.h"#include "dp_state.h"#include "jm_perf.h"#include "lockdet/lockdet_core.h"#include "loop_filter/loop_filter_core.h"#include "nco/nco_core.h"#include "dp_tlm/dp_tlm_core.h"#include <complex.h>#include <math.h>#include "detection/detection_core.h"#include "telemetry/telemetry_core.h"
Classes¶
| Type | Name |
|---|---|
| struct | dll_state_t DLL state. |
| struct | dll_tlm_t Telemetry attachment: a borrowed context + this object's probe ids. NULL ctx (the default) means detached — every probe site is then a single predicted-not-taken branch per code epoch. Zeroed in state blobs and preserved across set_state (the hand-written triplet treats it like the borrowed code ). |
Public Functions¶
| Type | Name |
|---|---|
| JM_FORCEINLINE JM_HOT int | dll_accumulate (dll_state_t * s, float _Complex d) Per-sample early/prompt/late correlate + fixed-point code-phase advance. |
| JM_FORCEINLINE float | dll_chip_sign (uint8_t c) |
| void | dll_configure (dll_state_t * state, double bn, double zeta) Recompute the loop gains for a new (bn, zeta); keep the code state. |
| int | dll_configure_lock (dll_state_t * state, double pfa, size_t n_looks, double ref_snr_db) Tune the always-on code-lock detector to a target (pfa, n_looks). |
| void | dll_configure_lock_raw (dll_state_t * state, double up_thresh, double down_thresh, size_t n_looks, double alpha, uint32_t n_up, uint32_t n_down) Set the lock detector's raw geometry directly. |
| dll_state_t * | dll_create (const uint8_t * code, size_t code_len, size_t sps, double init_chip, double bn, double zeta, double spacing, size_t segments) Create a code/timing delay-locked loop over a spreading code. |
| void | dll_destroy (dll_state_t * state) Destroy a DLL instance and release all memory (incl. the code copy). |
| JM_FORCEINLINE double | dll_dwell_center_chip_pos (const dll_state_t * s) This sample's dwell-CENTER code phase, chips. |
| double | dll_get_bn (const dll_state_t * state) |
| double | dll_get_code_phase (const dll_state_t * state) |
| double | dll_get_code_rate (const dll_state_t * state) |
| double | dll_get_last_error (const dll_state_t * state) |
| double | dll_get_lock_stat (const dll_state_t * state) Last lock test statistic R (compare against the configured eta). |
| int | dll_get_locked (const dll_state_t * state) Current lock decision (1 = locked, 0 = not), with the configured verify-count / hysteresis rule applied (see dll_configure_lock). |
| double | dll_get_noise_est (const dll_state_t * state) Current CFAR noise-power estimate E|O|^2 (offset-tap EMA). |
| size_t | dll_get_segments (const dll_state_t * state) |
| void | dll_get_state (const dll_state_t * state, void * blob) |
| size_t | dll_get_symbol_window (const dll_state_t * state) The lock detector's coherent window, in partials (0 when the symbol-period aid is off). Size n_looks from it. |
| void | dll_init (dll_state_t * s, const uint8_t * code, size_t code_len, size_t sps, double init_chip, double bn, double zeta, double spacing) Initialise a DLL in place (no allocation); BORROWS code . |
| JM_FORCEINLINE JM_HOT void | dll_lock_accumulate (dll_state_t * s, float _Complex d) Per-sample offset (noise) tap for the always-on lock detector. |
| void | dll_lock_epoch (dll_state_t * s) Per-epoch lock-detector housekeeping: re-draw the noise offset. |
| void | dll_lock_look (dll_state_t * s, double norm) Fold one look into the lock detector; clear the offset tap. |
| size_t | dll_lookback_segments (size_t tsamps, double max_error_db) Derive a principled segments count from a max tolerable async-lookback correlation-power loss, instead of hand-picking one. |
| JM_FORCEINLINE float | dll_replica (const dll_state_t * s, double c) Sub-chip code replica at fractional code phase c (one tap). |
| void | dll_reset (dll_state_t * state) Re-seed the loop to its create-time code phase; keep config. |
| void | dll_set_bn (dll_state_t * state, double val) |
| int | dll_set_lock_verify (dll_state_t * state, uint32_t n_up, uint32_t n_down) Set the lock detector's verify counts, keeping its thresholds. |
| void | dll_set_rate_aid (dll_state_t * state, double rate_aid) Set the carrier-aiding code-rate deviation (ratio; 0 = off). |
| int | dll_set_state (dll_state_t * state, const void * blob) |
| int | dll_set_symbol_period (dll_state_t * state, double partials_per_symbol) Give the loop the data-symbol period, so the lock detector's looks and the code discriminator's windows are coherent over a symbol instead of a quarter-epoch partial. |
| int | dll_set_telemetry (dll_state_t * state, dp_tlm_t * tlm, const char * prefix, uint32_t decim) Attach (or detach) a telemetry context and register the code loop's probes on it. Registers four probes, emitted once per code epoch (period) and further thinned by decim: "<prefix>.e" (the early-minus-late envelope discriminator — the loop stress), "<prefix>.rate" (the tracked code rate, chips advanced per nominal chip, ~1.0 at lock), "<prefix>.lock" (the CFAR lock statistic R; compare against the configured threshold) and "<prefix>.locked" (the verify-counted lock decision, 0/1 — the lockdet output, so a consumer sees where the declare/drop rule fired without re-deriving it from the statistic). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h ). |
| size_t | dll_state_bytes (const dll_state_t * state) |
| size_t | dll_steps (dll_state_t * state, const float _Complex * x, size_t x_len, float _Complex * out, size_t max_out) Correlate a carrier-wiped block against the local code and steer the code NCO once per code period. |
| size_t | dll_steps_max_out (dll_state_t * state) |
| void | dll_tlm_flush (const dll_state_t * s) Emit the code loop's telemetry records for the epoch just closed. |
| JM_FORCEINLINE JM_HOT void | dll_update (dll_state_t * s) Per-period code discriminator + loop update + NCO steer. |
Macros¶
| Type | Name |
|---|---|
| define | DLL_DISC_CLAMP 1.0 |
| define | DLL_EPS 1e-12 |
| define | DLL_STATE_MAGIC [**DP\_FOURCC**](dp__state_8h.md#define-dp_fourcc) ('D','L','L',' ') |
| define | DLL_STATE_VERSION /* multi line expression */ |
Detailed Description¶
Tracks the code phase of a continuous, repeating spreading code (e.g. a PN / Gold sequence) on a carrier-wiped sample stream. Per sample it correlates the input against three taps of a 2-samples/chip interpolated local-code replica — early (+spacing chips), prompt, late (-spacing chips) — accumulating an integrate-and-dump over one code period; per period it runs the power-domain non-coherent early-minus-late discriminator 0.5 * (|E|^2 - |L|^2) / |P|^2, filters it through an embedded 2nd-order loop_filter_state_t, and steers an embedded fixed-point NCO (nco_state_t) that tracks the code phase — a 32-bit phase accumulator, exact integer wraparound, no open-ended floating-point drift.
It pairs with the carrier loop (costas_core.h): the carrier loop wipes the carrier, the DLL wipes the code. The block API (dll_steps) is the Python face; the JM_FORCEINLINE dll_accumulate()/dll_update() are the C composition API a tracking channel inlines into its own sample loop.
Lifecycle: dll_create -> (steps / configure / reset)* -> dll_destroy, or embed by value with dll_init() (which BORROWS the caller-owned code, and always runs with segments == 1 — there is no by-value counterpart to dll_create()'s segments parameter).
uint8_t code[31] = { ... }; // 0/1 chips, one period
dll_state_t *d = dll_create(code, 31, 2, 0.0, 0.01, 0.707, 0.5);
float _Complex sym[16];
size_t k = dll_steps(d, rx, rx_len, sym, 16); // one prompt per period
double phase = d->chip_pos; // tracked code phase, chips
dll_destroy(d);
Public Functions Documentation¶
function dll_accumulate¶
Per-sample early/prompt/late correlate + fixed-point code-phase advance.
Correlates the carrier-wiped sample d against the early, prompt and late code taps at this sample's dwell-CENTER phase (dll_dwell_center_chip_pos, wrapped over the periodic code), then advances the embedded fixed-point NCO by one sample and re-derives chip_pos from its POST-advance (dwell-START-of-next-sample) phase (never independently accumulated — see dll_state_t::chip_pos). Inline, zero call overhead.
Parameters:
sDLL state. Must be non-NULL.dOne carrier-wiped input sample.
Returns:
1 if this sample's advance wrapped the code period (a period boundary), 0 otherwise. A plain return value, not a persistent struct field — a stored wrap flag that a caller forgets to consume is exactly the class of bug an earlier attempt at this redesign hit (a stale flag causing an infinite loop under segments>1 stress); a local value cannot go stale.
function dll_chip_sign¶
0/1 chip -> +1/-1 BPSK sign.
function dll_configure¶
Recompute the loop gains for a new (bn, zeta); keep the code state.
Re-derives the 2nd-order loop filter's proportional and integral gains for a new noise bandwidth and damping, leaving the tracked code phase, code rate and correlator accumulators untouched — retune the loop mid-run (e.g. narrow the bandwidth once pulled in) without dropping lock.
Parameters:
stateDLL state. Must be non-NULL.bnLoop noise bandwidth, normalised to the code-period rate.zetaDamping factor (0.707 = critically damped).
function dll_configure_lock¶
Tune the always-on code-lock detector to a target (pfa, n_looks).
The DLL carries a lock detector that reuses acquisition's non-coherent test statistic. Every emitted look (a partial in segments mode, or the full-epoch prompt when segments == 1) is also correlated at a random off-peak code phase — re-drawn each epoch and kept noise_guard chips clear of the prompt/early/late lobe — to give a signal-free CFAR noise sample (valid for a low-sidelobe code, e.g. Gold). The offset power feeds an EMA reference E|O|^2; the prompt powers of n_looks consecutive looks are summed into S = sum|P_k|^2, and the detector declares lock when
R = sqrt(2 * S / E|O|^2) > det_threshold_noncoherent(pfa, n_looks)
which under H0 has P(R > eta) = marcum_q(n_looks, 0, eta). Size n_looks with det_n_noncoh(snr, ...) for the operating C/N0.
The noise-reference EMA bandwidth is sized probabilistically via det_ema_alpha(): the signal-free |O|^2 samples are exponential (0 dB estimator SNR per sample — a DC level in fluctuation of equal power), and ref_snr_db chooses the EMA output's estimator SNR (mean^2/variance). Passing 0 derives it from n_looks: the reference's relative std is held to an eighth of the statistic's intrinsic H0 spread (1/sqrt(N)), floored at ~33 dB — which reproduces the classic 1/alpha = max(1024, 32*N) sizing exactly, now as a consequence instead of a constant.
The detector needs an off-peak code phase to sample noise from: with a very short code (fewer than ~2*(spacing+2)+1 chips, i.e. sf <= 6 at the default spacing) no offset clears the prompt/early/late lobe, the noise tap aliases the prompt, and the statistic pins below threshold — locked stays 0 (fail-closed) no matter the signal. Use a code of >= 7 chips (real spreading codes are far longer) for a meaningful lock decision.
The decision itself runs through an embedded lock detector (lockdet_core.h) rather than a single-comparison latch: locked flips up only after det_verify_count(pfa, pfa*1e-3) CONSECUTIVE above-threshold decisions (the false-declare budget held three decades under the per-decision pfa — 2 straight for the default 1e-3), and drops only after 2 straight below-threshold decisions, so a statistic grazing the threshold cannot chatter the flag. Full control of the verify counts and a split declare/drop threshold pair is C-only via dll_configure_lock_raw().
Parameters:
stateDLL state. Must be non-NULL.pfaPer-decision false-alarm probability, in (0, 1).n_looksNon-coherent integration depth N (looks); clamped >= 1.ref_snr_dbNoise-reference estimator SNR in dB (> 0), or 0 to derive fromn_looksas above.
Returns:
DP_OK, or DP_ERR_INVALID when pfa is outside (0, 1).
>>> import numpy as np
>>> from doppler.track import Dll
>>> d = Dll(code=np.zeros(31, dtype=np.uint8), sps=2)
>>> d.configure_lock(1e-3, 20)
>>> d.locked
False
>>> d.configure_lock(1e-3, 20, ref_snr_db=20.0) # ~50-look reference
>>> d.configure_lock(2.0, 20)
Traceback (most recent call last):
...
ValueError: configure_lock failed (rc=-4)
function dll_configure_lock_raw¶
Set the lock detector's raw geometry directly.
void dll_configure_lock_raw (
dll_state_t * state,
double up_thresh,
double down_thresh,
size_t n_looks,
double alpha,
uint32_t n_up,
uint32_t n_down
)
The escape hatch under dll_configure_lock() for a composing C caller that derives its own threshold/EMA/hysteresis geometry — the full lockdet decision rule is exposed: a split declare/drop threshold pair (level hysteresis) and both verify counts (time hysteresis; size them with det_verify_count()). Re-tuning clears the in-flight statistic and drops the lock so the next decision uses only looks gathered under the new config.
Parameters:
stateDLL state. Must be non-NULL.up_threshDeclare threshold on the statistic R (e.g. the CFAR eta from det_threshold_noncoherent()).down_threshDrop threshold on R; choose <= up_thresh for level hysteresis.n_looksNon-coherent integration depth N (looks); clamped >= 1.alphaEMA coefficient for the noise reference, in (0, 1].n_upConsecutive above-threshold decisions to declare lock; clamped to >= 1.n_downConsecutive below-threshold decisions to drop it; clamped to >= 1.>>> import numpy as np >>> from doppler.track import Dll >>> rng = np.random.default_rng(1) >>> # >= 7 chips gives a usable lock statistic >>> code = rng.integers(0, 2, 63).astype(np.uint8) >>> chip = np.where(code & 1, -1.0, 1.0) >>> x = np.tile(np.repeat(chip, 4), 400).astype(np.complex64) >>> d = Dll(code, sps=4, bn=0.005) >>> # raw geometry: declare at R>3, drop at R<2.5, 8-look, >>> # 2-of-2 hysteresis >>> d.configure_lock_raw(3.0, 2.5, 8, 1.0 / 1024, 2, 2) >>> _ = d.steps(x) >>> d.locked # cleared the declare threshold True >>> bool(d.lock_stat > 3.0) True
function dll_create¶
Create a code/timing delay-locked loop over a spreading code.
dll_state_t * dll_create (
const uint8_t * code,
size_t code_len,
size_t sps,
double init_chip,
double bn,
double zeta,
double spacing,
size_t segments
)
A non-coherent early/prompt/late DLL that tracks the code phase of a repeating spreading sequence (PN / Gold) on a carrier-wiped sample stream. Each code period it correlates the input against three replica taps — early (+spacing chips), prompt, late (-spacing chips) — runs the power-domain early-minus-late discriminator, filters it through a 2nd-order loop, and steers a fixed-point code-phase NCO. With segments == 1 it emits one prompt symbol per period (a coherent full-epoch integrate-and-dump); with segments > 1 it splits each epoch into that many partial correlations and tracks non-coherently across them, robust to an asynchronous data-symbol clock. An always-on CFAR lock detector (see dll_configure_lock) reports whether the loop is tracking; carrier-aiding (dll_set_rate_aid) lets the code NCO ride a physically-coupled Doppler the discriminator alone cannot pull in at low SNR.
Parameters:
codeSpreading code (0/1 chips), one period; copied internally.code_lenCode length (chips per period).spsSamples per chip (default 2).init_chipSeed code phase, chips (default 0.0).bnLoop noise bandwidth (default 0.01).zetaDamping factor (default 0.707).spacingEarly/late tap offset, chips (default 0.5).segmentsPartial correlations per code epoch (default 1). 1 = a coherent full-epoch integrate-and-dump (one prompt/period). >1 splits each epoch into that many sub-epoch partials: it emits that many partial prompts/period and tracks the code non-coherently across them (robust to an asynchronous data-symbol clock). segments/epoch ~ samples/symbol at a downstream SymbolSync when the symbol rate is near the code rate, so choose >= 2 for symbol-timing recovery.
Returns:
Heap-allocated state, or NULL on allocation failure.
Note:
Caller must call dll_destroy() when done.
>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8) # a 31-chip PN code
>>> chip = np.where(code & 1, -1.0, 1.0) # BPSK spreading code
>>> x = np.tile(np.repeat(chip, 2), 60).astype(np.complex64)
>>> d = Dll(code=code, sps=2) # 2 samples/chip loop
>>> sym = d.steps(x) # one prompt per period
>>> sym.shape # 60 despread symbols
(60,)
>>> round(float(np.mean(sym.real[-10:])), 1) # despread to a clean +1
1.0
>>> round(d.code_rate, 3) # code NCO at nominal rate
1.0
function dll_destroy¶
Destroy a DLL instance and release all memory (incl. the code copy).
Parameters:
stateMay be NULL.
function dll_dwell_center_chip_pos¶
This sample's dwell-CENTER code phase, chips.
A received sample is a zero-order hold over its dwell interval [chip_pos, chip_pos + step), step = one phase_inc in chip units (~1/sps). Evaluating the replica taps at the dwell's START (the raw pre-advance chip_pos) treats every sample as landing at the very first instant of its hold interval rather than at the interval's continuous-time representative point — this biases the correlation by half a sample's worth of chip phase (0.5/sps chips). Found via a direct symmetry check: the autocorrelation of a real signal must satisfy R(tau) = R(-tau), and the S-curve didn't — it was offset from tau=0 by exactly 0.5/sps, vanishing when taps are evaluated at the dwell midpoint instead. Advancing by half of phase_inc before deriving the chip position gives that midpoint.
Parameters:
sDLL state.
Returns:
Dwell-center code phase, chips.
function dll_get_bn¶
function dll_get_code_phase¶
function dll_get_code_rate¶
function dll_get_last_error¶
function dll_get_lock_stat¶
Last lock test statistic R (compare against the configured eta).
function dll_get_locked¶
Current lock decision (1 = locked, 0 = not), with the configured verify-count / hysteresis rule applied (see dll_configure_lock).
function dll_get_noise_est¶
Current CFAR noise-power estimate E|O|^2 (offset-tap EMA).
function dll_get_segments¶
function dll_get_state¶
function dll_get_symbol_window¶
The lock detector's coherent window, in partials (0 when the symbol-period aid is off). Size n_looks from it.
Parameters:
stateMust be non-NULL.
function dll_init¶
Initialise a DLL in place (no allocation); BORROWS code .
void dll_init (
dll_state_t * s,
const uint8_t * code,
size_t code_len,
size_t sps,
double init_chip,
double bn,
double zeta,
double spacing
)
The by-value counterpart to dll_create(): a tracking channel that embeds a dll_state_t initialises it here and retains ownership of code (it is not copied or freed). code must hold code_len chips for the loop's lifetime.
Parameters:
sState to initialise. Must be non-NULL.codeSpreading code (0/1 chips), one period; borrowed.code_lenCode length (chips per period); must be >= 1.spsSamples per chip.init_chipSeed code phase, chips.bnLoop noise bandwidth, normalised to the code-period rate.zetaDamping factor (0.707 = critically damped).spacingEarly/late tap offset, chips (0.5 = half-chip).
function dll_lock_accumulate¶
Per-sample offset (noise) tap for the always-on lock detector.
The composition sibling of dll_accumulate(): correlates the input against the code at this epoch's random off-peak offset, feeding the CFAR noise reference. Call it on the same sample stream as dll_accumulate() and BEFORE it (both taps evaluate this sample's dwell-CENTER chip phase, dll_dwell_center_chip_pos — dll_accumulate() hasn't advanced the NCO yet, so the two calls see the same phase). A composer that skips this (and dll_lock_look()/dll_lock_epoch()) simply leaves the lock detector idle — locked stays 0, lock_stat/noise_est stay 0.
Parameters:
sDLL state. Must be non-NULL.dOne carrier-wiped input sample (same sample as dll_accumulate).
function dll_lock_epoch¶
Per-epoch lock-detector housekeeping: re-draw the noise offset.
Call once per code epoch (after the period's dll_lock_look()) so the next epoch's noise tap lands at a fresh random off-peak code phase.
Parameters:
sDLL state. Must be non-NULL.
function dll_lock_look¶
Fold one look into the lock detector; clear the offset tap.
Normalises the prompt and offset accumulators by norm (the number of samples integrated into them — one full period for a full-epoch composer), folds the offset power into the CFAR noise reference and the prompt power into the running N-look sum, and — at every n_looks-th look — forms the statistic R and steps the verify-counted lock detector. Call at each look boundary BEFORE zeroing the correlator accumulators (it reads acc_p and acc_o; acc_o is cleared here). Out of line: per-look rate, never hot.
Parameters:
sDLL state. Must be non-NULL.normSamples integrated into acc_p/acc_o this look (> 0).
function dll_lookback_segments¶
Derive a principled segments count from a max tolerable async-lookback correlation-power loss, instead of hand-picking one.
Ports the coupled-despreader prototype's async_lookback_windows() verbatim (itself ported from ~/legacy-commz's asynchronous_correlation_loss): derives an ideal segment size from max_error_db, then snaps to the nearest exact divisor of tsamps so tsamps/segment_size is always an integer count (ties broken toward the smaller/earlier divisor, matching numpy.argmin's own first-occurrence convention). This project found hand-picked segments/windows values go stale silently (a WINDOWS=62 constant was carried for a long time implying an unexamined ~0.07-0.1dB loss tolerance, ~5-7x tighter than this formula's own validated 0.5dB default) — this function is the canonical, derived replacement.
Parameters:
tsampsOne code period, in samples (code_len * sps).max_error_dbMaximum tolerable correlation-power loss from splitting the epoch into this many segments (dB, > 0); 0.5 is this project's own validated default.
Returns:
Segment count in [1, tsamps] that evenly divides tsamps (1 if tsamps == 0).
function dll_replica¶
Sub-chip code replica at fractional code phase c (one tap).
The code is treated as held at a fixed 2 samples/chip, sampled at chip-relative positions {0.25, 0.75} (a quarter and three-quarters into each chip) and linearly interpolated at any fractional position — NOT at {0, 0.5}, which would confine the whole linear-interpolation transition zone to one side of each chip boundary (found during the initial port: a real bug, not a cosmetic asymmetry — it broke E/L symmetry around P badly enough to leave a large discriminator offset at perfect lock). Quarter/three-quarter placement centers the transition zone symmetrically on each boundary (spanning [chip-0.25, chip+0.25)), and both quarter-chip flat regions are pure sign(code[chip]) (no table is materialised — the 2x-oversampled index converts straight back to a chip index via >> 1, same as an unshifted grid would, only the phase origin moves). A point-sample interpolation, not a dwell-width-aware blend: this replaces the earlier exact matched-filter integral (which varied its blend width with the sample's chip-phase dwell time) with the simpler, validated design from docs/design/async-dsss-receiver.md §3.6 — the dwell-integral model was mathematically fancier but did not, on its own, fix the long-run false-lock that motivated this redesign; this one does.
Parameters:
sDLL state (for the code and period length).cCode phase of the tap, chips (any real value; wrapped mod sf).
Returns:
Linearly-interpolated ±1 replica value for this tap and sample.
function dll_reset¶
Re-seed the loop to its create-time code phase; keep config.
Restores the code phase, loop filter, correlator accumulators and lock detector to their post-construction state while preserving the tuned configuration (bn/zeta, spacing, segments, lock geometry). Re-running the same input after a reset therefore reproduces the same tracked state bit-for-bit — the basis of a deterministic replay.
Parameters:
stateMust be non-NULL.>>> import numpy as np >>> from doppler.track import Dll >>> rng = np.random.default_rng(21) >>> code = rng.integers(0, 2, 63).astype(np.uint8) >>> idx = (np.arange(63 * 4 * 300) * (1 + 3e-4) / 4).astype( ... np.int64) % 63 >>> x = np.where(code[idx] & 1, -1.0, 1.0).astype(np.complex64) >>> d = Dll(code, sps=4, bn=0.005) >>> _ = d.steps(x) >>> first = round(d.code_rate, 6) >>> d.reset() # back to the create-time code phase >>> _ = d.steps(x) # same input -> same tracked rate >>> round(d.code_rate, 6) == first True
function dll_set_bn¶
function dll_set_lock_verify¶
Set the lock detector's verify counts, keeping its thresholds.
dll_configure_lock() derives the declare count from pfa and fixes the drop count at 2. A caller that has sized n_looks for a target Pd knows the per-decision miss probability 1 - pd, and det_verify_count(1 - pd, budget) is the drop count that holds the false-drop rate under a budget three consecutive misses for pd = 0.99 at 1e-6 per decision. This sets both counts without touching the thresholds or the noise reference; the running verify counter and the flag restart.
Parameters:
stateDLL state. Must be non-NULL.n_upConsecutive above-threshold decisions to declare (>= 1).n_downConsecutive below-threshold decisions to drop (>= 1).
Returns:
DP_OK, or DP_ERR_INVALID when either count is 0.
>>> import numpy as np
>>> from doppler.track import Dll
>>> from doppler.detection import det_verify_count
>>> d = Dll(np.zeros(31, dtype=np.uint8), sps=2, segments=4)
>>> d.set_lock_verify(2, det_verify_count(0.01, 1e-6))
>>> d.locked
False
function dll_set_rate_aid¶
Set the carrier-aiding code-rate deviation (ratio; 0 = off).
A fixed fractional rate bias summed into the sample-and-hold phase_inc on top of the loop's own control every epoch for physically-coupled Doppler, carrier_offset_hz / carrier_freq_hz, so the code NCO rides the code-rate dilation the discriminator alone can't pull in at low SNR. Applied continuously across the epoch (via phase_inc), not as a phase pulse. Also nudges the current phase_inc so the aid takes effect before the first period update. code_rate stays the loop's own observable and is unaffected.
Parameters:
stateDLL state. Must be non-NULL.rate_aidFractional code-rate deviation (e.g. 8e-6). 0 disables.>>> import numpy as np >>> from doppler.track import Dll >>> rng = np.random.default_rng(11) >>> code = rng.integers(0, 2, 63).astype(np.uint8) >>> delta = 5e-4 # code-rate Doppler >>> idx = (np.arange(63 * 4 * 300) * (1 + delta) / 4).astype( ... np.int64) % 63 >>> x = np.where(code[idx] & 1, -1.0, 1.0).astype(np.complex64) >>> plain = Dll(code, sps=4, bn=0.005) >>> _ = plain.steps(x) >>> round(plain.code_rate, 4) # loop had to pull the whole Doppler 1.0005 >>> aided = Dll(code, sps=4, bn=0.005) >>> aided.set_rate_aid(delta) # feed the Doppler forward instead >>> _ = aided.steps(x) >>> round(aided.code_rate, 4) # loop integrator stays at nominal 1.0
function dll_set_state¶
function dll_set_symbol_period¶
Give the loop the data-symbol period, so the lock detector's looks and the code discriminator's windows are coherent over a symbol instead of a quarter-epoch partial.
In segments > 1 mode every partial is a look for the code-lock detector (dll_configure_lock()) and the discriminator sees one epoch through the per-epoch look-back: the smallest integrations the asynchronous data allows when nothing is known about where its transitions fall, and therefore the weakest. This is the same max-power search the per-epoch look-back already runs, lifted to the symbol scale once the symbol PERIOD is known: with P = partials_per_symbol the transitions recur every P partials, so ceil(P) boundary-phase hypotheses each define a transition-free window of L = min(floor(P) - 1, 4 * segments) partials per symbol. Each hypothesis accumulates the power of its coherently summed windows (an EMA over the last ~32 symbols); the one with the most power IS the symbol timing, and its windows become the detector's looks and the discriminator's windows. A look then integrates L partials coherently at SPEC's 1.8 epochs per symbol and four partials per epoch, six partials instead of one, 7.8 dB more per look and never straddles a transition. Size n_looks for it with detection.det_n_noncoh() over L * (sf * sps / segments) samples.
The code loop steers once per symbol, on the early/prompt/late sums over the winning window, instead of once per epoch on the look-back's: the same discriminator on a window half again as long and never across a transition. The loop filter is re-timed to the symbol interval, so bn keeps its per-epoch meaning and the tracked rate is continuous across the switch either way a loop that has pulled in a code Doppler keeps it when the aid is turned on or off. Measured against the per-epoch loop at the operating point (docs/design/async-dsss-receiver.md §12.5, validate_dll_aid_jitter): pull-in about 20% faster, and a code jitter 0.8x the per-epoch loop's above 45 dB-Hz where the look-back's own handling of the data transitions sets it and 1.2-1.4x at the 40 dB-Hz floor, where the noise sets it and the window's unused partials cost more than its coherence buys; hundredths of a chip either way. The emitted partial stream is untouched: the look-back still supplies its normalisation. The search is blind to WHICH hypothesis is right on any one symbol it needs no decision and no external timing so it costs nothing at cold start and follows a slowly drifting symbol clock by itself. L is capped at four epochs of partials so a long symbol (a low data rate) does not ask for coherence across more carrier than the wipe-off holds.
Parameters:
stateDLL state. Must be non-NULL.partials_per_symbolData-symbol period in emitted partials,segments * chip_rate / (sf * symbol_rate); >= 2. 0 disables (per-partial looks again).
Returns:
DP_OK; DP_ERR_INVALID when segments <= 1 or the period is in (0, 2).
>>> import numpy as np
>>> from doppler.track import Dll
>>> code = (np.arange(63) * 7 % 2).astype(np.uint8)
>>> d = Dll(code, sps=2, segments=4)
>>> d.set_symbol_period(7.24) # 1.81 epochs per symbol, 4 partials/epoch
>>> d.symbol_window # coherent partials per look
6
>>> d.set_symbol_period(0.0) # back to per-partial looks
>>> d.symbol_window
0
function dll_set_telemetry¶
Attach (or detach) a telemetry context and register the code loop's probes on it. Registers four probes, emitted once per code epoch (period) and further thinned by decim: "<prefix>.e" (the early-minus-late envelope discriminator — the loop stress), "<prefix>.rate" (the tracked code rate, chips advanced per nominal chip, ~1.0 at lock), "<prefix>.lock" (the CFAR lock statistic R; compare against the configured threshold) and "<prefix>.locked" (the verify-counted lock decision, 0/1 — the lockdet output, so a consumer sees where the declare/drop rule fired without re-deriving it from the statistic). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h ).
Parameters:
stateMust be non-NULL.tlmTelemetry context to attach, or NULL to detach.prefixProbe-name prefix, e.g. "code" or "ch0.code".decimEmit every decim-th epoch; >= 1.
Returns:
DP_OK, or DP_ERR_INVALID when the probe table cannot take all four probes (the attach fails whole; the object stays detached).
>>> import numpy as np
>>> from doppler.track import Dll
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> code = np.zeros(31, dtype=np.uint8)
>>> d = Dll(code=code, sps=2)
>>> d.set_telemetry(tlm, "code")
>>> sorted(tlm.probe_names)
['code.e', 'code.lock', 'code.locked', 'code.rate']
>>> x = np.ones(31 * 2 * 50, dtype=np.complex64)
>>> _ = d.steps(x)
>>> recs = tlm.read() # four records per code epoch
>>> len(recs) > 0 and len(recs) % 4 == 0
True
function dll_state_bytes¶
function dll_steps¶
Correlate a carrier-wiped block against the local code and steer the code NCO once per code period.
size_t dll_steps (
dll_state_t * state,
const float _Complex * x,
size_t x_len,
float _Complex * out,
size_t max_out
)
The Python face of the loop. Each code period the early/prompt/late correlators dump, the power-domain non-coherent early-minus-late discriminator runs, and the fixed-point code-phase NCO is re-steered; the prompt correlator value is emitted as one output symbol per period (or segments partial prompts per period when segments > 1). The loop is carrier-blind — it tracks with a residual carrier still on the input, so carrier recovery (Costas) and symbol-timing recovery are downstream stages fed from this output. Returned blocks are block-size invariant and safe to keep across calls (a block still referenced is never overwritten, jm gh-437).
Parameters:
stateDLL state. Must be non-NULL.xCarrier-wiped input samples (one contiguous block).x_lenNumber of input samples.outOutput buffer for the emitted prompt symbols.max_outCapacity ofoutin elements; emission stops there.
Returns:
Number of prompt symbols written — one per completed code period (segments per period when segments > 1) — up to max_out.
>>> import numpy as np
>>> from doppler.track import Dll
>>> rng = np.random.default_rng(1)
>>> code = rng.integers(0, 2, 31).astype(np.uint8)
>>> chip = np.where(code & 1, -1.0, 1.0) # BPSK spreading code
>>> x = np.tile(np.repeat(chip, 2), 40).astype(np.complex64)
>>> d = Dll(code=code, sps=2)
>>> sym = d.steps(x) # one prompt per period
>>> sym.dtype
dtype('complex64')
>>> round(float(np.mean(sym.real[-10:])), 1) # despread to a clean +1
1.0
>>> round(d.code_rate, 3) # locked at nominal rate
1.0
function dll_steps_max_out¶
function dll_tlm_flush¶
Emit the code loop's telemetry records for the epoch just closed.
Out-of-line on purpose: the emit machinery must not inline into the per-sample correlator loop (inlined ring-write expansions bloat the loop body and an extern call site forces per-iteration state reloads — both measured ~20% slower detached on other loops). Callers gate on s->tlm.ctx and call this once per code-epoch update. Records "<prefix>.e" (the E-L envelope discriminator — the loop stress), "<prefix>.rate" (the tracked code rate, chips per nominal chip), "<prefix>.lock" (the CFAR lock statistic R, refreshed every n_looks looks) and "<prefix>.locked" (the verify-counted lockdet decision, 0/1 — plotted against .lock it shows exactly where the declare/drop rule fired). A composing tracking channel (the DSSS despreader) calls this from its own per-epoch update.
Parameters:
sState with a non-NULL tlm.ctx (caller-checked).
function dll_update¶
Per-period code discriminator + loop update + NCO steer.
Runs the power-domain non-coherent early-minus-late discriminator 0.5 * (|E|^2 - |L|^2) / |P|^2 on the dumped accumulators (the prompt power is the normalizing "signal + noise power" reference — the validated design from docs/design/async-dsss-receiver.md §3.6, not a magnitude-domain (|E|-|L|)/(|E|+|L|) ratio), filters it, and steers phase_inc (sample-and-hold — held constant until the next call, exactly one epoch later) from BOTH the integrator (code_rate, a sustained rate) and the proportional term, spread smoothly across the whole next period rather than kicked directly into phase. Two things were tried and rejected while porting this design: (1) folding the loop filter's full combined control (integ + kp*e) into phase_inc as a single rate (mirroring symsync_core.h) massively over-corrects — a rate held for a whole sf*sps-sample period turns a kp*e-chip correction into a kp*e*sf-chip one, unstable at any non-tiny bn; (2) kicking phase directly once per period (mirroring costas_core.c) is unsafe right after a wrap (when phase is near zero) — a negative kick pushes phase backward across the just-crossed boundary, and the very next sample's forward step re-crosses it, registering a second, spurious wrap. Spreading the same total kp*e-chip correction over the next period's phase_inc (not phase directly) reproduces the original double-accumulator design's chip_pos += kp*e exactly, without either failure mode. The period wrap itself is NOT handled here — it falls out of dll_accumulate()'s own NCO advance (which wraps mod 2^32 on its own); call this at a period boundary (dll_accumulate() returned 1) after reading the prompt, then the caller resets the accumulators. Inline.
The NCO free-runs at its own nominal rate (1/tsamps cycles/sample, set once in seed() and never touched directly); this function only ever computes a pure correction (ctrl, built entirely from the loop filter's integrator and proportional term no "1.0"/nominal anywhere in it) and adds it on top when steering phase_inc. Keeping the control path free of the nominal rate is what lets a future second correction source (e.g. a carrier-aiding term) sum in cleanly, and is what code_rate (a public, ratio-form observable 1.0 = nominal, unrelated to how phase_inc is actually steered) must never be substituted for internally.
Parameters:
sDLL state. Must be non-NULL.
Macro Definition Documentation¶
define DLL_DISC_CLAMP¶
define DLL_EPS¶
define DLL_STATE_MAGIC¶
define DLL_STATE_VERSION¶
The documentation for this class was generated from the following file native/inc/dll/dll_core.h