Skip to content

Python Utilities API

The doppler.util module holds small numeric helpers shared across the library: square_clip, the per-component hard limiter used by the AGC and other saturating paths; saturate, the total range guard a loop puts on its own state; ema_step/ema_alpha_decim, the library's one exponential moving average and the coefficient that advances it a whole chunk at a time; the complement_power kernel behind that coefficient; sinc/mean_sinc, the straddle loss of a correlator or DFT bin; and the quadrature rules simpson_weights, midpoint_nodes and gauss_hermite. One helper is not numeric: fromto, the path a script writes an artifact to.

Source: src/doppler/util/__init__.py


square_clip

Clips the real and imaginary parts of a complex sample independently to [-lin, lin] — a square region in the IQ plane (each axis limited on its own), as opposed to a circular magnitude clip. This is the cheap, branch-light limiter a feedback loop applies after gain to bound excursions without rotating the sample's phase quadrant.

from doppler.util import square_clip

square_clip(3 + 4j, 1.0)    # (1+1j)   -> re and im each clamped to [-1, 1]
square_clip(0.5 - 0.2j, 1.0)  # (0.5-0.2j) -> inside the square, unchanged

Contrast with a circular clip, which would scale 3 + 4j (magnitude 5) down to magnitude lin while preserving its phase; square_clip instead clamps each axis, which is what a fixed-point I/Q datapath does at its rails.


saturate

Confines a value to [lo, hi] and is total over every double — including NaN and both infinities. That totality is the point: fmin(fmax(v, lo), hi) looks equivalent and is not, because every comparison against NaN is false, so a NaN falls through to whichever bound the platform's fmin happens to return.

The NaN destination is therefore a parameter, not a default, because which end is safe is domain knowledge rather than arithmetic:

from doppler.util import saturate

saturate(0.5, 0.0, 1.0, 1.0)            # 0.5  -> inside, passed through
saturate(float("inf"), 0.0, 1.0, 1.0)   # 1.0  -> infinity is just above
saturate(float("nan"), 0.0, 1.0, 1.0)   # 1.0  -> a level: unknown reads LOUD
saturate(float("nan"), 0.0, 1.0, 0.0)   # 0.0  -> a lock stat: unknown is UNLOCKED

Use it where an untrusted value first becomes persistent state — the input of an EMA, an accumulator or an integrator. Ahead of that boundary a bad value corrupts one output and is gone; past it, it is remembered and everything derived from it inherits the damage. The AGC's detector is the worked example: see Automatic Gain Control, whose §4 records what a single non-finite sample did before this guard existed.


ema_step

One step of the library's first-order exponential moving average, state + alpha * (x - state). Every running estimator in doppler is this recursion with a different input — a power detector, a lock statistic, a spectrum accumulator — and it exists as one function because four hand-written copies, in two different algebraic forms, cannot be reasoned about together.

from doppler.util import ema_step

ema_step(0.0, 1.0, 0.5)     # 0.5  -> halfway to the observation
ema_step(2.0, 2.0, 0.25)    # 2.0  -> at its fixed point, exactly no motion
ema_step(1.0, 7.0, 1.0)     # 7.0  -> alpha 1 is EXACT pass-through
ema_step(1.0, 7.0, 0.0)     # 1.0  -> alpha 0 freezes the state

The two boundaries are contract, not tolerance. alpha = 1 means "do not average" and is a request callers really make — det_ema_alpha(0, 0) returns exactly 1.0 — so it returns the observation bit-exactly rather than the few ulps the bare recursion would give. alpha = 0 freezes the state exactly, and a coefficient above 1 saturates to pass-through instead of overshooting.

Like saturate's companion note above, this function is deliberately not total in x: a non-finite observation poisons the state permanently, because an EMA remembers. That is why the guard belongs on this function's input. Why this algebraic form and not the other, and what its noise reduction and time constant are, is in The Exponential Moving Average.


ema_alpha_decim

The coefficient that advances an EMA d samples in a single step, 1 - (1 - alpha)^d. A loop that updates once per chunk uses it so the decimation factor stays a performance knob instead of a retune.

from doppler.util import ema_alpha_decim, ema_step

ema_alpha_decim(0.05, 1)    # 0.05 -> d == 1 returns alpha EXACTLY
round(ema_alpha_decim(0.05, 8), 12)   # 0.336579568711

