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; and ema_step/ema_alpha_decim, the library's one exponential moving
average and the coefficient that advances it a whole chunk at a time.
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.
square_clip
¶
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 |
required |
Returns:
| Type | Description |
|---|---|
complex
|
Sample with each component limited to |
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 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 |
required |
hi
|
float
|
Upper bound, returned for any |
required |
nan_to
|
float
|
Returned when |
required |
Returns:
| Type | Description |
|---|---|
float
|
|
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
¶
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 |
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 ::saturate there,
as agc_steps does. See agc_core.h for what one unguarded non-finite
sample cost.
Examples:
ema_alpha_decim
¶
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 |
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 |
required |
d
|
int
|
Chunk length in samples, |
required |
Returns:
| Type | Description |
|---|---|
float
|
The per-chunk coefficient, in |
Examples:
Related pages¶
Gallery — Lock Detection: Verify Counts + Hysteresis Design — Automatic Gain Control, The Exponential Moving Average, Lock Detection — the reasoning