Skip to content

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

det_threshold(pfa: float) -> float

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:

>>> from doppler.detection import det_threshold
>>> round(det_threshold(pfa=1e-6), 4)
5.2565

det_pd

det_pd(snr: float, dwell: int, threshold: float) -> float

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:

>>> from doppler.detection import det_pd, det_threshold
>>> thr = det_threshold(pfa=1e-6)
>>> round(det_pd(snr=1.613, dwell=8, threshold=thr), 2)  # Pd 0.9
0.9
>>> round(det_pd(snr=0.0, dwell=8, threshold=thr), 6)    # Pd = Pfa
1e-06

det_dwell

det_dwell(
    snr: float, pd_min: float, pfa: float, max_dwell: int
) -> int

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:

>>> from doppler.detection import det_dwell
>>> det_dwell(snr=0.5, pd_min=0.9, pfa=1e-6, max_dwell=256)
84

det_snr

det_snr(dwell: int, pd_min: float, pfa: float) -> float

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:

>>> from doppler.detection import det_snr, det_pd, det_threshold
>>> snr = det_snr(dwell=8, pd_min=0.9, pfa=1e-6)
>>> round(snr, 3)
1.613
>>> pd = det_pd(snr=snr, dwell=8, threshold=det_threshold(pfa=1e-6))
>>> abs(pd - 0.9) < 1e-9   # det_snr inverts det_pd, to tolerance
True

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_cfar models that, with room for signal leaking into the reference.
  • C/N0. det_cn0_to_snr and det_snr_to_cn0 convert 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

det_pfa_cell(pfa: float, n_cells: float) -> float

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:

>>> from doppler.detection import det_pfa_cell
>>> det_pfa_cell(pfa=1e-3, n_cells=1.0)          # one cell: pfa itself
0.001
>>> round(det_pfa_cell(pfa=1e-3, n_cells=1000.0) * 1e6, 6)
1.0005

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

det_cn0_to_snr(cn0_dbhz: float, fs: float) -> float

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:

>>> from doppler.detection import det_cn0_to_snr, det_snr_to_cn0
>>> round(det_cn0_to_snr(cn0_dbhz=60.0, fs=1e6), 12)   # 0 dB per sample
1.0
>>> round(det_snr_to_cn0(snr=det_cn0_to_snr(45.0, 2e6), fs=2e6), 9)
45.0

det_snr_to_cn0

det_snr_to_cn0(snr: float, fs: float) -> float

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:

>>> from doppler.detection import det_snr_to_cn0
>>> round(det_snr_to_cn0(snr=1.0, fs=1e6), 12)
60.0

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

det_threshold_noncoherent(
    pfa: float, n_noncoh: int
) -> float

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:

>>> from doppler.detection import det_threshold_noncoherent
>>> from doppler.detection import det_threshold
>>> round(det_threshold_noncoherent(pfa=1e-3, n_noncoh=4), 3)
5.111
>>> det_threshold_noncoherent(pfa=1e-6, n_noncoh=1) == det_threshold(
...     pfa=1e-6)
True

det_pd_noncoherent

det_pd_noncoherent(
    snr: float, n_coh: int, n_noncoh: int, threshold: float
) -> float

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

det_n_noncoh(
    snr: float,
    n_coh: int,
    pd_min: float,
    pfa: float,
    max_n_noncoh: int,
) -> int

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:

>>> from doppler.detection import det_n_noncoh
>>> det_n_noncoh(
...     snr=2.0, n_coh=16, pd_min=0.9, pfa=1e-3, max_n_noncoh=64)
1

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

det_q_inv(p: float) -> float

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

det_dwell_gauss(
    mean: float, var: float, pd: float, pfa: float
) -> int

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:

>>> from doppler.detection import det_dwell_gauss
>>> det_dwell_gauss(mean=0.4, var=0.5, pd=0.99, pfa=1e-5)
136
>>> det_dwell_gauss(mean=0.8, var=0.5, pd=0.99, pfa=1e-5)   # 2x mean
34
>>> det_dwell_gauss(mean=0.0, var=0.5, pd=0.99, pfa=1e-5)   # no signal
-1

det_threshold_gauss

det_threshold_gauss(
    mean: float, pd: float, pfa: float
) -> float

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:

>>> from doppler.detection import det_threshold_gauss
>>> round(det_threshold_gauss(mean=0.4, pd=0.99, pfa=1e-5), 4)
0.2588
>>> round(det_threshold_gauss(mean=0.8, pd=0.99, pfa=1e-5), 4)  # scales
0.5176

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

det_ema_alpha(snr_in_db: float, snr_out_db: float) -> float

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:

>>> from doppler.detection import det_ema_alpha
>>> det_ema_alpha(0.0, 0.0)      # no gain requested -> no averaging
1.0
>>> round(1 / det_ema_alpha(0.0, 20.0), 1)   # 20 dB gain ~ 50 looks
50.5
>>> round(1 / det_ema_alpha(10.0, 30.0), 1)  # same 20 dB gain, shifted
50.5

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

det_verify_count(p_look: float, p_target: float) -> int

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:

>>> from doppler.detection import det_verify_count
>>> det_verify_count(1e-3, 1e-6)   # two 1e-3 looks reach 1e-6
2
>>> det_verify_count(1e-3, 1e-9)
3
>>> det_verify_count(0.5, 1e-3)    # drop side: pd = 0.5 per look
10
>>> det_verify_count(1e-3, 0.5)    # budget already met -> 1
1