# d steps of alpha == one step of the compounded coefficient
s = 0.0
for _ in range(8):
    s = ema_step(s, 1.0, 0.05)
round(s - ema_step(0.0, 1.0, ema_alpha_decim(0.05, 8)), 15)   # 0.0

It is computed through expm1/log1p because the direct expression cancels: at d = 1, where the answer must be alpha itself, 1 - (1 - alpha) is 6 ulps off at alpha = 0.05 and 26865 ulps off at 1e-5. Exactness at d = 1 is what makes a decimated path comparable to the undecimated one at all.


complement_power

1 - (1 - p)**x, the probability that at least one of x independent trials succeeds, computed through expm1/log1p so nothing cancels when p is small. It is the kernel of two library quantities: ema_alpha_decim above (x = d), and the Šidák split of a search's false-alarm probability over n independent cells (x = 1/n).

from doppler.util import complement_power

pc = complement_power(1e-3, 1 / 1000)       # per-cell Pfa for 1000 cells
round(complement_power(pc, 1000), 15)       # 0.001 -> n cells give pfa back

sinc and mean_sinc

sinc(u) is the normalized sin(pi u)/(pi u): the amplitude a signal u bins off a DFT or correlator bin's centre keeps in that bin. mean_sinc(umax) averages it over a uniform offset in [0, umax], which is the loss a detection-probability model should use rather than the worst case.

from doppler.util import mean_sinc, sinc

round(sinc(0.5), 6)          # 0.63662 -> half a bin off: 2/pi
round(mean_sinc(0.5), 6)     # 0.872654 -> averaged over half a bin

Quadrature: simpson_weights, midpoint_nodes, gauss_hermite

Each rule is a node or weight generator filling arrays in place, so the caller evaluates its own function at the nodes: the same rule in C and Python, with no callback crossing the boundary.

  • simpson_weights(w): weights for the MEAN over an interval, w @ f(linspace(a, b, len(w))), for an odd length of at least 3.
  • midpoint_nodes(u): the cell centres (k + 1/2)/n on [0, 1], each weighted 1/n.
  • gauss_hermite(z, p): the n-point rule for a standard normal, exact for polynomials up to degree 2n - 1. For N(mu, sigma**2) evaluate at mu + sigma * z.
import numpy as np
from doppler.util import gauss_hermite, midpoint_nodes, simpson_weights

w = np.empty(65)
simpson_weights(w)
u = np.linspace(0.0, 0.5, 65)
round(float(w @ np.sinc(u)), 6)     # 0.872654 -> mean_sinc(0.5)

m = np.empty(4)
midpoint_nodes(m)                   # [0.125, 0.375, 0.625, 0.875]

z, p = np.empty(3), np.empty(3)
gauss_hermite(z, p)
round(float(p @ z**4), 12)         # 3.0 -> E[Z^4], exact to degree 2n-1 = 5

A bad length (an even Simpson length, mismatched Gauss-Hermite arrays) raises.


fromto

The path from a start to a destination, Path(here) / to, where here defaults to the directory of the file making the call. A script that names its figure with a bare file name writes it into whatever directory the run started in. With fromto it lands beside the script, from wherever it was started.

The destination is joined with pathlib's /, which returns an absolute right operand unchanged. So one call covers a script's default and its override: a relative name lands under the start, an absolute path where it says, and no destination is the start itself.

from doppler.util import fromto

fromto("dll_lock.png", here="/runs").as_posix()       # '/runs/dll_lock.png'
fromto("/tmp/dll_lock.png", here="/runs").as_posix()  # '/tmp/dll_lock.png'
fromto(here="/runs").as_posix()                       # '/runs'

A characterization script calls it as fromto(out or "dll_lock.png"), and resolves a path typed on its command line before passing it (Path(arg).resolve()). A typed path is relative to the working directory by convention; resolved, it is absolute and comes back as given. Called with no here from a file-less context (a REPL, python -c), it raises ValueError, because there is no script to start from.


square_clip

square_clip(y: complex, lin: float) -> complex

Square-clip a complex sample: clip the real and imaginary parts independently to [-lin, lin] (a square region in the IQ plane).

Parameters:

Name Type Description Default
y complex

Complex CF32 input sample.

required
lin float

Per-component clip threshold (linear amplitude, >= 0). Values outside [-lin, lin] are clamped; values on the boundary are preserved exactly.

