Python Detection Statistics API¶
The doppler.detection module is the detection-theory layer over the C
detection core: closed-form relationships between probability of detection
(Pd), probability of false alarm (Pfa), SNR, and coherent dwell length for a
square-law detector. Pair it with the streaming
CorrDetector — detection tells you
what threshold and dwell to use, CorrDetector runs the detection. Mind
the units: det_threshold is in units of the noise's Rayleigh σ, while
CorrDetector's noise_est is the mean magnitude of the surface, so its gate
is det_threshold(pfa) · sqrt(2/π).
Every quantity comes in two forms: an amplitude-SNR version (det_*, where
SNR is the linear signal/noise amplitude ratio) and a power-SNR version
(det_*_power, the linear power ratio = amplitude²). The two are equivalent
detectors — det_pd(s, ...) equals det_pd_power(s**2, ...).
The threshold depends only on the target false-alarm rate; Pd then depends on
the SNR and the coherent dwell. The whole chain is closed-form and stateless:
>>> from doppler.detection import det_threshold, det_pd, det_dwell, det_snr
>>> thr = det_threshold(pfa=1e-6) # threshold for Pfa = 1e-6
>>> round(thr, 4)
5.2565
>>> round(det_pd(snr=1.613, dwell=8, threshold=thr), 2)
0.9
>>> det_dwell(snr=0.5, pd_min=0.9, pfa=1e-6, max_dwell=256)
84
>>> round(det_snr(dwell=8, pd_min=0.9, pfa=1e-6), 3) # inverse of det_pd
1.613
The underlying Marcum Q-function is exposed directly — under H0 (a = 0) it is
the Rayleigh tail exp(-b²/2):
>>> from doppler.detection import marcum_q
>>> round(marcum_q(m=1, a=0.0, b=1.0), 5) # P(Rayleigh > 1) = exp(-0.5)
0.60653
>>> round(marcum_q(m=1, a=2.0, b=1.0), 5) # signal present (a = 2)
0.91811
Amplitude SNR (linear)¶
det_threshold
¶
Threshold eta for a given false-alarm probability.
Exact closed-form inversion of Pfa = exp(-eta^2/2):
eta = sqrt(-2 * ln(pfa))
The threshold is independent of dwell and SNR; it depends only on the desired Pfa.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
Desired false-alarm probability, in (0, 1). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Threshold eta > 0; NaN for pfa outside (0, 1). |
Examples:
det_pd
¶
Detection probability for given per-sample amplitude SNR and dwell.
Computes Pd = Q_1(a, eta) where a = sqrt(2 * dwell) * snr.
At snr = 0, det_pd returns Pfa (the false-alarm rate, as expected for a noise-only input). As snr or dwell increase, Pd approaches 1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR (signal / noise amplitude, linear). snr = 0 gives Pd = Pfa. |
required |
dwell
|
int
|
Coherent integration depth; must be >= 1. |
required |
threshold
|
float
|
Test-stat threshold eta, e.g. from det_threshold(). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Detection probability in [0, 1]. |
Examples:
det_dwell
¶
Minimum dwell such that Pd >= pd_min for the given SNR and Pfa.
Iterates dwell = 1, 2, ..., max_dwell, computing det_pd() at each step. Returns the first dwell that satisfies the Pd requirement, or -1 if none is found within max_dwell iterations.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR (linear). |
required |
pd_min
|
float
|
Required detection probability, in (0, 1), e.g. 0.9. |
required |
pfa
|
float
|
False-alarm probability, in (0, 1); used to derive eta. |
required |
max_dwell
|
int
|
Search upper bound; prevents infinite loops for low SNR. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Minimum dwell >= 1, or -1 if not achievable or if either probability is outside (0, 1). |
Examples:
det_snr
¶
Minimum per-sample amplitude SNR achieving Pd >= pd_min.
Binary search over SNR in [0, hi] where hi is doubled from 1.0 until det_pd(hi, dwell, threshold) >= pd_min. 64 bisection iterations yield ~1e-19 relative precision on the final interval.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dwell
|
int
|
Coherent integration depth; must be >= 1. |
required |
pd_min
|
float
|
Required detection probability, in (0, 1). |
required |
pfa
|
float
|
False-alarm probability, in (0, 1); used to derive eta. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Minimum amplitude SNR >= 0; NaN if either probability is outside (0, 1). |
Examples:
A search, a measured reference, a C/N0¶
Real detectors differ from the single known-noise cell above in three ways, and each has one function here:
- Many cells. A search false-alarms when any cell does, so the per-cell
Pfa is
det_pfa_cell(pfa, n_cells)(Šidák), and each threshold comes from that. - A measured reference. A cell-averaging CFAR divides by the mean
magnitude of k cells, its own included, and loses Pd to it:
det_pd_cfarmodels that, with room for signal leaking into the reference. - C/N0.
det_cn0_to_snranddet_snr_to_cn0convert to and from the per-sample amplitude SNR every function here takes.
from doppler.detection import (
det_cn0_to_snr,
det_pd,
det_pd_cfar,
det_pfa_cell,
det_threshold,
)
snr = det_cn0_to_snr(45.0, 2e6) # 45 dB-Hz at 2 MS/s
eta = det_threshold(det_pfa_cell(1e-3, 256)) # a 256-cell search at 1e-3
known = det_pd(snr, 2000, eta) # noise known exactly
cfar = det_pd_cfar(snr, 2000, eta, 256.0, 0.0, 0.0) # measured from 256 cells
assert cfar < known
det_pfa_cell
¶
The per-cell false-alarm probability that gives a search of n_cells independent cells the false-alarm probability pfa (Sidak).
The search false-alarms when ANY cell does, so n cells each at pc miss together with probability (1 - pc)^n. Solving 1 - (1 - pc)^n = pfa gives pc = 1 - (1 - pfa)^(1/n), computed through complement_power() because the direct form cancels at the small pfa every search uses.
Exact for independent cells, and for Gaussian noise an upper bound on the search's Pfa at ANY correlation between cells (Sidak's inequality): the events |z_i| <= c are symmetric convex sets, whose joint probability under a Gaussian is at least their product. Bonferroni's pfa/n, the first term of the same series, holds for any noise distribution too, at a cost of about pfa/2 relative -- ~1e-4 dB of threshold at pfa = 1e-3. A shared CFAR reference makes the cells' test sets data-dependent, where the inequality is well motivated rather than proven.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
The search's false-alarm probability, in (0, 1). |
required |
n_cells
|
float
|
Independent cells searched, >= 1 (any real count). |
required |
Returns:
| Type | Description |
|---|---|
float
|
The per-cell pfa to set each threshold from; NaN for pfa outside (0, 1) or n_cells below 1. |
Examples:
det_pd_cfar
¶
det_pd_cfar(
snr: float,
dwell: int,
threshold: float,
k: float,
leak: float,
leak_cells: float,
) -> float
Pd of a cell-averaging CFAR test: the gate is det_pd()'s threshold scaled by a noise reference MEASURED as the mean magnitude of k cells, the test cell's own included.
det_pd() prices the noise as known. A detector that measures it pays twice: the reference is noisy, and it contains the signal. With T = threshold*sqrt(2/pi) in mean-magnitude units, the test fires when the peak R clears T times the mean of the k cells; moving the peak's own share to the left, R (1 - T/k) > T (k-1)/k S, so the peak faces T (k-1)/(k-T) S, S the mean of the OTHER k-1 cells. S is Gaussian to good approximation -- Rayleigh cells of mean sqrt(pi/2) and variance (4-pi)/2 -- raised by the signal energy leak that sits in those cells (sidelobes, and what a straddle slid out of the peak), spread over leak_cells of them: a cell holding non-centrality nu^2 has mean magnitude ~ sqrt(pi/2 + nu^2), exact at 0 and for a large one. The expectation over S is 2-point Gauss-Hermite (gauss_hermite()), within 5e-5 of 6 points.
k -> infinity is det_pd() at threshold exactly, and a reference too small to hold the gate (k <= T + 1) is answered as det_pd().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR of the test cell. |
required |
dwell
|
int
|
Coherent integration length M, as in det_pd(). |
required |
threshold
|
float
|
The known-noise threshold eta, as det_threshold(). |
required |
k
|
float
|
Reference cells, the test cell included. |
required |
leak
|
float
|
Signal non-centrality energy in the other k-1 cells, in the units of det_pd()'s a^2 = 2 M snr^2; <= 0 is none. |
required |
leak_cells
|
float
|
Cells that energy is spread over, at most k-1; <= 0 spreads it over all of them. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Detection probability, between 0 and 1. |
Examples:
>>> from doppler.detection import det_pd, det_pd_cfar, det_threshold
>>> eta = det_threshold(pfa=1e-3)
>>> round(det_pd(snr=0.3, dwell=64, threshold=eta), 4) # noise known
0.4285
>>> round(det_pd_cfar(0.3, 64, eta, 128.0, 0.0, 0.0), 4) # 128-cell ref
0.4068
>>> round(det_pd_cfar(0.3, 64, eta, 128.0, 40.0, 4.0), 4) # signal in it
0.3299
>>> round(det_pd_cfar(0.3, 64, eta, 1e12, 0.0, 0.0), 4) # k -> inf
0.4285
det_cn0_to_snr
¶
The per-sample amplitude SNR this module's functions take, from a C/N0 and a sample rate.
Power SNR per sample is (C/N0)/fs, and snr is its square root: the convention of det_pd(), det_dwell() and every coherent function here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cn0_dbhz
|
float
|
Carrier-to-noise density, dB-Hz. |
required |
fs
|
float
|
Sample rate, Hz. |
required |
Returns:
| Type | Description |
|---|---|
float
|
sqrt(10^(cn0_dbhz/10) / fs). |
Examples:
det_snr_to_cn0
¶
The C/N0, dB-Hz, of a per-sample amplitude SNR at a sample rate: the inverse of det_cn0_to_snr().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR (linear, > 0). |
required |
fs
|
float
|
Sample rate, Hz. |
required |
Returns:
| Type | Description |
|---|---|
float
|
20 log10(snr) + 10 log10(fs). |
Examples:
Non-coherent integration¶
When coherent integration is capped (Doppler walk, data bits, oscillator drift,
Doppler rate), N_nc coherent looks are combined by summing squared
magnitude. The detector becomes the order-N_nc Marcum-Q; these helpers package
it and reduce to the coherent (order-1) versions above at n_noncoh = 1. They
drive the doppler.acquire.Acquisition engine's coherent/non-coherent split.
det_threshold_noncoherent
¶
CFAR threshold eta_nc for a non-coherent detector of n_noncoh looks.
Solves marcum_q(n_noncoh, 0, eta_nc) = pfa (the order-M central tail, monotone decreasing in eta_nc) by bisection. For n_noncoh = 1 this is the exact closed form sqrt(-2 ln pfa) (== det_threshold).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
Per-test false-alarm probability in (0, 1). |
required |
n_noncoh
|
int
|
Number of non-coherent looks; must be >= 1. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Threshold eta_nc on the normalized statistic R; NaN for pfa outside (0, 1). |
Examples:
det_pd_noncoherent
¶
Detection probability for n_noncoh non-coherent looks.
Computes Pd = Q_{n_noncoh}(a, threshold) with the non-centrality a = sqrt(2 * n_coh * n_noncoh) * snr. At n_noncoh = 1 this is exactly det_pd(snr, n_coh, threshold); at snr = 0 it returns the per-test Pfa.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR (signal / noise amplitude). |
required |
n_coh
|
int
|
Coherent integration length in samples (dwell * N). |
required |
n_noncoh
|
int
|
Number of non-coherent looks; must be >= 1. |
required |
threshold
|
float
|
Threshold eta_nc, e.g. from det_threshold_noncoherent(). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Detection probability in [0, 1]. |
Examples:
>>> from doppler.detection import det_pd_noncoherent, det_pd
>>> from doppler.detection import det_threshold_noncoherent
>>> from doppler.detection import det_threshold
>>> eta = det_threshold(pfa=1e-6)
>>> det_pd_noncoherent(snr=0.5, n_coh=8, n_noncoh=1, threshold=eta) \
... == det_pd(snr=0.5, dwell=8, threshold=eta) # -> coherent
True
>>> eta4 = det_threshold_noncoherent(pfa=1e-3, n_noncoh=4)
>>> round(det_pd_noncoherent(
... snr=0.3, n_coh=16, n_noncoh=4, threshold=eta4), 2)
0.19
det_n_noncoh
¶
Minimum non-coherent looks achieving Pd >= pd_min at fixed n_coh.
Iterates n_noncoh = 1, 2, ..., max_n_noncoh, recomputing the threshold (det_threshold_noncoherent, which grows with the look count) at each step. Returns the first look count that meets the Pd requirement, or -1 if none does within max_n_noncoh. Used by the acquisition engine's (M, N_nc) split.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr
|
float
|
Per-sample amplitude SNR (linear). |
required |
n_coh
|
int
|
Coherent integration length in samples (dwell * N). |
required |
pd_min
|
float
|
Required detection probability, in (0, 1), e.g. 0.9. |
required |
pfa
|
float
|
Per-test false-alarm probability, in (0, 1). |
required |
max_n_noncoh
|
int
|
Search upper bound on the look count. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Minimum n_noncoh >= 1, or -1 if not achievable or if either probability is outside (0, 1). |
Examples:
Gaussian test statistic¶
The helpers above size the amplitude-ratio detector, whose H0 law is Rayleigh.
A second family of detectors thresholds a statistic that is Gaussian under
H0 — a lock metric averaged over enough looks for the CLT to hold. Those share
one sizing chain, used by SymbolSync's timing lock and the carrier lock
detectors. The rationale, the measured evidence, and the independence
assumption it rests on are in
the design note.
Given the statistic's H0 variance and its H1 mean at the operating point,
det_dwell_gauss gives the looks needed and det_threshold_gauss the declare
threshold; both are expressed in det_q_inv, the standard-normal upper tail.
det_threshold is the wrong law here
It inverts the envelope law and returns 4.7985 at pfa = 1e-5, where
det_q_inv returns 4.2649. Only one of those is a sigma count for a
zero-mean Gaussian.
from doppler.detection import (
det_dwell_gauss,
det_q_inv,
det_threshold_gauss,
)
# A statistic with H0 variance 1/2 and an H1 mean of 0.63 at the design
# point, wanted at pd = 0.99 with a 1e-5 per-look false-alarm budget.
assert det_dwell_gauss(mean=0.63, var=0.5, pd=0.99, pfa=1e-5) == 55
assert round(det_threshold_gauss(mean=0.63, pd=0.99, pfa=1e-5), 4) == 0.4076
# The quantile is SIGNED: negative above the median, which is why the
# separation below is a sum of two tails rather than a difference.
assert round(det_q_inv(p=1e-5), 4) == 4.2649
assert round(det_q_inv(p=0.99), 4) == -2.3263
det_q_inv
¶
Upper-tail quantile of the standard normal: the eta with Q(eta) = p.
Q(eta) = 0.5*erfc(eta/sqrt(2)), so this is sqrt(2)*erfcinv(2p).
Everything below is expressed in it, and a caller thresholding its own
zero-mean Gaussian statistic wants det_q_inv(pfa) * sd_H0.
Signed, and that matters. Above the median the quantile is
negative, which is exactly why det_dwell_gauss()'s Q_inv(pfa) -
Q_inv(pd) is a sum of two tails rather than a difference: every
caller's pd is above 0.5. Clamping it to zero there halves the dwell
without failing anything.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
p
|
float
|
Tail probability in (0, 1). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Quantile in H0 sigmas -- positive below the median, exactly 0 at it, negative above. NaN for p outside (0, 1) -- not 0.0, which is the median's quantile. |
Examples:
>>> from doppler.detection import det_q_inv, det_threshold
>>> round(det_q_inv(p=5e-6), 4) # the carrier lock metric's 4.42 sigma
4.4172
>>> round(det_q_inv(p=0.5), 4) # the median
0.0
>>> round(det_q_inv(p=0.99), 4) # above it: NEGATIVE, by design
-2.3263
>>> round(det_threshold(pfa=5e-6), 4) # the OTHER law -- not this one
4.9409
det_dwell_gauss
¶
Looks a Gaussian statistic must average to separate H1 from H0.
The classic sizing: with a per-look H0 variance var and an H1 mean mean
(H0 mean zero), block-averaging n looks shrinks the H0 spread as
1/n, and the smallest n whose H0 and H1 tails clear both budgets is
n = var * ((Q_inv(pfa) - Q_inv(pd)) / mean)^2
Q_inv(pd) is negative for pd > 0.5, so the difference is the total
separation both tails must fit inside.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mean
|
float
|
H1 mean of one look, > 0 (H0 mean is taken as zero). |
required |
var
|
float
|
H0 variance of one look, > 0. |
required |
pd
|
float
|
Required detection probability, in (0, 1). |
required |
pfa
|
float
|
Allowed false-alarm probability, in (0, 1) and below pd. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Looks needed, rounded up and clamped to >= 1; -1 on invalid input. |
Examples:
det_threshold_gauss
¶
Declare threshold for a Gaussian statistic sized by det_dwell_gauss.
The crossover point that meets both budgets at once, in the statistic's own units:
thresh = Q_inv(pfa) * mean / (Q_inv(pfa) - Q_inv(pd))
Independent of the variance and of the look count -- those set how many looks are needed to reach this point, not where it is.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mean
|
float
|
H1 mean of one look, > 0. |
required |
pd
|
float
|
Required detection probability, in (0, 1). |
required |
pfa
|
float
|
Allowed false-alarm probability, in (0, 1) and below pd. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Threshold in the statistic's units; NaN on invalid input. |
Examples:
Estimator smoothing¶
det_ema_alpha sizes a first-order EMA probabilistically: treat the
quantity being smoothed as a DC level in noise with a per-sample
estimator SNR (mean² / variance), pick the output SNR the decision
needs, and the coefficient follows from the EMA's variance reduction
(2 − α)/α. It is how the DLL's code-lock detector sizes its CFAR
noise-reference bandwidth (Dll.configure_lock(..., ref_snr_db=...)),
and the same call sizes any lock-metric smoother when the per-look SNR
is known from C/N0:
from doppler.detection import det_ema_alpha
# signal-free power reference: exponential samples = 0 dB per sample;
# a 20 dB estimator SNR needs an ~50-look EMA
assert round(1 / det_ema_alpha(0.0, 20.0), 1) == 50.5
# only the requested gain matters, not where the pair sits in dB
assert abs(det_ema_alpha(10.0, 30.0) - det_ema_alpha(0.0, 20.0)) < 1e-15
# already good enough -> no averaging
assert det_ema_alpha(6.0, 3.0) == 1.0
det_ema_alpha
¶
EMA coefficient for a target estimator SNR (DC level in noise).
Sizes a first-order EMA y = (1-alpha)*y + alpha*x that estimates a DC
level from noisy i.i.d. measurements x. Per sample the estimator SNR
(mean^2 / variance) is snr_in; the EMA improves it by its variance
reduction (2-alpha)/alpha, so the output SNR is snr_out = snr_in *
(2-alpha)/alpha. Solving for the coefficient:
alpha = 2 * snr_in / (snr_in + snr_out) (SNRs linear)
Returns 1.0 (no averaging) when snr_out_db <= snr_in_db. Typical inputs: a signal-free power reference |n|^2 is exponential (0 dB per sample); a lock signal at known C/N0 has per-look SNR from its coherent integration (minus squaring loss), and this picks the smoothing bandwidth that makes the lock decision variable meet a chosen decision SNR.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr_in_db
|
float
|
Per-sample estimator SNR, dB (mean^2 / variance). |
required |
snr_out_db
|
float
|
Desired EMA-output estimator SNR, dB. |
required |
Returns:
| Type | Description |
|---|---|
float
|
EMA coefficient alpha in (0, 1]. |
Examples:
Lock verification¶
A loop that computes a lock statistic still needs a decision rule: when is
the statistic high enough, long enough, to declare lock — and low enough,
long enough, to drop it? LockDet is that rule factored out once: separate
declare/drop thresholds (level hysteresis) plus consecutive-look verify
counts (time hysteresis). Consecutive independent looks compound
probabilistically — n looks at per-look probability p reach ≈ p^n — so
the verify counts are derived, not guessed: det_verify_count sizes them
from a per-look rate and a compound budget, and det_verify_delay predicts
the declare latency they cost. Both the ≈ and independent are load-bearing
enough to have their own section in
the design note. The DLL's code-lock latch and the M-PSK receiver's two-way
acquisition↔tracking handover both run on an embedded C lockdet.
from doppler.detection import LockDet, det_verify_count, det_verify_delay
# declare side: per-decision pfa 1e-3, false-declare budget 1e-9 -> 3 straight
n_up = det_verify_count(1e-3, 1e-9)
assert n_up == 3
# drop side: per-look miss rate 1-pd = 0.2, false-drop budget 1e-4 -> 6
n_down = det_verify_count(0.2, 1e-4)
assert n_down == 6
# the price in latency: mean looks to a declare at pd = 0.9
assert round(det_verify_delay(0.9, n_up), 2) == 3.72
d = LockDet(up_thresh=8.5, down_thresh=7.0, n_up=n_up, n_down=n_down)
assert [d.step(9.0), d.step(9.0), d.step(9.0)] == [0, 0, 1] # 3rd hit locks
assert d.step(7.5) == 1 # inside the hysteresis band: sticky
det_verify_count
¶
Verify count: consecutive looks needed to compound to a budget.
n consecutive independent looks at per-look probability p compound to
~p^n, so the smallest n with p_look^n <= p_target is ceil(ln
p_target / ln p_look) (clamped to >= 1).
That ~ is a BUDGET, and deliberately the conservative side of one: a
consecutive-run detector's exact declare rate is p^n (1-p)/(1-p^n)
(lockdet_core.h), which is lower, so sizing on p^n over-provisions n
rather than under. The gap is ~p -- negligible where a detector is
really sized, 10% at p = 0.1 -- so pick n here and predict what a
caller will observe with det_verify_delay().
One function serves both sides of a lock detector (lockdet_core.h): the declare count from (per-look pfa, false-declare budget) and the drop count from (per-look miss rate 1 - pd, false-drop budget). Degenerate inputs resolve naturally: a target already met by one look returns 1; p_look >= 1 can never compound below a smaller target and returns INT_MAX.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
p_look
|
float
|
Per-look probability (pfa or 1 - pd), in [0, 1]. |
required |
p_target
|
float
|
Compound probability budget, in (0, 1). |
required |
Returns:
| Type | Description |
|---|---|
int
|
Smallest verify count n with p_look^n <= p_target; -1 for a probability outside its range. |
Examples:
det_verify_delay
¶
Expected looks until a run of n consecutive successes completes.
The mean waiting time of the consecutive-run process a lockdet verify counter implements: at per-look success probability p, the first run of n straight successes takes on average
E[T] = (1 - p^n) / (p^n * (1 - p)) looks,
which is the declare latency bought by a verify count of n (multiply by the look period for time). Limits are handled exactly: p = 1 gives n (the run completes immediately), p = 0 gives infinity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
p_look
|
float
|
Per-look success probability (e.g. pd), in [0, 1]. |
required |
n
|
int
|
Run length (the verify count); clamped to >= 1. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Expected number of looks to the first length-n run; NaN for p_look outside [0, 1]. |
Examples:
One-shot (burst) decisions use the same machinery with one twist: when
the noise reference is estimated from as many samples as the signal
sum (the BurstDespreader lock test), the exact H0 law is F(n, n),
not chi-square — det_threshold_f prices that gate exactly, for every
n:
from doppler.detection import det_threshold_f
# F(2,2) tail is 1/(1+g): the quantile is exactly (1-pfa)/pfa
assert round(det_threshold_f(1e-3, 2), 6) == 999.0
# the estimate hardens with dof: the gate approaches the known-noise one
assert det_threshold_f(1e-3, 16) > det_threshold_f(1e-3, 64) > 1.0
det_threshold_f
¶
Upper quantile of F(n, n) — the exact H0 law for a ratio test whose noise reference is estimated from as many samples as the signal sum.
A chi-square threshold (det_threshold_noncoherent) prices a statistic
normalised by a KNOWN noise power. When the noise power is instead
estimated from n same-burst samples (the BurstDespreader lock test: sum
Re^2 against sum Im^2), the ratio's tail fattens to F(n, n) and the
chi-square gate realizes tens of times the priced pfa (41x at n = 16,
pfa = 1e-3). This helper returns the exact gate: P(chi2_n / chi2_n > g)
= I_{1/(1+g)}(n/2, n/2) = pfa, solved on the regularized incomplete
beta — valid for every n >= 1, odd included. As n grows the estimate
hardens and g approaches the known-noise value. Threshold a
BurstDespreader as lock_stat > sqrt(stat_n * det_threshold_f(pfa,
stat_n)).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
Tail probability budget, in (0, 1). |
required |
n
|
int
|
Degrees of freedom on each side (>= 1). |
required |
Returns:
| Type | Description |
|---|---|
float
|
The F(n, n) upper-pfa quantile; NaN on invalid input. |
Examples:
LockDet
¶
LockDet component.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
up_thresh constructor parameter. |
1.0
|
down_thresh
|
float
|
down_thresh constructor parameter. |
1.0
|
n_up
|
int
|
n_up constructor parameter. |
1
|
n_down
|
int
|
n_down constructor parameter. |
1
|
Examples:
Create with defaults:
>>> from doppler.detection import LockDet
>>> obj = LockDet(up_thresh=1.0, down_thresh=1.0, n_up=1, n_down=1)
cnt
property
¶
Running consecutive-look verify counter: hits toward a declare while unlocked, misses toward a drop while locked.
step
¶
Feed one look of the lock metric; return the current decision.
Unlocked: a hit (x > up_thresh) advances the verify run and the
n_up-th consecutive hit declares lock; any miss resets the run. Locked:
a miss (x < down_thresh) advances the run and the n_down-th
consecutive miss drops the lock; any hit (x >= down_thresh) resets
it. A metric inside the [down_thresh, up_thresh] band is sticky — it
neither advances a declare nor a drop.
A non-finite look is a miss in both states: it never advances a declare, and while locked it advances the drop run like any other miss. An unknown lock is not a lock, which is the rule util_core.h states for lock statistics generally. So a metric that goes NaN drops the lock after n_down looks rather than holding it lit indefinitely.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
float
|
Lock metric for this look. Non-finite counts as a miss. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Decision after this look (1 = locked, 0 = not). |
Examples:
>>> from doppler.detection import LockDet
>>> d = LockDet(up_thresh=1.5, down_thresh=1.2, n_up=2, n_down=3)
>>> [d.step(2.0), d.step(2.0)] # declared on the 2nd straight hit
[0, 1]
>>> d.step(1.3) # in the hysteresis band: stays up
1
>>> [d.step(1.0), d.step(1.0), d.step(1.0)] # 3rd straight miss drops
[1, 1, 0]
steps
¶
Run a block of lock-metric looks through the detector. Applies lockdet_step() to each look in turn, so the decision flag and the in-flight verify run carry across the block exactly as they would look by look — a signal can be processed in frames of any size with no seam.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
NDArray[float64]
|
Lock-metric looks, one scalar per look (length >= n). |
required |
Returns:
| Type | Description |
|---|---|
NDArray[int32]
|
Output. |
Examples:
configure
¶
Re-tune thresholds and verify counts; a live lock survives, the in-flight verify run restarts under the new config.
The current locked flag survives (a live lock is not dropped by a re-tune); the in-flight verify counter is cleared so the next run is counted entirely under the new config.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
up_thresh
|
float
|
Declare threshold (hit when metric > up_thresh). |
required |
down_thresh
|
float
|
Drop threshold (miss when metric < down_thresh). |
required |
n_up
|
int
|
Consecutive hits to declare; clamped to >= 1. |
required |
n_down
|
int
|
Consecutive misses to drop; clamped to >= 1. |
required |
Examples:
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 LockDet 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 LockDet 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 LockDet 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 LockDet be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
LockDet
|
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 LockDet.
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. |
...
|
Frame synchronisation¶
The detectors above threshold a statistic. SyncFinder thresholds a
distance: it correlates a known marker against every bit offset of a stream,
in both polarities, and reports the first offset within max_errors of it.
First rather than best, because a best-match search has to see the whole stream
before it can answer and a synchroniser reading a live capture cannot wait.
It is the general kernel, not any one standard's: pass the marker. CCSDS's
32-bit attached sync marker comes from
asm_bits(),
so nothing transcribes 0x1ACFFC1D twice.
max_errors is not a property of the marker
Half of 32 is 16, so 8 "sounds safe". At t = 8 the marker is found at
its true offset only 58 % of the time on a stream with no channel
errors at all — because each of the offsets ahead of it is an independent
chance to false-hit first, and the search reports the first acceptable
offset. The number you need is a function of how much stream you sweep.
Measured in
the ccsds_tm validation report;
the fix is to ask, not to guess.
pfa is the per-offset false-alarm probability,
2 * sum(C(n, i) for i <= t) / 2**n — the factor of two because the
complement is searched too. max_errors_for inverts it through the window:
over W offsets the chance a false hit precedes the marker is
1 - (1 - pfa)**W, and it returns the largest tolerance that still meets the
false-frame rate you name.
>>> from doppler.detection import SyncFinder
>>> from doppler.ccsds import asm_bits
>>> f = SyncFinder(asm_bits())
>>> round(f.pfa(1) * 2**32) # marker + complement, each with 32 neighbours
66
>>> [f.max_errors_for(w, pfa=1e-3) for w in (96, 4096, 100_000)]
[3, 1, 0]
Sweep further and the affordable tolerance falls — which is the whole point, and is invisible from the signature alone.
import numpy as np
from doppler.detection import SyncFinder
from doppler.ccsds import asm_bits
asm = asm_bits()
rng = np.random.default_rng(3)
stream = np.concatenate([rng.integers(0, 2, 96).astype(np.uint8), asm])
stream = (stream ^ 1).astype(np.uint8) # a 180-degree carrier ambiguity
hit = SyncFinder(asm).find(stream, max_errors=3)
assert (hit.found, hit.offset, hit.inverted, hit.errors) == (1, 96, 1, 0)
inverted is the reason the marker is not randomised: it looks the same in
every frame and in exactly one polarity, so it is the only thing in a frame
that can report that a BPSK carrier locked 180 degrees out. Downstream cannot
— see
the frame-description page
for why an outer code is blind to a global complement.
SyncFinder
¶
Create a searcher for marker.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
marker
|
NDArray[uint8]
|
Unpacked bits, one per byte; only the LSB is used. |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If construction fails. The exception message is |
Examples:
>>> import numpy as np
>>> from doppler.detection import SyncFinder
>>> from doppler.ccsds import asm_bits
>>> asm = asm_bits() # 0x1ACFFC1D, no transcription
>>> f = SyncFinder(asm)
>>> f.nbits
32
>>> rx = np.concatenate([np.zeros(96, np.uint8), asm])
>>> hit = f.find(rx, max_errors=f.max_errors_for(96, pfa=1e-3))
>>> hit.found, hit.offset, hit.inverted
(1, 96, 0)
find
¶
Find the first marker in bits, either polarity.
The FIRST offset whose Hamming distance to the marker, or to its complement, is at most max_errors. First rather than best, because a best-match search has to see the whole stream before it can answer and a synchroniser reading a live capture cannot wait for that.
Choose max_errors with max_errors_for, against the window this caller
actually searches — the marker length is the wrong thing to halve.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bits
|
NDArray[uint8]
|
Unpacked bits, one per byte. |
required |
max_errors
|
int
|
Largest tolerated Hamming distance, in bits. |
0
|
Returns:
| Type | Description |
|---|---|
SyncHit
|
A record whose found says whether the rest of it means anything; a miss returns it zeroed. |
Examples:
pfa
¶
Probability that ONE random offset false-hits this marker at a tolerance of max_errors.
2 * sum_{i <= max_errors} C(n, i) / 2^n, the factor of two because
find searches the complement too. Measured against the 32-bit CCSDS
marker, this tracks the observed false-alarm rate to within 20 % at
every threshold where the count supports a rate
(src/doppler/tests/validation/ccsds_tm/results.md §2.2).
This is the PER-OFFSET number. What a synchroniser cares about is its
whole window; max_errors_for is this inverted through it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_errors
|
int
|
Tolerance in bits. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Probability in [0, 1]. |
Examples:
>>> import numpy as np
>>> from doppler.detection import SyncFinder
>>> from doppler.ccsds import asm_bits
>>> f = SyncFinder(asm_bits())
>>> # the marker and its complement, out of 2**32 windows
>>> round(f.pfa(0) * 2**32)
2
>>> # ...plus each one's 32 one-bit neighbours
>>> round(f.pfa(1) * 2**32)
66
max_errors_for
¶
The largest tolerance whose false-frame rate over a search window still meets pfa.
The question find's signature cannot ask. Every offset ahead of the
true marker is an independent chance to win the race, so the
probability the window produces a false frame is 1 - (1 -
pfa(t))^window_bits, which rises with t. The largest t that still
holds is the most tolerant threshold a caller can afford — and it falls
as they search further, which is the whole of doppler#897.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
window_bits
|
int
|
Offsets tried AHEAD of the marker: the length of stream searched, not the length of the frame. |
required |
pfa
|
float
|
Tolerated probability of a false frame over that window. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Tolerance in bits, or -1 when even an exact match exceeds pfa over that window. |
Examples:
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 SyncFinder be used in a with statement so its C resources are
released deterministically on exit rather than at collection time.
Returns:
| Type | Description |
|---|---|
SyncFinder
|
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 SyncFinder.
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. |
...
|
SyncHit
¶
Bases: tuple[int, int, int, int]
Where a marker was found, and in which polarity. found is the verdict:
the other three fields mean nothing without it, which is why the record
carries it rather than spelling a miss as a sentinel offset.
Attributes:
| Name | Type | Description |
|---|---|---|
found |
int
|
A marker was found: 1 yes, 0 no. |
offset |
int
|
Bit index where the marker starts. |
inverted |
int
|
The stream is complemented — a BPSK carrier recovered through a 180-degree ambiguity delivers every bit inverted, and a marker no randomiser covers is the only thing in a frame that can report it. |
errors |
int
|
Hamming distance to the marker at that offset, in the polarity reported. |
inverted
property
¶
The stream is complemented — a BPSK carrier recovered through a 180-degree ambiguity delivers every bit inverted, and a marker no randomiser covers is the only thing in a frame that can report it.
errors
property
¶
Hamming distance to the marker at that offset, in the polarity reported.
Power-SNR (linear)¶
det_threshold_power
¶
Power threshold p from Pfa for the power detector.
Exact closed-form: P(Exponential(1) > p) = exp(-p) = Pfa, so
p = -ln(Pfa)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pfa
|
float
|
Desired false-alarm probability, in (0, 1). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Threshold p > 0; NaN for pfa outside (0, 1). |
Examples:
det_pd_power
¶
Detection probability for the power detector.
Pd = Q_1(sqrt(2·dwell·snr_power), sqrt(2·power_threshold))
The result equals det_pd() at the equivalent amplitude SNR: power SNR
s corresponds to amplitude SNR sqrt(s), and the Q_1 arguments
match.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr_power
|
float
|
Per-sample power SNR (signal power / noise power at the correlator output, linear). 0 gives Pd = Pfa. |
required |
dwell
|
int
|
Coherent integration depth; must be >= 1. |
required |
power_threshold
|
float
|
Threshold p, e.g. from det_threshold_power(). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Detection probability in [0, 1]. |
Examples:
det_dwell_power
¶
Minimum dwell such that Pd >= pd_min for the power detector.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
snr_power
|
float
|
Per-sample power SNR (linear). |
required |
pd_min
|
float
|
Required detection probability, in (0, 1). |
required |
pfa
|
float
|
False-alarm probability, in (0, 1); used to derive p. |
required |
max_dwell
|
int
|
Search upper bound. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Minimum dwell >= 1, or -1 if not achievable or if either probability is outside (0, 1). |
Examples:
det_snr_power
¶
Minimum per-sample power SNR achieving Pd >= pd_min.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dwell
|
int
|
Coherent integration depth; must be >= 1. |
required |
pd_min
|
float
|
Required detection probability, in (0, 1). |
required |
pfa
|
float
|
False-alarm probability, in (0, 1). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Minimum power SNR >= 0; NaN if either probability is outside (0, 1). |
Examples:
>>> from doppler.detection import (det_snr_power, det_pd_power,
... det_threshold_power)
>>> sp = det_snr_power(dwell=8, pd_min=0.9, pfa=1e-6)
>>> round(sp, 4)
2.6017
>>> pd = det_pd_power(snr_power=sp, dwell=8,
... power_threshold=det_threshold_power(pfa=1e-6))
>>> abs(pd - 0.9) < 1e-9 # det_snr_power inverts det_pd_power
True
Primitive¶
marcum_q
¶
Marcum Q function Q_M(a, b) for integer M >= 1.
Probability that a Rice(a, sigma=1) random variable exceeds b. For M=1: Q_1(a, b) = P(Rice(a,1) > b). General integer M relates to the noncentral chi-squared CDF with 2M degrees of freedom.
Computed via the Poisson-weighted chi-squared series (exact for M=1):
Q_M(a, b) = sum_{k=0}^inf w_k * Q_{M+k}(0, b)
where: w_k = exp(-u) * u^k/k! (u = a^2/2) Q_n(0,b) = exp(-v) * sum_{j=0}^{n-1} v^j/j! (v = b^2/2)
Each iteration advances both the Poisson weight and the chi-sum in O(1) using the recurrences w_{k+1} = w_k * u/(k+1) and Q_{n+1}(0,b) = Q_n(0,b) + exp(-v)*v^n/n!.
The window is CENTRED on the Poisson mode k ~ u = a^2/2 and its
half-width scales as 12*sqrt(u+1) + 60 terms, so the term count grows
with a rather than being the fixed ~60 this comment used to claim:
about 60 terms at a = 0, but ~187 at a = 15. That scaling is the whole
point -- a Poisson(u) distribution's mass sits at k ~ u with spread
~sqrt(u), so a fixed window anchored at k = 0 misses it entirely once
a is large, which is a real bug this code already carries a comment
about (see marcum_q.c). Total cost: O(sqrt(u) + M).
Special cases:
- a = 0: Q_M(0, b) = exp(-b^2/2) * sum_{j=0}^{M-1} (b^2/2)^j/j!
- b <= 0: Q_M(a, b) = 1.0
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
m
|
int
|
Integration order; must be >= 1. |
required |
a
|
float
|
Non-centrality parameter (signal strength). a = 0 for H0. |
required |
b
|
float
|
Threshold (same units as test_stat). |
required |
Returns:
| Type | Description |
|---|---|
float
|
Q_M(a, b) in [0, 1]. |
Examples:
>>> from doppler.detection import marcum_q
>>> round(marcum_q(m=1, a=0.0, b=1.0), 5) # P(Rayleigh>1) = exp(-.5)
0.60653
>>> round(marcum_q(m=1, a=0.0, b=2.0), 5) # exp(-2)
0.13534
>>> round(marcum_q(m=2, a=0.0, b=2.0), 5) # 3*exp(-2)
0.40601
>>> round(marcum_q(m=1, a=2.0, b=1.0), 5) # signal present (a=2)
0.91811
Related pages¶
Gallery — Streaming Async Despreader, Measuring an Error Rate, Defensibly, CarrierAcquisition: RRC Pulse Shaping, Name Your Own Code — and What Happens Past the Radius, Detection Theory Curves, Monte Carlo vs Marcum Q Theory, Lock Detection: Verify Counts + Hysteresis, M-PSK Receiver — Pull-in, Lock, and BER
Guides — DSSS Burst Acquisition, Lock Detection Across doppler.track
Design — AsyncDsssReceiver — the measurement record, AsyncDsssReceiver — the continuous DSSS receiver, from spec to object, Detection Sizing — the four laws behind one prefix, DSSS acquisition: stateless, parallel, dynamics-capable, The Exponential Moving Average, MPSK Receiver, SymbolSync Timing Lock Detector
Contributing — Adding an algorithm — the lifecycle, Validation log