The Exponential Moving Average¶
Almost every estimator in doppler is, at its centre, one line: a running average that forgets. A power detector, a lock statistic, a spectrum accumulator and a noise-level estimate are all the same recursion with different inputs — and for most of this library's life they were four separate copies of it, in two different algebraic forms, with no shared statement of what the recursion guarantees.
This page is the why: what the average is for, which of its conventions are load-bearing, what it does at its boundaries, and — the part that took measurement rather than reading — which of the two ways of writing it is the right one, and why that is not a matter of taste.
The contract lives in native/inc/util/util_core.h (ema_step,
ema_alpha_decim) and the C-level evidence in
native/tests/test_util_core.c. This page does not restate either; it
explains the reasoning they assume.
Related: Automatic Gain Control, The NCO.
Status. Sections 1–6 describe the shipped primitive, and every
mechanism in them is pinned by a sabotage-proven C test. Section 7 was
written as a diagnosis of a measured defect in agc's decimated loop and
is now the record of its fix, including the caller-facing rule that
came out of measuring it. Section 8 records adoption: every historical
call site now runs the one primitive.
1. What it is for¶
One question, asked once per observation: what is this quantity's recent average, given that the recent past matters more than the distant past?
A block average answers a different question and needs a block. An EMA answers continuously, in constant memory, with one multiply and one add — which is why it is the estimator that ends up inside every loop:
- A level detector feeding a gain loop needs the average power now, with a memory short enough to track a real level change and long enough not to modulate the gain with the signal's own noise.
- A lock statistic needs the same thing over a phase-error product, and its memory is what converts an instantaneous, noisy indication into a decision variable.
- A spectrum accumulator in exponential mode is the identical recursion applied per bin.
- A detection threshold is sized against the EMA's noise reduction,
which is a closed-form function of the coefficient
(
det_ema_alphaindetection_core.hinverts exactly that law).
Four consumers, one recursion. The reason it is now one function is that four hand-written copies cannot be reasoned about together: a property established for one of them says nothing about the others, and a defect fixed in one silently leaves the rest wrong. That is the same failure the NCO's phase-word conversion had — three private copies, one of them fixed and two not — and it is written up in The NCO.
2. Theory of operation¶
The recursion, with alpha the coefficient in [0, 1]:
Equivalently state <- (1 - alpha) * state + alpha * x, which is the
same thing on paper and not the same thing in floating point; §3 is
about that difference.
flowchart LR
X["observation x"] --> D(("−"))
S["state"] --> D
D -->|"x − state"| M["× alpha"]
M --> A(("+"))
S --> A
A --> S2["state′"]
S2 -.->|"z⁻¹"| S
Three properties follow directly, and they are what a consumer actually relies on:
It is a one-pole low-pass filter. The pole sits at 1 - alpha, so an
impulse decays geometrically and the memory is 1/alpha observations to
the 1/e point (-1/ln(1-alpha) exactly). Everything a caller wants to
know about "how long does it remember" is that number.
Its noise reduction is closed-form. For a white input of variance
σ², the converged output variance is σ² · alpha/(2 - alpha), so the
estimator's SNR improves by (2 - alpha)/alpha. This is the law
det_ema_alpha inverts to size a coefficient for a requested estimator
SNR — which means the law is already load-bearing in this library, and
until now nothing checked the EMA delivered it.
It converges to its input, and never past it. For a constant input the state approaches it monotonically from whichever side it started, and the fixed point is exact: an average handed its own current value must not move. That last property sounds trivial and is the one a careless implementation loses, because a converged estimator that drifts is a slow bias in every consumer and is invisible to any test that only watches the transient.
3. Which form, and why it was measured rather than chosen¶
The two algebraic forms are:
| expression | |
|---|---|
| incremental | state + alpha * (x - state) |
| two-product | alpha * x + (1 - alpha) * state |
doppler had both. agc and async_dsss_receiver wrote the first;
acc_trace wrote the second. Neither file said why.
They differ in rounding, and the difference has a direction. Measured against a 60-digit reference over 5000 steps of random input:
alpha |
incremental | two-product |
|---|---|---|
| 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 incremental form wins everywhere, by a margin that grows as the
average lengthens — and lengthening is the direction every narrow-band
estimator moves. The reason is structural: the incremental form adds a
small correction to a large state, so the large quantity is never
re-rounded, while the two-product form multiplies the large state by
(1 - alpha) and rounds it on every single step.
The two-product form wins exactly one case, and it is a boundary rather than a regime — see §4.
What measurement retired¶
Two expectations went in and did not survive, and they are recorded because the reasoning that produced them is tempting:
- "The two-product form drifts off its fixed point." It does not.
Handed
x == state, both forms return the state exactly, over every coefficient and magnitude tried. The two roundings cancel rather than accumulate. - "The two-product form is inexact at
alpha = 0." It is not:0*x + 1*stateis exact.
So the boundary sections of the C test pin a floor that both forms meet. They are not the argument for this one; §3's accuracy table and §4's pass-through case are.
4. The boundaries are part of the contract¶
An EMA is asked for degenerate coefficients in ordinary use, so the ends of the range are not edge cases to be tolerated — they are answers a caller depends on.
alpha = 1 means "do not average", and must return the observation
bit-exactly. This is a real request: det_ema_alpha(0, 0) returns
exactly 1.0 for "no gain asked for, so no averaging". The incremental
form does not deliver it — state + 1*(x - state) rounds twice, and
was measured inexact for about 9.5% of random (state, x) pairs
(18984 of 200000). An estimator told not to average that returns
something a few ulps from its input is a quiet, permanent bias.
So ema_step takes the branch explicitly. It is loop-invariant and
folds away entirely when alpha is a compile-time constant, so the
common path pays nothing for it.
alpha = 0 freezes the state, exactly, and both forms deliver that.
alpha > 1 saturates to pass-through. A coefficient above 1 is a
caller error, but the answer must stay bounded: the bare recursion would
fly past the observation and oscillate outward, turning a bad parameter
into a diverging estimator. Saturating makes the worst case "no
averaging", which is wrong but stable.
5. It is deliberately not total in its observation¶
ema_step has no guard on x. Hand it a NaN or an infinity and the
state is poisoned permanently.
That is a decision, not an oversight, and it is the same one
Automatic Gain Control §4 argues at length: an EMA
remembers, so its input is the boundary where an untrusted value first
becomes persistent state. One guard there makes the whole downstream
chain total; a clamp at each stage is several chances to miss one. The
guard is saturate, the sibling primitive in the same header,
and the caller places it because only the caller knows which end is safe
— a level wants the ceiling, a lock statistic the floor.
The AGC's own history is the argument: one non-finite sample, and later
~800 samples of silence, each destroyed its loop permanently, and both
were closed by a single saturate at the detector's input rather than
by making the recursion defensive.
6. Decimation: compounding the pole, exactly¶
A loop that updates its average once per chunk of d samples must not
thereby change its own time constant. The coefficient that advances d
samples in one step is
and ema_alpha_decim computes it. Getting this right is what makes a
decimation factor a performance knob rather than a retune, and the
property that makes the claim checkable is the degenerate one:
At d = 1 the answer must be alpha itself, bit for bit. Only then
can a decimated path and a per-sample path be compared at all, because
only then is decim = 1 genuinely the undecimated recursion.
The direct expression fails that, and fails it worst where it matters.
1 - (1 - alpha) is catastrophic cancellation, and the error grows as
the average lengthens:
alpha |
1 - (1 - alpha) at d = 1 |
|---|---|
| 0.05 | 6 ulps off |
| 6.25e-5 | 2556 ulps off |
| 1e-5 | 26865 ulps off |
| 1e-7 | 3977032 ulps off |
ema_alpha_decim goes through -expm1(d * log1p(-alpha)), which is
exact at d = 1 at every coefficient tried, and answers alpha = 0 and
alpha = 1 directly rather than through log1p(-1) = -inf.
agc_steps forms its detector pole by repeated multiplication —
ac *= a1 d times, then 1 - ac — and therefore carries exactly this
defect today. See §8.
7. An EMA is not a loop filter, and conflating them costs a transient¶
This section exists because the distinction is invisible in code that sits three lines apart, and getting it wrong produced a measured, long-standing anomaly in the AGC.
agc_steps computes two decimated coefficients per chunk:
double alpha_d = 1.0 - ac; /* detector pole */
double k_d = (double)d * 4.0 * state->loop_bw; /* loop-filter gain */
The first compounds the pole — the shape §6 describes, near enough. The
second scales an integrator gain linearly by d, which is a
different operation with a different error.
Both lines are now ema_alpha_decim; the pair above is quoted as it stood,
because the rest of this section is the argument for why.
For the closed loop, the error decays per sample by (1 - k₁) with
k₁ = 4·loop_bw. Over d samples that is (1 - k₁)^d; the chunked
update applies (1 - d·k₁) instead. Those differ by the second-order
term, and (1 - d·k₁) is always the smaller — so a larger decimation
always converges faster:
d |
per-sample (1-k₁)^d |
chunked (1-d·k₁) |
gap | C(d,2)·k₁² |
|---|---|---|---|---|
| 8 | 0.922745 | 0.920000 | 0.002745 | 0.0028 |
| 16 | 0.851458 | 0.840000 | 0.011458 | 0.0120 |
| 32 | 0.724980 | 0.680000 | 0.044980 | 0.0496 |
(at loop_bw = 0.0025, so k₁ = 0.01.) The divergence is therefore
second order in d·k₁: d(d-1)/2 · (4·loop_bw)² predicts it to
within 2% at d = 8 and 10% at d = 32, the residual being the
higher-order terms it omits. (An earlier draft claimed three significant
figures; it is the right leading order, not that.)
This is the mechanism behind test_agc_core.c §23's measured spread of
2.53 dB between decim 8 and 32 at a common sample index. Two
consequences worth stating plainly:
-
It is not the detector — for this stimulus. The chunk's power is a flat mean of every sample (nothing is subsampled) and its pole is compounded. Measured on the detector's own state (
p_avg, loop inert so the applied gain stays 1, which is the isolation that works — see the correction below), the spread acrossdecim8/16/32 on §23's constant-envelope input is 0.0035 dB, against the 1.52 dB total at the same index. So the attribution holds, by about 2.6 orders of magnitude.It does not generalise, and the earlier draft's "five orders of magnitude" came from a method that suppressed the quantity it was measuring: driving
loop_bw → 0and readinggain_dbscales the detector's disagreement byk₁ ≈ 1e-9along with everything else, so "spread ≈ 0" was close to vacuous. Read offp_avginstead, a signal with structure inside the chunk separates the decims by far more:input spread across decim8/16/32constant |x|=10 0.0035 dB alternating 1:100 every sample 0.0034 dB 90% silence, 10% bursts 0.55 dB monotone ramp 1 → 100 0.61 dB That part is irreducible: a block mean cannot equal a per-sample geometric weighting for a signal that varies within the block. It is why the rule below is stated for the transient rather than as a claim that
decimis free. -
The header's
loop_bw << 1/(4·decim)precondition is not only a stability condition — it is preciselyd·k₁ << 1, the condition that makes this second-order term vanish. Atdecim = 32withloop_bw = 0.0025the ratio is 3×, not "well below", which is where the 2.53 dB comes from.
The fix is to compound the loop gain the way the detector's pole is
compounded, k_d = 1 - (1 - k₁)^d computed through the same
cancellation-free path — ema_alpha_decim(4·loop_bw, d). Done, and
the d = 1 bit-exactness it depends on is the property §6 was built to
provide. Measured at §23's own settings:
| n | before, spread | after, spread |
|---|---|---|
| 64 | 1.52 dB | 0.22 dB |
| 128 | 2.53 dB | 0.77 dB |
| 256 | 1.05 dB | 0.63 dB |
| 512 | 0.009 dB | 0.012 dB |
So the rectangular integration was most of it, and not all of it. What survives is the first-order hold: the applied gain ramps across each chunk, so a longer chunk ramps over a longer span and the detector sees a different signal — the same mechanism as the table above, arriving through the loop rather than the input. It cannot be compounded away, because it is not a coefficient.
What it can be is bounded, by one number. 4·decim·loop_bw is how far
the loop moves within a chunk, and it is the group the header's
precondition was always written in:
4·decim·loop_bw |
gain falling | gain rising | worst |
|---|---|---|---|
| 0.008 | 0.015 dB | 0.054 dB | 0.05 dB |
| 0.032 | 0.059 dB | 0.197 dB | 0.20 dB |
| 0.128 | 0.281 dB | 0.592 dB | 0.59 dB |
| 0.320 | 1.08 dB | 0.91 dB | 1.08 dB |
| 0.640 | 3.73 dB | 0.97 dB | 3.73 dB |
Keep 4·decim·loop_bw ≤ 0.05 and decim costs under 0.3 dB of
transient. That is the rule the header now states and test_agc_core.c
§23 now asserts.
Both directions are quoted because the loop is not symmetric — the
detector sits inside it and measures power, so a rising gain costs
about 4× a falling one at the same group. That direction sets the rule,
and it was nearly missed: the first sweep measured only the falling case
and put the promise at 0.1 dB. agc_demo.py cold-starts into a weak
signal, so its family assert failed at 0.232 dB and forced the correction
before any of it shipped. The example earned its place as a gate rather
than an illustration.
§23 asserts both, and they do different jobs. At the same group, reverting the compounding moves the falling case 0.059 → 0.146 dB but the rising one only 0.197 → 0.232 dB, so the falling case is the regression detector (verified by doing exactly that) while the rising case pins the promise. Rising is dominated by the first-order hold and the detector asymmetry, neither of which the coefficient touches — so a bound there would look like a guard and catch nothing.
§23's own configuration is 0.32, six times the rule. That is why the
anomaly appeared there, and why it is quantified rather than eliminated.
8. Adoption — what happened¶
The primitive ships, and every site that can use it now does.
| site | form was | status |
|---|---|---|
agc_core.c power detector |
incremental | migrated; pole now exact at d=1 |
async_dsss_receiver_core.c lock_num/den |
incremental | migrated; bit-identical |
acc_trace_core.c ACC_TRACE_EXP |
two-product | migrated; more accurate |
detection_core.h det_ema_alpha |
sizes it only | n/a — never ran the recursion |
Two changed behaviour and one did not, as predicted:
acc_tracemoved from the two-product form to the incremental one. Measured on the real consumer in C against along doublereference, the error improves at every coefficient and most where §3 predicted: 43× atalpha = 1e-5, 2.7× at 1e-3, 1.8× at 0.01, 3.7× at 0.2. Its readback is float32, so no consumer can observe it — the gain is in the accumulator's own state, which is where a long trace's error actually accumulates.agcgained an exactly-compounded detector pole (§6), sodecim = 1is the undecimated recursion rather than 6 ulps off it — and then §7's loop-filter half followed.async_dsss_receiverwas already the incremental form, so it was a pure substitution. Verified byte for byte on a 200-symbol run, with the comparison itself sabotage-checked first, because a signature that never changes is exactly what a stale build produces.
So this page now describes the library's behaviour and not only a function. Establishing the properties first is what made that possible: each site moved against a known contract instead of against an assumption, and the one that changed numerics most was the one whose direction §3 had already predicted.
9. What was considered and rejected — the first-order CIC¶
At alpha = 1/N an EMA and an N-long moving average answer roughly the
same question, and the CIC is the cheaper structure by most measures. It
was measured rather than dismissed, and the reason it is not used here is
narrower than it first appears.
The four axes, measured¶
Noise — for unit white input, output variance:
| N | EMA(1/N) | predicted 1/(2N-1) |
CIC(N) | predicted 1/N |
CIC/EMA |
|---|---|---|---|---|---|
| 8 | 0.066497 | 0.066667 | 0.124839 | 0.125000 | 1.88x |
| 64 | 0.007926 | 0.007874 | 0.015577 | 0.015625 | 1.97x |
| 1024 | 0.000492 | 0.000489 | 0.000956 | 0.000977 | 1.94x |
The EMA is ~2x quieter at equal N, because alpha/(2-alpha) is
1/(2N-1) against the CIC's 1/N. Note that "alpha = 1/N matches
length N" is not the equal-bandwidth pairing in the first place — that is
alpha = 2/(N+1), at which the two are equivalent.
Settling — samples for a unit step to arrive:
| N | EMA to 1/e | CIC to 1/e | EMA to 99% | CIC to 99% |
|---|---|---|---|---|
| 8 | 8 | 6 | 35 | 8 |
| 64 | 64 | 41 | 293 | 64 |
| 1024 | 1024 | 648 | 4714 | 1014 |
The CIC settles exactly in N samples. The EMA needs ~4.6N to reach 99% and never truly arrives — it is asymptotic by construction.
Cost and state — per input sample, for a CIC decimating by R = N
(integrator at the input rate, comb at the output rate):
| N | EMA(1/N) | CIC decim R=N | CIC state |
|---|---|---|---|
| 8 | 1.960 ns | 1.363 ns | 2 doubles |
| 1024 | 1.944 ns | 1.360 ns | 2 doubles |
| 16384 | 1.891 ns | 1.433 ns | 2 doubles |
This is the correction that matters, and an earlier draft of this page had it backwards. A decimating CIC needs no ring buffer — its state is O(1) at any N, and it is ~30% cheaper per sample, flat in N. The N-word buffer belongs to a rate-1 moving average (a boxcar), which is a different structure. Do not reject the CIC on memory; that argument is false.
Fixed point — unsigned unipolar, the shape a power detector actually has, rising to 1000000 and then falling to 1000:
| N | EMA rising | CIC rising | EMA falling (naive) | EMA falling (guarded) | CIC falling |
|---|---|---|---|---|---|
| 8 | 999993 | 1000000 | 993 | 1007 | 1000 |
| 64 | 999937 | 1000000 | 937 | 1063 | 1000 |
| 1024 | 998977 | 1000000 | 18446744073709551593 | 2023 | 1000 |
The CIC is exact, both directions, every N — integer adds, no
rounding until the final shift. The EMA carries a ±(N-1) LSB dead
band: once |x - state| < 2^k the truncating shift rounds the
correction to zero and the average stalls short of its input. Worse, the
naive unsigned update state += (x - state) >> k underflows when the
signal falls, and its failure profile is the bad one — quietly 7 and 63
LSB wrong at small N, then 2^64 garbage at N=1024. An unsigned EMA
needs a direction branch; a CIC needs nothing.
So why the EMA¶
Not memory, and not cost. Output rate.
A first-order CIC's comb fires once per R samples, and for a first-order
section R is the averaging length. So it produces one output per
averaging window — a non-overlapping block average. Every EMA consumer in
this library needs the average faster than that:
- the AGC updates its gain every
decim= 8–32 samples while averaging over1/alpha≈ 16,000; a CIC atR = Nwould move the loop once every 16,000 samples, three orders of magnitude too slow; - acc_trace in exponential mode wants a trace value every frame, not one every N frames;
- the async DSSS lock statistic is read per symbol, against a dwell of 30.
Which gives the general rule, and it is the honest reason: for an
output rate faster than 1/N, a recursive average is the only O(1)
option. A block average that must emit faster than it averages needs
the ring back, and then the memory objection returns — but as a property
of the rate requirement, not of the CIC.
Where the CIC is right, doppler already uses one¶
Two places, both deliberate:
- Inside
agc_stepsitself. The chunk power —JM_SUMSQ_F32over the chunk followed by* inv_c— is a first-order CIC decimator atR = decim, computed as a direct block sum so it needs no integrator state at all. It supplies the short average; the EMA supplies the long one. The two are cascaded decimate-then-smooth, not competitors, and that cascade is the efficient structure. - The
boxcarobject, for a genuine fixed-length moving average.
So the rejection is not of the CIC. It is of the CIC as a replacement for the recursive stage, and only because that stage has to answer faster than once per window.