required

Returns:

Type Description
complex

Sample with each component limited to [-lin, lin].

Examples:

>>> from doppler.util import square_clip
>>> square_clip(0.5+0.25j, 1.0)   # within bounds, passed through
(0.5+0.25j)
>>> square_clip(2.0+0.5j, 1.0)    # real clipped, imag unchanged
(1+0.5j)
>>> square_clip(3.0-4.0j, 1.0)    # both components clipped
(1-1j)
>>> square_clip(0.5+0.5j, 0.25)   # smaller threshold clips both
(0.25+0.25j)
>>> square_clip(-2.0+0.0j, 1.0)   # negative real clipped
(-1+0j)

saturate

saturate(
    v: float, lo: float, hi: float, nan_to: float
) -> float

Saturate a value into [lo, hi], total over every double including NaN and both infinities. The NaN destination is a parameter because which end is safe is domain knowledge: a gain control guarding a measured power wants the ceiling, a lock statistic wants the floor. Use it at the boundary where an untrusted value first becomes persistent state -- the input of an EMA, accumulator or integrator.

fmin/fmax are not enough for this job. A plain fmin(fmax(v, lo), hi) propagates NaN on some platforms and silently returns a bound on others, and a hand-written v > hi ? hi : v leaves NaN untouched, because every comparison against NaN is false. This function has no fall-through: a value that is neither inside the interval, nor below it, nor above it can only be NaN.

Parameters:

Name Type Description Default
v float

Value to saturate. Any double.

required
lo float

Lower bound, returned for any v < lo.

required
hi float

Upper bound, returned for any v > hi.

required
nan_to float

Returned when v is NaN. Pick the end that is safe in the caller's own terms; it is usually lo or hi.

required

Returns:

Type Description
float

v when lo <= v <= hi, otherwise lo, hi or nan_to.

Notes

Why the NaN destination is the caller's Which end is safe is domain knowledge, not arithmetic. A gain control guarding a measured power wants NaN at the ceiling — an unknown level must drive the gain down, because too little gain loses a signal while too much rails everything downstream. A lock statistic wants NaN at the floor — an unknown lock is not a lock. Baking either choice in would hand the wrong default to half its callers, so nan_to is a parameter and each call site states its own safe direction.

Where to use it At the boundary where an untrusted value first becomes persistent state — the input of an EMA, an accumulator, or an integrator. Ahead of that boundary a bad value corrupts one output and is gone; past it, it is remembered and every quantity derived from it inherits the damage. One guard there makes the whole downstream chain total, where a clamp at each stage is several chances to miss one.

Examples:

>>> from doppler.util import saturate
>>> saturate(0.5, 0.0, 1.0, 1.0)     # inside the interval
0.5
>>> saturate(2.0, 0.0, 1.0, 1.0)     # above the ceiling
1.0
>>> saturate(-3.0, 0.0, 1.0, 1.0)    # below the floor
0.0
>>> saturate(float("inf"), 0.0, 1.0, 1.0)   # infinity is just above
1.0
>>> saturate(float("nan"), 0.0, 1.0, 1.0)   # NaN takes the caller's end
1.0
>>> saturate(float("nan"), 0.0, 1.0, 0.0)   # ... which may be the other
0.0

ema_step

ema_step(state: float, x: float, alpha: float) -> float

One step of a first-order exponential moving average, state + alpha*(x - state). The canonical EMA for the library: it was written out four times in two different algebraic forms before this existed, and duplicated implementations drift. The incremental form is the more accurate of the two everywhere the library operates, by a margin that grows as the average lengthens; alpha == 1 (pass-through) and alpha == 0 (frozen) are both exact. NOT total in x -- a non-finite observation poisons the state permanently, because an EMA remembers, so saturate() belongs on this function's input.

The canonical EMA for the whole library. It was written out four times before this existed — agc (power detector), async_dsss_receiver (the lock_num/lock_den pair), acc_trace (ACC_TRACE_EXP) and the recursion det_ema_alpha sizes — in two different algebraic forms, which are identical on paper and not in floating point. Duplicated implementations drift; this is the one.

Why this form, and not alpha*x + (1-alpha)*state

Both were measured against a 60-digit reference over 5000 steps. The incremental form written here is the more accurate one everywhere the library actually operates, by a margin that grows as the average gets longer — which is the direction a narrow-band estimator moves:

alpha this form alpha*x + (1-alpha)*state
0.05 9.0e-17 6.5e-16
1e-3 3.1e-16 1.6e-15
1e-5 2.7e-17 5.4e-15

The other form wins exactly one case, and it is a boundary rather than a regime: at alpha == 1 it returns x bit-exactly while the incremental form does not (measured inexact for 9.6% of random (state, x) pairs, because state + 1*(x - state) rounds twice). That case is real — det_ema_alpha returns exactly 1.0 for "no gain requested, so no averaging" — so it is handled explicitly below rather than paid for at every alpha.

Parameters:

Name Type Description Default
state float

Current EMA state.

required
x float

New observation.

required
alpha float

Coefficient in [0, 1]. 1 is pass-through (no averaging) and is exact; 0 freezes the state and is exact. A value above 1 saturates to pass-through rather than overshooting.

required

Returns:

Type Description
float

The updated state.

Notes

NOT total in x: a non-finite observation poisons the state permanently, because an EMA remembers. That is deliberate — the guard belongs at the boundary where an untrusted value first becomes persistent state, which is this function's input. Use ::dp_saturate there, as dp_agc_steps does. See agc_core.h for what one unguarded non-finite sample cost.

Examples:

>>> from doppler.util import ema_step
>>> ema_step(0.0, 1.0, 0.5)          # halfway to the observation
0.5
>>> ema_step(2.0, 2.0, 0.25)         # at its fixed point, no motion
2.0
>>> ema_step(1.0, 7.0, 1.0)          # alpha 1 is exact pass-through
7.0
>>> ema_step(1.0, 7.0, 0.0)          # alpha 0 freezes the state
1.0

ema_alpha_decim

ema_alpha_decim(alpha: float, d: int) -> float

The EMA coefficient that advances d samples in one step, 1 - (1 - alpha)^d. A decimated loop updates once per chunk of d samples and must not thereby change its own time constant. Computed through expm1/log1p because the direct expression cancels catastrophically for small alpha -- 26865 ulps off at alpha 1e-5, d 1 -- and being exact at d == 1 is what lets the decimated and per-sample paths be compared bit-for-bit.

A decimated loop updates its average once per chunk of d samples and must not thereby change its own time constant. Compounding the pole exactly is what makes decim a performance knob instead of a retune.

Why expm1/log1p rather than the direct expression

1.0 - pow(1.0 - alpha, d) cancels catastrophically for small alpha, and the damage is worst exactly where a narrow-band estimator lives. Measured at d == 1, where the answer must be alpha itself:

alpha direct 1-(1-alpha)^1 this function
0.05 6 ulps off exact
1e-5 26865 ulps off exact

dp_agc_steps used the repeated-multiply form and had this defect; it now forms BOTH its per-chunk coefficients with this function. Being exact at d == 1 is the property that lets a caller set decim = 1 and get bit-for-bit the undecimated recursion, so the decimated and per-sample paths can be compared at all.

Parameters:

Name Type Description Default
alpha float

Per-sample coefficient in [0, 1].

required
d int

Chunk length in samples, >= 1.

required

Returns:

Type Description
float

The per-chunk coefficient, in [0, 1].

Examples:

>>> from doppler.util import ema_alpha_decim
>>> ema_alpha_decim(0.05, 1)         # d == 1 returns alpha exactly
0.05
>>> round(ema_alpha_decim(0.05, 8), 12)
0.336579568711
>>> ema_alpha_decim(1.0, 4)          # pass-through stays pass-through
1.0
>>> ema_alpha_decim(0.0, 8)          # frozen stays frozen
0.0

complement_power

complement_power(p: float, x: float) -> float

1 - (1 - p)^x for p in [0, 1] and real x >= 0, computed through expm1/log1p so it stays accurate when p is small. The one kernel behind two library quantities: the EMA coefficient that advances d samples (ema_alpha_decim, x = d) and the per-cell false-alarm probability that splits a search's Pfa over n independent cells (det_pfa_cell, x = 1/n).

Written directly, 1 - pow(1 - p, x) loses everything 1 - p rounded away: at p = 1e-5 it is 26865 ulps off. -expm1(x * log1p(-p)) is the same quantity with nothing cancelled.