det_verify_delay

det_verify_delay(p_look: float, n: int) -> float

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:

>>> from doppler.detection import det_verify_delay
>>> det_verify_delay(1.0, 8)             # certain hits: exactly n
8.0
>>> round(det_verify_delay(0.5, 2), 6)   # 2 straight coin heads: 6
6.0
>>> round(det_verify_delay(0.9, 8), 1)
13.2

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

det_threshold_f(pfa: float, n: int) -> float

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:

>>> from doppler.detection import det_threshold_f
>>> round(det_threshold_f(1e-3, 2), 6)  # exact: (1 - pfa)/pfa
999.0
>>> round(det_threshold_f(1e-3, 4), 4)
53.4358
>>> round(det_threshold_f(1e-3, 64), 4)  # hardens toward known-noise
2.1931

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)

up_thresh property writable

up_thresh: float

declare side: hit when metric > up_thresh.

down_thresh property writable

down_thresh: float

drop side: miss when metric < down_thresh.

n_up property

n_up: int

consecutive hits required to declare (>= 1).

n_down property

n_down: int

consecutive misses required to drop (>= 1).

cnt property

cnt: int

Running consecutive-look verify counter: hits toward a declare while unlocked, misses toward a drop while locked.

locked property

locked: bool

Current decision (True = locked).

step

step(x: float) -> int

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

steps(
    x: NDArray[float64], out: NDArray[int32] | None = None
) -> NDArray[np.int32]

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:

>>> import numpy as np
>>> from doppler.detection import LockDet
>>> d = LockDet(up_thresh=1.5, down_thresh=1.2, n_up=2, n_down=2)
>>> x = np.array([2.0, 2.0, 1.0, 2.0])   # declares on the 2nd hit
>>> d.steps(x).tolist()
[0, 1, 1, 1]

configure

configure(
    up_thresh: float,
    down_thresh: float,
    n_up: int,
    n_down: int,
) -> None

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:

>>> from doppler.detection import LockDet
>>> d = LockDet(up_thresh=1.5, down_thresh=1.2, n_up=2, n_down=2)
>>> d.configure(up_thresh=3.0, down_thresh=2.5, n_up=1, n_down=1)
>>> d.up_thresh          # thresholds re-tuned in place
3.0
>>> d.step(4.0)          # a single hit now declares (n_up=1)
1

reset

reset() -> None

Drop the lock and clear the verify counter; keep the config.

Examples:

>>> from doppler.detection import LockDet
>>> d = LockDet(up_thresh=1.5, down_thresh=1.2, n_up=1, n_down=1)
>>> d.step(2.0)          # one hit declares lock (n_up=1)
1
>>> d.reset()            # drop it and clear the verify run
>>> d.locked
False

state_bytes

state_bytes() -> int

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

get_state() -> bytes

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, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

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 get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

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__() -> LockDet

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 SyncFinder: the marker must be a non-empty array of 0/1 bits, one per element.

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)

nbits property

nbits: int

Marker length in bits.

find

find(bits: NDArray[uint8], max_errors: int = 0) -> SyncHit

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:

>>> import numpy as np
>>> from doppler.detection import SyncFinder
>>> m = np.array([1, 0, 1, 1, 0, 0, 1, 0], dtype=np.uint8)
>>> rx = np.concatenate([np.zeros(20, np.uint8), 1 - m])
>>> hit = SyncFinder(m).find(rx, max_errors=1)
>>> hit.found, hit.offset, hit.inverted
(1, 20, 1)

pfa

pfa(max_errors: int) -> float

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

max_errors_for(window_bits: int, pfa: float) -> int

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:

>>> from doppler.detection import SyncFinder
>>> from doppler.ccsds import asm_bits
>>> f = SyncFinder(asm_bits())
>>> f.max_errors_for(window_bits=96, pfa=1e-3)
3
>>> f.max_errors_for(window_bits=100000, pfa=1e-3)   # search further
0

destroy

destroy() -> None

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__() -> SyncFinder

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.

found property

found: int

A marker was found: 1 yes, 0 no.

offset property

offset: int

Bit index where the marker starts.

inverted property

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 property

errors: int

Hamming distance to the marker at that offset, in the polarity reported.


Power-SNR (linear)

det_threshold_power

det_threshold_power(pfa: float) -> float

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:

>>> from doppler.detection import det_threshold_power
>>> round(det_threshold_power(pfa=1e-6), 3)   # -ln(1e-6) = 6*ln(10)
13.816

det_pd_power

det_pd_power(
    snr_power: float, dwell: int, power_threshold: float
) -> float

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:

>>> from doppler.detection import det_pd_power, det_threshold_power
>>> thr = det_threshold_power(pfa=1e-6)
>>> round(det_pd_power(
...     snr_power=2.6017, dwell=8, power_threshold=thr), 2)
0.9

det_dwell_power

det_dwell_power(
    snr_power: float,
    pd_min: float,
    pfa: float,
    max_dwell: int,
) -> int

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:

>>> from doppler.detection import det_dwell_power
>>> det_dwell_power(
...     snr_power=0.25, pd_min=0.9, pfa=1e-6, max_dwell=256)
84

det_snr_power

det_snr_power(
    dwell: int, pd_min: float, pfa: float
) -> float

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(m: int, a: float, b: float) -> float

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

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