Two library quantities are this one expression, and both call it:

  • the EMA coefficient that advances d samples in one step, ema_alpha_decim(alpha, d) (x = d);
  • the per-cell false-alarm probability that splits a search's pfa over n independent cells, det_pfa_cell(pfa, n) (x = 1/n, Šidák).

Parameters:

Name Type Description Default
p float

Per-trial probability, in [0, 1].

required
x float

Number of trials, any real x >= 0.

required

Returns:

Type Description
float

1 - (1 - p)^x; exactly p at x == 1, 0 at x == 0 or p <= 0, and 1 at p >= 1 (for x > 0).

Examples:

>>> from doppler.util import complement_power
>>> complement_power(0.05, 1.0)          # one trial is p exactly
0.05
>>> round(complement_power(0.5, 2.0), 12)  # 1 - 0.25
0.75
>>> round(complement_power(1e-3, 1 / 1000) * 1e6, 6)  # Sidak split
1.0005
>>> complement_power(0.3, 0.0)
0.0

sinc

sinc(u: float) -> float

Normalized sinc, sin(pi u)/(pi u), with sinc(0) = 1: the amplitude response of a rectangular window, so the straddle loss of a signal u bins off a DFT bin's centre.

The amplitude response of a rectangular window, which makes it the straddle loss of every correlator and DFT: a signal u bins off a bin's centre keeps sinc(u) of its amplitude in that bin.

Parameters:

Name Type Description Default
u float

Offset, in bins (any real).

required

Returns:

Type Description
float

sin(pi u) / (pi u), and exactly 1 at u == 0.

Examples:

>>> from doppler.util import sinc
>>> sinc(0.0)
1.0
>>> round(sinc(0.5), 12)                 # half a bin: 2/pi
0.636619772368
>>> abs(sinc(1.0)) < 1e-15               # the first null
True

mean_sinc

mean_sinc(umax: float) -> float

The mean of sinc(u) over u in [0, umax]: the average amplitude loss of a signal whose offset from the nearest bin centre is uniform over umax bins. 1 for umax <= 0. 64-interval Simpson (simpson_weights) over segments of at most half a bin: within 3e-10 at any umax.

The average amplitude loss of a signal whose offset from the nearest bin centre is uniform over umax bins: the scalloping a Pd model averages over, where sinc(umax) would be only the worst case. 64-interval Simpson (dp_simpson_weights()) over segments of at most half a bin: within 3e-10 at any umax, far below any model this feeds.

Parameters:

Name Type Description Default
umax float

Upper end of the offset, in bins.

required

Returns:

Type Description
float

The mean; 1 for umax <= 0.

Examples:

>>> from doppler.util import mean_sinc
>>> mean_sinc(0.0)
1.0
>>> round(mean_sinc(0.5), 9)             # uniform over half a bin
0.8726543

simpson_weights

simpson_weights(w: NDArray[float64]) -> None

Fill w with composite Simpson weights for the MEAN of a function over an interval: sum(w[i] * f(a + i*(b - a)/(n - 1))) approximates the mean of f over [a, b], for n = len(w) odd and at least 3. The weights sum to 1. Raises for any other length.

With n = w_len points, sum(w[i] * f(a + i*(b - a)/(n - 1))) is the mean of f over [a, b] (multiply by b - a for the integral). The weights are 1, 4, 2, 4, ..., 2, 4, 1 over 3 (n - 1) and sum to 1. Exact for any cubic; the error falls as (n - 1)^-4 for a smooth f.

Parameters:

Name Type Description Default
w NDArray[float64]

Output, w_len weights.

required

Examples:

>>> import numpy as np
>>> from doppler.util import simpson_weights
>>> w = np.empty(5)
>>> simpson_weights(w)
>>> w * 12                               # 1, 4, 2, 4, 1 over 12
array([1., 4., 2., 4., 1.])
>>> u = np.linspace(0.0, 1.0, 5)
>>> round(float(w @ u**3), 12)           # mean of u^3 over [0, 1]
0.25

midpoint_nodes

midpoint_nodes(u: NDArray[float64]) -> None

Fill u with the midpoint-rule nodes on [0, 1], u[k] = (k + 1/2)/n for n = len(u): the points a uniform average over n equal cells is evaluated at, each weighted 1/n.

The points a uniform average over n equal cells is evaluated at, each weighted 1/n. Scale to [a, b] as a + (b - a) * u[k].

Parameters:

Name Type Description Default
u NDArray[float64]

Output, u_len nodes, ascending.

required

Examples:

>>> import numpy as np
>>> from doppler.util import midpoint_nodes
>>> u = np.empty(4)
>>> midpoint_nodes(u)
>>> u
array([0.125, 0.375, 0.625, 0.875])

gauss_hermite

gauss_hermite(
    z: NDArray[float64], p: NDArray[float64]
) -> None

Fill z and p with the n-point Gauss-Hermite rule for a STANDARD NORMAL: sum(p[i] * f(z[i])) approximates E[f(Z)], Z ~ N(0, 1), exactly for any polynomial f of degree up to 2n - 1. For X ~ N(mu, sigma^2) evaluate f(mu + sigma * z[i]). Nodes ascend and the weights sum to 1. z and p must be the same length n >= 1; raises otherwise.

sum(p[i] * f(z[i])) approximates E[f(Z)], Z ~ N(0, 1), and is exact for any polynomial f of degree up to 2n - 1. For X ~ N(mu, sigma^2), evaluate f(mu + sigma * z[i]). The nodes ascend and are symmetric about 0; the weights sum to 1.

The nodes are the roots of the probabilists' Hermite polynomial He_n, found by Newton's method on its orthonormal recurrence h[k+1] = (z h[k] - sqrt(k) h[k-1]) / sqrt(k+1), which cannot overflow the way He_n and n! do. Each starts from the classical asymptotic guesses (Numerical Recipes' gauher). The weight of a root is 1 / (n h[n-1](z)^2).

Parameters:

Name Type Description Default
z NDArray[float64]

Output, n nodes.

required
p NDArray[float64]

Output, n weights.

required

Examples:

>>> import numpy as np
>>> from doppler.util import gauss_hermite
>>> z, p = np.empty(2), np.empty(2)
>>> gauss_hermite(z, p)
>>> z, p                                 # +-1, each half
(array([-1.,  1.]), array([0.5, 0.5]))
>>> z, p = np.empty(5), np.empty(5)
>>> gauss_hermite(z, p)
>>> round(float(p @ z**4), 12)           # E[Z^4] = 3
3.0

fromto

fromto(
    to: str | PathLike[str] | None = None,
    here: str | PathLike[str] | None = None,
) -> Path

The path from here to to: where a script writes an artifact.

The start, here, defaults to the directory of the file that called fromto, so a bare file name lands beside the calling script whatever directory the run started in. The destination is joined onto the start with pathlib's /, which returns its right operand unchanged when that is absolute. So one call covers both cases a script has: no destination given (its default name, beside itself) and a destination given (used as given).

Parameters:

Name Type Description Default
to str or PathLike

The destination. A relative one is joined onto here. An absolute one is returned unchanged. None returns here itself, the directory, for a script that writes several files into it.

None
here str or PathLike

The start. Default: the directory of the calling file, read from the caller's __file__ and resolved to an absolute path.

None

Returns:

Type Description
Path

Path(here) / to, or Path(here) when to is None. Nothing is created or checked on disk; the caller makes any directory it writes into.

Raises:

Type Description
ValueError

here is omitted and the caller has no __file__ (a REPL, or python -c), so there is no script to start from.

Notes

A path someone types on a command line is relative to the working directory by convention. Resolve it where it is read (Path(arg).resolve()): it is then absolute, and fromto returns it as given. Only the default is anchored at the script.

Examples:

A relative destination lands under the start, an absolute one where it says, and no destination is the start itself:

>>> from doppler.util import fromto
>>> fromto("dll_lock.png", here="/runs").as_posix()
'/runs/dll_lock.png'
>>> fromto("/tmp/dll_lock.png", here="/runs").as_posix()
'/tmp/dll_lock.png'
>>> fromto(here="/runs").as_posix()
'/runs'

With no here, the start is the directory of the file making the call:

>>> from pathlib import Path
>>> fromto("x.png") == Path(__file__).resolve().parent / "x.png"
True

Gallery — Lock Detection: Verify Counts + Hysteresis Guides — Waveforms — what you can generate Design — Automatic Gain Control, AsyncDsssReceiver — the continuous DSSS receiver, from spec to object, BurstBank — the coarse-Doppler bank as one C object, DSSS acquisition: stateless, parallel, dynamics-capable, The Exponential Moving Average, Lock Detection — the reasoning