Skip to content

MPSK Receiver

Status: track.MpskReceiver and track.MpskReceiverR are shipped and built on the matched DDC cascade (§1.4). Every measured table in this document is from the shipped code unless its caption says otherwise; where the design changes what a number will be, it says so rather than quietly restating it.

Mode 1 is agreed and partly built. Each section states its own status, and this table is the index — if the two ever disagree, the section is right and this row is stale:

section status
§2 the modes BUILT. The handover and acq_to_track are gone (#877)
§3.3 the tap BUILT. One tap: the discriminator reads the strobe (#832)
§3.4 pull-in design. Corrects the shipped prose; pull_in_hz is not a surface yet
§4.1 detector spec partly built. The threshold's Pfa half landed with gh-644 (§8.1)
§7 rates and units design. Nothing takes sample_rate_hz/symbol_rate_hz yet
§8 construction partly built. Five parameters derive today (§8.1); §8.2 is what remains

A section marked design describes a receiver that does not exist yet, and nothing in the shipped code should be read as implementing it.

Scope: a streaming M-PSK receiver (track.MpskReceiver for complex baseband, track.MpskReceiverR for a real IF, M = BPSK / QPSK / 8PSK) that demodulates pulse-shaped signals by composing existing doppler.track and doppler.resample primitives. C-first: every block below is a C core; the Python face is the jm-generated thin wrapper.

§9 is the exception to that scope, and deliberately so: it specifies the mpsk constellation primitive — Gray labelling, the hard-decision rule and differential mode — which the receiver reuses rather than contains. It lives here because it is the same topic, and one topic has one home.

The first target is a continuous BPSK receiver. That is what Mode 1 is sized for, what the defaults are chosen against, and what the numbers below are quoted at when a single case has to be picked.

Related: carrier loop theory (the decision-directed CarrierMpsk loop), RateSync (whose timing loop this receiver literally reuses), the matched rate converter, the async DSSS despreader (design) — DSSS-MPSK is the pipeline Dll(segments) → MpskReceiver, not a fused object.


0. Flavors

  • Continuous streaming
    • Time measured in minutes to hours
    • Periods of data modulation off but carrier on
    • NRZ BPSK primarily (but capable for shaped M-PSK)
  • Burst
    • Short duration measured in seconds or less
    • Narrowband
    • DSSS payload
    • MPSK
    • RRC shaped usually

1. Architecture — composition, not machinery

The receiver owns no filter, no NCO and no interpolator of its own. It is a matched down-converter with two loops closed around its two control ports:

flowchart LR
    IN["Rx<br/>cf32 BB/IF<br/>or f32 IF"]

    subgraph FIXED["fixed rate — the plan sets this clock"]
        direction LR
        LO["LO"] --> MIX["MIX"]
        MIX --> DEC["DEC"] --> AGC["AGC"]
    end

    subgraph STEERED["steered — the timing loop stretches this clock"]
        direction LR
        MFR["MFR"] --> STROBE{"m_out per symbol<br/>on-time? gate?"}
    end

    subgraph CAR["Phase (carrier) loop"]
        direction LR
        PED["PED"] --> PLF["PLF"]
    end

    subgraph TL["Timing loop"]
        direction LR
        TED["TED"] --> TLF["TLF"]
    end

    IN --> MIX
    AGC --> MFR
    AGC -.->|"nda_tap=mf_in"| PED
    STROBE -.->|"nda_tap=mf_out — every output"| PED
    STROBE -.->|"nda_tap=strobe — on-time only"| PED
    STROBE --> TED
    PLF -.->|"freq_ctrl"| LO
    TLF -.->|"rate_ctrl"| MFR
    STROBE -->|"y_k"| OUT["steps()"]
    STROBE --> BITS["bits()"]

The figure groups by clock, not by component, because that is the distinction that explains the receiver: everything left of the MFR runs at a rate the plan fixed, everything from the MFR on runs at a rate the timing loop is stretching. The two loops hang off that boundary, and where the PED reads relative to it is exactly what nda_tap chooses.

LO sits inside the fixed-rate group deliberately. Its rate is the input sample rate, fixed by the plan; it is its frequency that PLF steers. Drawing LO and MIX as one box hid that difference.

Nomenclature

One name per block, used in the code, the docs and every figure. Where a term below disagrees with a symbol in the source, the source is the thing to fix.

Term Block Owns
LO Local oscillator The phase accumulator; steered by freq_ctrl
MIX Mixer The complex multiply that brings the carrier to DC
DEC Integer decimator The planner's CIC / halfband chain, at a fixed integer rate
AGC Automatic gain control The signal level; its reference is derived for this exact node
MFR Matched filter resampler The terminal polyphase stage — the bank is the matched filter, the arm is the fractional delay; steered by rate_ctrl
PED Phase error detector The NDA M-th-power discriminator; reads wherever nda_tap points it
PLF Phase loop filter Type-2 PI, output freq_ctrl
TED Timing error detector Gardner, carrier-blind (magnitude-squared, so it is independent of carrier phase)
TLF Timing loop filter Type-2 PI, output rate_ctrl

The taps are named for their node

nda_tap chooses where the PED reads. The names are the MFR's two ports plus one well-known gate:

nda_tap node needs symbol timing?
mf_in the MFR's input — the AGC's output no
mf_out the MFR's output, at m_out·Rs no
strobe the on-time MFR output, at Rs yes

mf_in and mf_out are the two sides of the matched filter; strobe is mf_out with the timing loop's gate applied, and keeps its own name because that name is standard.

Naming the node rather than a region is deliberate, and both halves were wrong before. preterm / "pre-MF" describe a region — the LO's output and DEC's output are equally "before the matched filter" — where mf_in is one edge in the figure above (AGC --> MFR) and nowhere else. And mf_all read as both sides of the filter rather than its output, which is the one thing it never meant.

At the RateConverter layer the same node is called pre-terminal, which is the honest name there: a plain cascade's terminal stage is not a matched filter at all, so it has no mf to be the input of.

  • The matched filter is the cascade's terminal polyphase stage. It is not a separate FIR: the bank the down-converter was already evaluating carries the pulse-matched taps, so the match costs the dot product it was doing anyway.
  • The interpolator is the bank arm. Selecting arm p of P is the fractional symbol-timing delay, to 1/num_phases of an output period. There is no Farrow and no second timing mechanism — see the "NCO alone controls timing" rule.
  • Two control ports, one per loop, and they are duals: the timing loop steers the terminal accumulator (rate_ctrl), the carrier loop steers the LO phase accumulator (freq_ctrl).
  • The timing loop is ratesync_loop_t — literally RateSync's loop, factored out for reuse, not a copy of it. Timing is carrier-blind (Gardner |·|²), so it settles in parallel with carrier acquisition and can lead it.
  • m_out outputs per symbol come out of the terminal stage. Gardner takes every m_out-th as the on-time strobe and the one m_out/2 back as the transition gate, so the oversampled matched-filtered stream falls out free.
  • DSSS-MPSK is the downstream pipeline Dll(segments) → MpskReceiver; the despreader removes the PN code and hands symbols to this modem. Not fused.

The internal signals, and what gates what

The figure above is the architecture. This one is the same receiver drawn in its own identifiers, so a reader can put a finger on a block and find the symbol in the source. Nothing here is a second design — it is the first one with the wires named.

flowchart TB
    X(["x — one input sample"])
    FE["front end<br/>ddc_execute_ctrl_push_tap2()<br/>LO · MIX · DEC · AGC · MFR"]
    X --> FE

    FE -->|"zpre, n_pre"| PMI["mpsk_rx_push_mf_in()"]
    FE -->|"ys 0..n"| TO["mpsk_rx_take_output()"]

    PMI -->|"nda_tap == MF_IN"| DISC
    TO -->|"nda_tap == MF_OUT<br/>every output, ahead of the strobe"| DISC

    TO --> RL["ratesync_loop_take_output()<br/>gates to the on-time sample `on`"]
    RL -->|"nda_tap == STROBE"| DISC

    subgraph D["mpsk_rx_disc() — runs in BOTH modes, always"]
        direction TB
        DISC["carrier_nda_disc(z, m) → pe, lk"]
        LKE["lock += CARRIER_NDA_LOCK_ALPHA · (lk − lock)"]
        CLD["lockdet_step(&amp;car_lock, lock)"]
        DISC --> LKE --> CLD
    end

    RL --> ROT["y_rot = on · sym_rot"]
    DISC -->|"pe, unconditionally"| STEER

    STEER["mpsk_rx_steer(pe)<br/>car_error = pe<br/>freq_ctrl = −loop_filter_step(car_lf, pe) · freq_scale"]

    ROT --> CNT["sym_count++"]
    LKE -.->|"lock"| CLD

    STEER -.->|"freq_ctrl"| FE
    RL -.->|"timing.ctrl — the rate_ctrl port"| FE
    ROT --> OUT(["*sym = y_rot"])

The couplings here are chosen rather than inherited, and each was measured before being left alone. They are the reason this diagram is worth having: in every case the gate a reader would expect is deliberately absent.

  • Nothing gates the steer. mpsk_rx_disc() runs on every strobe it is handed and steers on every one, from the first symbol to the last. The lock EMA and its verify-counted decision (car_lock) are computed beside it and read by nobody inside the receiver. Measured where that could still fail: at the last symbol before the decision declares, the loop has already acquired a non-zero fraction of the true offset — 0.376 at bn_carrier = 0.02, and 0.72–1.06 across M at 0.02 with the derived threshold — where a steer gated on the decision would read exactly 0.0. Do not read those fractions as a statement about convergence: the statistic's H1 mean is a function of Es/N0 alone, so the decision instant carries no frequency information (docs/design/lock-detect.md §1).

  • The strobe tap is NOT gated on the timing loop's lock detector. It steers from its first strobe, locked or not. An earlier revision gated it, on the sound reasoning that a pre-lock strobe is an arbitrary phase of the pulse — and the transient is real (QPSK, sps = 8, 20 dB, 5 seeds: timing declares at symbol 185 on average, and across that window the lock statistic swings −0.947 to +0.888 where a settled lock reads +0.906). But across a 24-cell sweep the gate changed exactly one cell, and what it really bought was making carrier acquisition easier to measure, not easier to achieve. A tap that needs timing it cannot wait for is a reason to choose a different tap — which is what nda_tap is for. Gating the default hid that choice behind a coupling the caller could neither see nor override.

1.1 The two domains, and why the split is the design

Everything above divides into two domains that run at different clocks, and almost every design decision in this document follows from which side of that line a thing sits on.

fixed-rate domain steered domain
where LO, decimation cascade, pre-terminal stream terminal stage and everything after it
clock set by the plan; nothing downstream moves it the timing loop is actively stretching it
who lives here AGC, NDA carrier discriminator, LO steering matched filter, Gardner TED, strobes, symbols, decisions

The pre-terminal stream is the fixed-rate side, and RateConverter already says why, in the AGC's own words (native/inc/RateConverter/RateConverter_core.h):

The tap is pre-terminal rather than post because the terminal stage's OUTPUT rate is the one a timing loop is actively steering, and an AGC whose bandwidth is quoted in cycles per sample of a stream another loop is stretching is coupled to that loop. The pre-terminal rate is fixed.

The AGC was put there for exactly the decoupling the carrier loop needs, so the carrier discriminator is the second tenant of one tap, not a new one.

The AGC is load-bearing on that tap, not optional. It is what makes the pre-terminal stream a defined-level stream, and the timing detector's slope is a construct-time constant for a unit-amplitude signal (§6.1). An un-levelled receiver is not a configuration; it is a broken one, which is why agc stops being a construction parameter (§8).

Two consequences worth stating outright, because the shipped code does not yet have them:

  • The carrier loop leaves the timing domain entirely. It reads pre-terminal, so it depends on no strobe, no symbol timing and no lock detector. There is nothing to gate and nothing to wait for.
  • The matched filter leaves the carrier loop. Today the loop closes around the matched filter, so its dead time is that filter's group delay — at RRC span = 8 that is ~8 symbols inside the loop, which is why the shipped header tells callers to keep bn_carrier a small fraction of the symbol rate. Reading pre-terminal leaves only the LO and the decimators in the loop, a fraction of a symbol. That is the change that buys back loop bandwidth, and with it the ability to track real dynamics.

1.2 One object, two faces — and why it took a jm feature

MpskReceiver takes complex baseband; MpskReceiverR takes a real IF. They are one object with two constructors, and the real face is a jm view over the same core — the same shape the continuous flavor already had.

They were separate types until the collapse (mpsk.md §12), by the rule the down-converters follow: a difference in constructor is a flavor (a jm view), a difference in method signature is a separate type. steps(x) takes cf32 on one and f32 on the other, and a view shared its parent's methods verbatim, so one class could not have both. That constraint was jm's, not the rule's. just-makeit#1012 removed it — a view method restating a parent's NAME may declare its own signature when it binds its own C symbol via fn, which is the discriminator and not a convenience (the parent's symbol carries the parent's prototype, so a different signature is only callable through a different symbol). The type/flavor test stands; what changed is that the answer became expressible.

Everything behind the front end was already shared rather than duplicated: mpsk_rx_loops_t (both loops, the discriminator, the demapper) is one implementation. What the collapse added is that it now has ONE test home — while there were two types, the shared header's claims were pinned only where one of the two tests happened to reach them, and "the LO runs at half the input rate" was pinned by neither, which is where the gh-765 freq_scale defect lived. The real face adds only the front end (MatchedDdcr instead of MatchedDDC) and the rate conversion its halfband forces — its LO runs at the intermediate rate fs/2, and the R2C halfband has an fs/4 shift baked in. That also sets its one extra constraint: sps > 2·m_out, because the cascade behind the halfband runs at twice the overall rate.

Under §7's units that constraint becomes a relation between the two rates the caller supplies, and the halving/doubling of the carrier frequency disappears from the caller's view completely: an IF is stated in Hz and the intermediate rate is this object's business.

1.3 The real path is for an IF at fs/4 — and why that is the whole design

MpskReceiverR exists to decimate a real IF sitting at or near fs/4 efficiently. That is not a restriction bolted on afterwards; it is what the architecture is:

  • an R2C halfband is the cheapest real→complex converter there is — half its taps are zero and the other branch is a pure delay;
  • it bakes in an fs/4 shift for free, because at fs/4 the rotation sequence is 1, j, -1, -j, which is sign flips and rail swaps rather than multiplies;
  • and it decimates by two in the same pass, because a real signal occupying one Nyquist half needs only half the rate once it is complex.

All three are the same fact. Put the IF at fs/4 and the front end is nearly free. That is the supported placement; what follows is the tolerance around it, not an advertised operating band.

The tolerance is geometric, and it is about OVERRUN, not distance

The halfband's image rejection is deep across the middle of the band and collapses at the edges — measured on the front end alone, with a real tone in and the wanted/image ratio out:

input f 0.01 0.02 0.04 0.06 0.10 0.44 0.46 0.49
rejection −6.5 dB −13.7 −34.1 −61.3 −66.2 −61.3 −34.1 −6.5

Symmetric about fs/4, as the structure requires.

What that costs a signal is not set by where its centre sits but by whether its occupied band overruns DC or Nyquist. For a real IF at f_c with occupied half-width B (B = 1/sps to the first null of a rectangular pulse), the leaked image is the signal's own conjugate and occupies [−f_c−B, −f_c+B]. It overlaps the wanted band [f_c−B, f_c+B] exactly when

\[-f_c + B > f_c - B \quad\Longleftrightarrow\quad B > f_c\]

so the tolerance is

\[\frac{1}{\mathrm{sps}} \;<\; f_c \;<\; 0.5 - \frac{1}{\mathrm{sps}}\]

with no fixed frequency in it at all. Touching DC is free; overrunning it is what costs. Measured over 12 trials per row (sps = 10, 12, 16, 20 × three symbol seeds), placement expressed as overrun past that limit in units of B:

placement EVM range worst SER failed to lock
f_c = fs/4 (the design centre) −18.9 … −24.9 dB 0 0/12
exactly at the limit (band touches DC) −17.2 … −20.1 dB 0 0/12
0.2 B past −14.1 … −16.4 dB 0 0/12
0.4 B past −12.4 … −14.4 dB 0 0/12
0.6 B past −9.6 … −10.6 dB 1.4e−2 9/12
0.7 B past 12/12

Symmetric on the Nyquist side. Two things to read off it. EVM degrades gracefully and monotonically, so any EVM threshold on this axis is a judgement about where "degraded" starts; the error rate is what actually breaks, and it breaks where the geometry says. And the margin is not tight: half a null-width of overrun is still error-free, which is why touching DC at the design's own B = f_c is comfortable rather than marginal.

Why this was previously written down as a fixed band

An earlier statement of this constraint was 0.06 < f_c < 0.44 — the frequency range over which the front end's rejection is deep, with the pulse's half-width subtracted at the point of use. That is a sound conservative rule and it is over-conservative at high oversampling: at sps = 20 it forbids f_c ∈ [0.05, 0.11], which measures −18.8 to −20.5 dB with zero SER.

It also carried a severity that no longer holds. The same note recorded that an occupied band reaching DC drops EVM to −4 dB; it now costs 2.7 dB. The difference is not the front end — image rejection is a ratio and is unchanged, which the table above re-confirms. It is that the R2C halfband returned half the amplitude the analytic-signal convention requires, so the real path ran 6 dB down; a timing detector's slope goes as , which reached the loop as a 4× under-drive, and the harness added another 2.5× on top of that. The old figure measured an under-driven receiver failing at a marginal placement, not the placement itself.

The tell is that the penalty used to be bimodal — ~18 dB on most seeds and ~2.6 dB on the rest, depending on which rotation the carrier loop happened to settle into. With the loop correctly driven the measurement is deterministic to a few tenths of a dB. A measurement that needs a median over seeds to be stable is usually telling you something about the loop, not about the axis being swept.

1.4 Why the engine changed — and what it cost

The original build put a dense matched-filter FIR and a symsync Gardner+Farrow loop downstream of a per-sample wipe-off, with a separate boxcar "arm" feeding the NDA discriminator. That works, but it pays for the same samples several times and it does not scale in sps: a single-stage matched filter at sps = 256 needs ~4225 taps per arm.

Rebuilding on the matched DDC replaced all four pieces with one cascade:

Was Is now
per-sample integer-NCO wipe-off the DDC's LO, driven by freq_ctrl
separate boxcar arm + its own AGC the cascade's own stream, and no AGC on the detector at all
dense matched-filter FIR the terminal polyphase stage's bank
symsync Gardner + Farrow ratesync_loop_t on rate_ctrl, bank arms

The payoff is that the sample-to-symbol ratio becomes a double and the front end plans itself: at 8 samples/symbol the plan is a halfband or two plus a terminal stage; at 256 it is a CIC in front of the same terminal stage. The matched filter costs ~34 taps/arm at both ends of a 64× span of input rates. An irrational ratio — a free-running ADC clock against the symbol clock — is no harder than an integer one, because the terminal accumulator is a double and the loop only has to steer the strobe.

This was a compatibility break, deliberately taken

Outputs are not bit-identical to releases before the rebuild. The matched filter became a polyphase bank instead of a dense FIR and the interpolator became a bank arm instead of a Farrow, so recovered symbols move at the float level. Detection performance is unchanged — the fused matched filter measures on the Es/N0 bound — but exact-output pins are not.

bn_carrier also changed units, to the symbol rate. §7 changes them again, to Hz, which is the last time they move.


2. The modes

A mode is defined by which discriminator steers the carrier loop, and at what clock. That is the definition to hold onto: the shipped code names its two discriminators "acquisition" and "tracking" as though those were modes, and they are not — in Mode 1 the NDA discriminator is the tracking loop.

2.1 Mode 1 — NDA carrier tracking, full stop

One discriminator, from the first output to the last. The M-th-power NDA error steers the LO forever.

There is no handover, no warmup, no lock gate and no timing gate. Nothing in Mode 1 waits for anything. The receiver walks in from wherever it starts, and the transient is simply the cost of starting.

That is the whole point, and it is a reliability argument rather than a simplicity one: there is no state in which the receiver can be wrong about which mode it is in, because there is one. No declaring on garbage, no drop-back that never fires, no metric that has to be trusted before the loop is allowed to act.

Mode 1
steers from NDA M-th-power error, carrier_nda_disc
reads the pre-terminal stream, decimated to 2 samples/symbol (§3.3)
clock fixed at construction; independent of the timing loop and of the cascade plan
needs no data, no symbol timing, no lock
capture \|Δf\| ≲ k·Bn/M, reported as pull_in_hz (§3.4)
resolves the M-fold ambiguity never — that is the caller's, via differential demapping or a sync word

The M-fold ambiguity is permanent here. No decision-directed stage ever pins the absolute phase, so bits() defaults to the differential (rotation-invariant) demap in this mode. Coherent demapping without a downstream sync word is a misconfiguration, not a choice.

Its one quiet failure is the false lock at Δf = k·F/M (§3.5). Everything else Mode 1 does wrong, it does visibly.

2.2 What Mode 1 deliberately does not do

  • It does not search. The carrier must start inside the capture window (§3.4). A coarse frequency estimate in front — an FFT sweep, carrier_acq, or the ppe 2-D rate×freq estimator — is how a link with more uncertainty than that is closed, handed in as center_freq_hz. That variant is a separate object composed in front, not a mode inside this one, and the seam it needs is already the one parameter Mode 1 exposes. It composes in C, the way dsss_receiver already composes this receiver — a C object exposed to Python, not a Python pipeline.
  • It does not hand over to a decision-directed loop. Mode 2 will define that if it returns. This is now the shipped surface rather than a target: acq_to_track, the handover lockdet, mpsk_receiver_configure_lock() and get_tracking() were all removed in #877, on the measurement recorded in §2.4. warmup_syms went earlier, with the Costas arm (1f417e97), so it survives only on dsss_receiver.

2.3 The invariant

A mode is defined by when it reads, not where. Every rate-keyed constant must be declared in the units of the clock it actually runs on.

The shipped code violated this in three places, all of which Mode 1 removed rather than repaired — recorded here because Mode 2 will have to face them again if a second discriminator ever returns:

  1. The carrier loop filter's update period is set once, from the acquisition tap's clock, and never re-set when the decision-directed discriminator takes over at the symbol rate. With nda_tap = mf_out at m_out = 8 that is an 8× loop-gain error the moment the receiver declares. config_carrier() runs at init only.
  2. The lock EMA's α is per-update, so at a tap faster than Rs the metric's memory is shorter in symbols and its looks are correlated, which breaks the independent-look assumption its σ_H0 is derived from (§4.2).
  3. The two lockdets carry the same 8/32 verify counts on different clockshandover was stepped once per symbol, car_lock once per tapped sample. So locked meant "8 symbols" at one tap and "1 symbol" at another.

Mode 1 has one clock on the steering path and one on the reporting path, and both are fixed at construction. None of the three can arise — and all three are now closed in the shipped code rather than only in this section: one tap (#832) removed the rate spread, and one discriminator (#877) removed the second lockdet entirely.


3. Carrier recovery

3.1 De-rotation is per-sample, always

The LO wipe-off runs on every input sample, before anything else. A residual carrier rotating across an integration window costs sinc energy; the window here is the matched filter (short for I&D, long for RRC), so per-sample de-rotation is the general-purpose placement. It costs more compute than de-rotating symbols, and that is the accepted trade — it is correct for every mode without special-casing.

Since the rebuild this holds structurally rather than by convention: the LO is the first stage of the front end, so predetection de-rotation is where the signal path puts it, not something the receiver has to remember to do.

The sign convention differs between the two sides

The DDC mixes x · lo_step_ctrl(...) while carrier_nda_disc consumes x · conj(...). The loop therefore negates the filtered error before it reaches freq_ctrl. Get this wrong and the failure does not look like a sign error: timing, rate and symbol count all stay perfect, and the lock metric sits at a steady negative value (−0.48 for QPSK where +0.62 is a real lock) with every symbol parked on a decision boundary. Read the lock metric's sign, not just its magnitude.

3.2 The NDA discriminator + lock signal (canonical definition)

The M-th-power detector is computed efficiently by repeated complex squaring of the sample z = i + jq: strips BPSK, z⁴ strips QPSK, z⁸ strips 8PSK. Each squaring level yields both a phase error and a lock signal; the phase-error scale normalizes the discriminator gain so one bn behaves identically across M, and the lock signal is left unscaled so that it reads ~1.0 at lock for every M.

Normalization — the detector divides out its own amplitude law. Both outputs are normalized by |z|^M: the lock signal is Re((z/|z|)^M) and the phase error is Im((z/|z|)^M). This is the same rule the timing detector follows — a TED normalizes by its own slope (symsync_ted_slope()) — applied to its sibling. A discriminator's raw output is the phase error multiplied by things it did not choose, and amplitude is the largest of them: Im(z^M) scales as A^M, so a 2× level error is 4× loop gain at BPSK and 256× at 8PSK. |z|^M is a power of p = |z|² for every supported M, so dividing it out costs one divide and no sqrt.

This is why the receiver has exactly one AGC. There used to be a second, embedded ahead of this discriminator, whose entire job was to make |z| = 1 true so the raw form would behave. With the detector normalizing itself that condition no longer has to be manufactured, and the receiver's one AGC — in the front-end cascade — serves the signal path instead of a detector. Two level loops in series, each correcting the other's excursions, is what one per detector gets you.

The classic objection, and where it actually applies

A per-sample magnitude normalization is Yuen's "polarity-type" hard limiter, and the textbook result is that it costs 2.5–4 dB of extra squaring loss over the linear-arm form. That result is real, and it is below this receiver's operating point. Measured directly on the two detectors fed identical unit-average-power samples — loop SNR (slope² / var(e) at lock, 4×10⁵ samples per point), normalized minus raw:

Es/N0 M=2 M=4 M=8
0 dB −0.63 −2.08 −2.17
3 dB −0.37 +0.16 −4.70
6 dB −0.04 +1.35 +0.88
10 dB +0.04 +1.22 +3.47
15 dB −0.00 +0.51 +2.52
20 dB −0.00 +0.16 +1.03

The penalty exists, at 0–3 dB Es/N0 — where 8PSK's loop SNR is −20 dB and the link cannot be closed regardless. From ~6 dB up, which includes every SER=1e-3 anchor, normalizing is equal or better, and the advantage grows with M. The reason is the same one that used to justify a 10 dB square clip on the AGC output: constructive-ISI peaks dominate the |z|^M weighting, and the clip bounded them approximately. Normalizing removes them exactly, and the clip goes with the AGC that carried it.

Input scaling is not a precondition of this detector at all. The front-end AGC still levels the signal path — which both loops run on — because the timing detector needs unit symbol amplitude for its construct-time slope to mean what it says. This detector is indifferent to the level, not to the AGC.

# osr = sample_rate // symbol_rate   # input oversampling, typ. 4
# The arm is a free-running half-symbol boxcar moving average (no rate
# conversion); i, q are its per-sample outputs (one per input sample),
# AGC-normalized to unit average power.  esno = symbol energy-to-noise-density
# ratio, dB.
#
# Squaring loss S_L (Lindsey-Simon / Yuen): the SNR penalty of the M-th-power
# nonlinearity; S_L <= 1 always, so sq_loss_dB <= 0.  Measured from the loop it
# is  slope**2 / var(phase_error) / rd  using the ACTUAL S-curve slope at lock
# (= 2 only for a constant-modulus arm; it collapses on a pulse-shaped arm,
# see below) — NOT a hardcoded 4.

import numpy as np

mod = "BPSK"                       # one of "BPSK", "QPSK", "8PSK"
esno = 10.0                        # symbol Es/No, dB
i = np.array([0.71, -0.62, 0.80])  # example half-symbol boxcar arm outputs
q = np.array([0.10, -0.21, 0.05])  # (unit-power, one pair per input sample)

rd = 10 ** (esno / 10.0)

bpsk_lock = i**2 - q**2          # Re(z^2)
bpsk_phase_error = 2 * i * q     # Im(z^2)

if mod == "BPSK":
    phase_error = bpsk_phase_error
    lock_signal = bpsk_lock                                           # Re(z^2)
    # Yuen Eq. 8-19 (passive arm-filter Costas loop), half-symbol boxcar arm.
    # Verified moments: K2 = 5/6, K4 = 23/30, KL = 2/3, Bi/R = 2 (z = Bi/R/2 = 1).
    K2, K4, KL, z = 5 / 6, 23 / 30, 2 / 3, 1
    S_L = K2**2 / (K4 + KL * z / rd)   # high-SNR floor K2**2 / K4 = 0.906 = -0.43 dB
    sq_loss_dB = 10 * np.log10(S_L)
elif mod == "QPSK":
    phase_error = bpsk_phase_error * bpsk_lock                        # ~ Im(z^4)
    lock_signal = bpsk_lock**2 - bpsk_phase_error**2                  # ~ Re(z^4)
    # No clean closed form for the M-th-power passive loop; empirical fit (dB).
    sq_loss_dB = -0.0564724 * esno**2 + 1.90284531 * esno - 15.65792221
else:  # 8PSK
    qpsk_phase_error = bpsk_phase_error * bpsk_lock
    qpsk_lock = bpsk_lock**2 - bpsk_phase_error**2
    phase_error = qpsk_phase_error * qpsk_lock                        # ~ Im(z^8)
    # The 4 is load-bearing: qpsk_phase_error is HALF of Im(z^4), so squaring it
    # needs the 2 squared back for this to be Re(z^8). Without it the statistic
    # is Re(z^4)**2 - Im(z^4)**2/4 -- equal to Re(z^8) at phi=0 and nowhere else,
    # and biased positive on noise where Re(z^8) is zero-mean.
    # NB the shipped lock signal LIMITS first (divides by |z|^M) so its H0
    # variance is 1/2 at every M; see "the lock statistic" below. Only the
    # phase error keeps the raw |z|^M weighting.
    lock_signal = qpsk_lock**2 - 4 * qpsk_phase_error**2              # Re(z^8)
    sq_loss_dB = -0.14285557 * esno**2 + 5.70706958 * esno - 58.13670891

# Coherent (data x squaring) gain of the free-running half-symbol boxcar arm:
#   Re E[z_d^M] = 1/2 + 1/(M+1)   ->  M=2: 5/6,  M=4: 7/10,  M=8: 11/18.
# For BPSK this is exactly K2 = 5/6;  mod_loss_dB = 10 * log10(1 / K2).
mod_loss_dB = 10 * np.log10(1 / (5 / 6))
  • phase_errorIm(z^M) (scaled) — a sawtooth S-curve of period 2π/M, the M-fold phase ambiguity. It steers the NCO; the ambiguity is resolved downstream (differential demap or a sync word).
  • lock_signal = Re((z/|z|)^M) — the M-th power of a limited sample: ≈ 1 when phase-locked, zero-mean with no carrier, bounded in ±1, and with an H0 variance of ½ at every M. In Mode 1 it is a report, not a control input (§4).

Derivation — the recursion is the M-th-power loop

Write the sample z = i + jq. The first level is literally :

bpsk_lock        = i² − q² = Re(z²)
bpsk_phase_error = 2iq     = Im(z²)

Each subsequent level squares the running pair and reads off its real/imaginary parts, so (lock, phase_error) climbs the powers z² → z⁴ → z⁸. Verified exactly (residual 0 over a full phase sweep):

M phase_error lock_signal (before limiting)
2 Im(z²) Re(z²)
4 ½·Im(z⁴) Re(z⁴)
8 ¼·Im(z⁸) Re(z⁴)² − 4·(½·Im(z⁴))²

So phase_error is exactly the M-th-power discriminator Im(z^M), scaled by 1, ½, ¼. That scale is not arbitrary — it normalizes the phase-detector gain across M. The S-curve slope at lock is M × scale = 2 for every M, so one loop-filter bn behaves identically for BPSK / QPSK / 8PSK.

The lock_signal is Re(z^M) at every M, and the recursion's ½ has to be undone to get there. Because the carried imaginary term is qe = ½·Im(z⁴), the 8th-power real part is Re(z⁸) = Re(z⁴)² − Im(z⁴)² = ql² − (2·qe)², so the lock expression needs the factor of 4 written out explicitly.

This was wrong until 2026-07-27, and the reasoning that kept it wrong

The lock signal read ql² − qe² — i.e. Re(z⁴)² − ¼·Im(z⁴)² — and this section argued the shortfall was an acceptable trade: "the two coincide at lock, so it remains a faithful monotone lock detector; making it exact would require doubling the carried imaginary term, which would break the constant-gain property."

The premise is false. Making it exact requires multiplying by 4 inside the lock expression, which does not touch the carried term and so cannot affect the phase-detector gain at all. There was never a trade to make.

And "coincides at lock" was the wrong test. Both forms are exactly +1.0000 at φ = 0, so every locked measurement agreed and the error was invisible where anyone looked. It differed everywhere else — including on noise, where Re(z⁸) is zero-mean but Re(z⁴)² − ¼·Im(z⁴)² is not, leaving a positive residual of ¾·E[Im(z⁴)²]. Measured on unit-power complex Gaussian noise: mean +8.94 instead of −0.11. A lock detector is thresholded against its noise-only distribution, so the one region the form was wrong in was the only region that sets its false-alarm rate.

Gain collapse on a pulse-shaped input — the loop still locks. The raw M-th-power coherent gain is Σ_k g_k^M over the pulse taps g. For a constant-modulus input this yields the S-curve slope 2; for a pulse-shaped (pre-matched-filter RRC) input Σ g_k⁴ is minuscule and the slope collapses ~80×. Because the loop filter is type-2 (PI), the steady-state frequency error is still driven to zero — the loop locks on RRC as well as constant-modulus — only pull-in is slower and jitter higher. This is the regime the pre-terminal tap operates in by construction (§3.3), so it is a property of the design rather than an edge case.

3.3 Where the discriminator reads — the strobe, and why the menu closed

Settled. The NDA discriminator reads the on-time strobe, and that is not a default any more — nda_tap is gone, along with the mf_out and mf_in code paths (#832). This section is the record of how three taps became one, because the reasoning is worth more than the knob was.

The strobe's timing dependency costs nothing where it would matter. It is the only tap whose input quality depends on the timing loop, which looks like a liability exactly when the carrier must acquire before timing can close. It is not: a carrier with its modulation off is sampling-phase invariant — every sample is the same constellation point — so the M-th-power discriminator does not care which phase the timing loop nominated. NDA is the implicit answer when there is no data to be aided by, and the strobe is where it reads.

Measured on that waveform (NRZ, modulation off then dense, under a coupled Doppler ramp — native/validation/rx_dynamics.c), the strobe wins on every axis:

tap lock, modulation OFF min at the data onset end
strobe +0.935 +0.860 +0.920
mf_out +0.934 +0.478 +0.802
mf_in +0.761 +0.417 +0.714

mf_in's price was a bandwidth ratio, not a tuning problem. Its node carries 10·log10(bank_sps) dB of excess noise bandwidth — measured 6.01 dB at bank_sps = 4, identical at 6.79, 12 and 20 dB Es/N0, which is what a pure bandwidth ratio looks like rather than an SNR-dependent effect. Recovering it needs an arm filter, which costs serialized state on every object carrying the tap, while the strobe reads the node already matched to the signal for free. mf_in was pinned as the continuous flavor's tap first, on the structural argument that a tap ahead of the matched filter needs no timing — and withdrawn on measurement, because that argument was never tested on this flavor's own waveform (doppler#790).

The measurement that ranks taps is not the obvious one

Ranking taps by reading norm_freq back at the design centre cannot rank them, and ranks them confidently anyway. At zero offset the correct answer is zero, so a carrier loop that never steers reports a perfect error — and reports it more perfectly than a loop that works: a real loop shows its own jitter (~1e-2 Hz), a dead loop shows the double-precision zero it was initialised with (~1e-9 Hz). Ranked on that number the deadest loop wins by six orders of magnitude and the ranking is exactly inverted.

That is not hypothetical. It is how this tap's headline number was originally produced, and the inversion survived into a commit message (strobe 2.3e-03 Hz vs mf_in 1.9e-09 Hz) because those two numbers are genuinely what those two loops print at 0 Hz offset. The sound measurement is how much of a KNOWN offset each tap actually removes, which is what the retired rx_nda_tap.c harness swept. The lesson outlives the harness: an estimator's error at the value it is initialised to is not a measurement of the estimator.

What the taps were for still exists, as a derivation rather than a menu. The pull-in range an M-th-power detector can see is |df| < F/(2M) at update rate F, and reading at the strobe fixes F = Rs. A caller who needs more range does not pick a different node; they state the requirement and the receiver derives what it can meet, or refuses (§8.2). That is the same information the tap choice carried, asked as a question the caller can answer.

The 2-samples/symbol decimation, considered and declined

The original argument stands on its own terms and is kept because the trade is live: bank_sps = sps / D for whatever integer decimation D the plan chooses (RateConverter_core.c), so mf_in's clock is a planner outcome. At the default (8 samples/symbol, m_out = 8, terminal rate 1.0) the planner decimates by nothing and the stream sits at 8 samples/symbol — four times past Nyquist, with heavily correlated neighbours. The matched filter wants that fine grid; the discriminator gains nothing from eight correlated looks per symbol and pays for all of them. Decimating to 2 sps for the carrier loop alone needs no new filtering (the signal occupies ±(1+β)/2·Rs, inside the ±Rs a 2-sps grid supports) and would have bought a construction-constant clock, a pull-in ceiling of a stated Rs/M, and approximately independent looks.

It was not built, for a reason this document did not anticipate: a decimator carries state. The tap takes the raw stream so that it costs no serialized state at all — which matters because every stateful thing in this receiver has to appear in the state blob, be packed, be versioned, and resume bit-exactly (docs/design/state-serialization.md). A decimator on the carrier path buys a tidier clock and pays for it in the one place this project is strictest about.

So the consequence is real and stands: mf_in's pull-in ceiling moves with the caller's rate ratio rather than being quotable as a constant. mpsk_rx_updates_per_symbol() reports what the rate actually came out as, which is the honest substitute for a number the architecture will not fix. Whether that trade should be revisited is open; it is not a gap left by accident.

3.4 Pull-in is a loop-bandwidth property, not a tap property

This corrects the shipped documentation, which is the other way round. The header and the previous version of this document called the tap point "the pull-in range". The measured tap ranges below were each taken at that tap's own best bn_carrier — so what they measure is how wide a loop each tap tolerates, not a range the tap confers. At a fixed bn_carrier all three taps measured the same 0.01·Rs.

There are two bounds, and the tighter one is almost never the tap's:

bound value what it is
aliasing ceiling F/(2M) = Rs/M at the 2-sps clock above it the M-th-power phase advances more than π per update and the error folds
loop capture ≈ k·Bn/M where pull-in is prompt and predictable

Both scale as 1/M — the S-curve is periodic in 2π/M however its slope is normalised — so an 8PSK receiver needs four times tighter tuning than BPSK at the same bandwidth. At Bn = 0.01·Rs the loop bound is tighter than the ceiling by a factor of tens, so the ceiling is nearly never what a caller hits.

The contract, and it is Mode 1's one real precondition: the residual carrier must start inside the capture window. pull_in_hz is a reported derived value (§8) so the caller can check their tuning uncertainty against it in their own units. It is documented and reported, not guarded — the receiver never learns the true offset, so construction cannot check it. The signature of violating it is freq_hz walking while locked never asserts, and that is worth recognising as a tuning problem rather than a bug.

k is the standard second-order capture constant, but loop_filter has its own (bn, ζ, t) parameterisation, so the number that goes in this document is measured against the shipped filter rather than quoted from a textbook.

Measured on the shipped strobe tap, for the record

Largest unaided frequency error each tap could still acquire, QPSK at 8 samples/symbol, each at its own best bn_carrier. These describe the shipped architecture, not §3.3's:

tap m_out = 4 m_out = 8
strobe 0.010·Rs 0.050·Rs
mf_out 0.015·Rs 0.033·Rs

Both rows move with m_out, which is the mechanism showing through: both taps sit on the terminal stage's grid. The tap that does NOT move with it is mf_in, which reads ahead of the MFR — but its number is not measured yet (gh-766), so the check on §3.4's claim is currently the shape of these two rows rather than a third.

3.5 Stable false lock at Δf = k·F/M

F/M is exactly where an M-th power at update rate F aliases onto zero, so the M-fold ambiguity is a frequency ambiguity as well as a phase one. Measured on QPSK with an initial error of Rs/4 on the symbol-rate tap: the loop never moves, ending precisely where it started (tracked frequency 2e-6 against a true 0.03125), and reports a lock statistic of +0.83 against the ≈ 1.0 a real lock reads.

Nothing self-referenced detects this. The constellation is stationary, so self-referenced EVM looks clean, blind M2M4 looks clean, and the lock metric looks healthy. It takes an external frequency reference, or a sync word / known preamble.

This is Mode 1's single quiet failure, and the defence is upstream: a coarse estimate passed as center_freq_hz. The 2-sps tap moves the alias out to 2·Rs/M, which is a wider spacing than the shipped symbol-rate tap gives, but it does not remove the mechanism.


4. The lock indicator — telemetry, not control

In Mode 1 nothing in the receiver reads the lock statistic. It steers on the discriminator alone. lock (the EMA) is a debug trace and locked (its hysteretic binary counterpart) is a user-facing confidence signal — and that makes their honesty more important, not less: they are the only thing a caller has to judge whether the receiver is working.

It is computed on the on-time strobe, whatever steers the loop. That is the deliberate opposite of §3.3: steer wide and unmatched from the pre-terminal stream; report clean and calibrated from the matched-filtered symbol. One look per symbol, approximately independent, best available SNR — so one calibration covers every configuration instead of degrading silently with the steering clock.

4.1 The detector spec

Partly built. lock_thresh stops being a raw number the caller picks — and as of gh-644 it already is one the caller need not pick: passing 0 gets the derived σ_H0·η(Pfa) = 0.4999 at Pfa = 5e-6, reported back by get_lock_thresh() (§8.1). That is the Pfa half only. The rest of this section — deriving α and the verify counts alongside the threshold, and coupling all three to a Pd at a stated Es/N0 — is still design. So the shipped receiver derives a threshold from a false-alarm budget it does not tie to any detection probability, which is the same criticism made below, one parameter smaller.

The indicator is specified as a detection problem, with adjustable defaults:

esn0_floor_db = 4.0     the operating point the indicator must still work at
pd            = 0.99    probability of declaring when locked
pfa           = 1e-5    probability of declaring when not

From those, three things are derived together rather than set independently: the EMA α, the threshold, and the lockdet verify counts. The shipped code sets the first two from unrelated criteria — α = 0.05 from a 15.9 dB estimator-SNR target, lock_thresh from a Pfa budget alone — and ties neither to a Pd at any operating point. gh-644 moved the threshold from picked (0.5) to derived (0.4999); it did not couple it to α, so this paragraph still describes the shipped code.

The equations already exist; this is composition, not derivation:

link source
per-look H0: mean 0, variance ½, every M carrier_nda_core.h — the limiter is what makes it M-independent
per-look H1 mean at a given Es/N0 the squaring-loss chain in §3.2
EMA → N_eff = (2−α)/α, σ_H0 = sqrt(½·α/(2−α)) analytic, and measured to agree
per-look budget ↔ verify count det_verify_count(p_look, p_target)
declare latency det_verify_delay(p_look, n)

det_threshold() is the wrong law here, and it will look right

It inverts Pfa = exp(−η²/2) — the envelope/amplitude-ratio statistic — giving 4.94 at Pfa 5e-6. This statistic is Gaussian after the EMA and wants Q⁻¹(5e-6) = 4.417, which is exactly the 4.416 carrier_nda already quotes. So the existing default was computed on the Gaussian tail, and detection has no primitive for it. That quantile belongs in detection beside the others, not hand-rolled at the call site.

This came true rather than being heeded. gh-644 derived the threshold and carried η = 4.4159 as a literal in MPSK_RX_LOCK_THRESH_DEFAULT's comment, with the product pre-multiplied into 0.4999 — so the derivation is now written down in two places (here and that comment) and computed in none. Adding the Gaussian quantile to detection is still the open work, and is now the difference between a derived default and a hard-coded one that is merely described as derived.

Publish the latency. det_verify_delay(pd_look, n_up) × the look period is how long the indicator takes to light up. That number is the difference between a sane indicator and a mysterious one, and it is currently nowhere.

Two smoothers in series is a hazard to be chosen deliberately: the EMA's memory and the verify counter both average, and the spec must say whether pfa is per-look or per-declare — through det_verify_count those differ by orders of magnitude.

8PSK at a 4 dB floor is the binding case. The measured post-EMA d' for 8PSK is 1.76 at 10 dB, so at 4 dB the required N_eff grows hard and the declare latency with it. If a spec is unreachable, construction should say so rather than averaging for thousands of symbols and never declaring.

4.2 Limiting — what makes the threshold a Pfa

Re(z^M) on a raw sample is unbounded and its noise variance grows with M: at M = 8, |z|⁸ on Gaussian noise gives it an sd of 137 per look against a value of 1.0 at lock. The shipped lock signal therefore limits first:

lock_signal = Re((z / |z|)^M)

Under H0 the phase is uniform, so Var[Re(e^{jMθ})] = ½ for every M — the statistic is bounded in ±1 and its H0 law is M-independent, which is precisely what lets a single threshold mean a single false-alarm probability at every constellation order. With α = 0.05 (N_eff = 39 looks), σ_H0 = sqrt(½·α/(2−α)) = 0.1132 analytically and 0.1132 measured, so the shipped threshold is 4.42 σ — a per-look Pfa of 5e-6. Measured end to end, 100 noise-only runs × 20 000 symbols: 0/100 false declares at every M.

That threshold is now derived rather than picked: gh-644 made 0 request MPSK_RX_LOCK_THRESH_DEFAULT = σ_H0·η = 0.1132 · 4.4159 = 0.4999, which is the 0.5 that shipped. The measurements above therefore stand unchanged — the two values differ by 1e-4 — and that is the point of recording it here rather than silently substituting the new number: a value that was picked and a value that was derived look identical right up until one of them has to move.

That derivation assumes independent looks, which is a property of the tap rather than of the statistic — and is why §4 computes it on the strobe.

This costs H1 — limiting discards the |z|^M weighting that helps at low SNR — and is still a large net win at every order, because H0's variance falls by much more than H1's mean does. Detectability d' = (μ_H1 − μ_H0)/σ_H0 at Es/N0 = 10 / 20 dB, raw → limited: BPSK 5.70/6.21 → 7.95/8.75, QPSK 1.50/1.78 → 5.81/8.47, 8PSK 0.02/0.04 → 1.76/7.52. With the raw form only BPSK ever cleared a 1e-3 Pfa, so for M ≥ 4 there was no Pfa-derived threshold available at all.

4.3 The telemetry surface — everything exposed, everything plotted

Every probe the receiver registers is captured and plotted from Python, and that is enforced rather than intended: src/doppler/examples/mpsk_telemetry_capture_demo.py attaches one ring at decim = 1 (every event on every probe) and asserts that the recovered series match tlm.probe_names exactly, that no record was dropped, and that the capture round-trips through its own file bytes. test_examples.py runs it on every push. A probe added without a plot fails the example, so the surface cannot quietly grow past what a user can see.

Mode 1 puts three different clocks on that one ring, and they must be compared by time, never by record index:

probe group clock
rx.lock symbol — computed on the on-time strobe (§4)
rx.car.e, rx.car.freq, rx.car.locked the carrier clock, 2 samples/symbol (§3.3)
rx.sync.e, .ctrl, .rate, .lock, .locked, .mu symbol
rx.agc.gain_db, rx.agc.level_db the pre-terminal gain-update grid

Two changes from the shipped surface, both consequences of §3.3:

  • The carrier probes leave the symbol grid. Today the discriminator reads the strobe, so its probes are symbol-rate; moving it to the pre-terminal tap moves its telemetry with it. This is exactly the situation the AGC probes are already in, and the same warning now covers both — which is why the capture demo plots real seconds rather than record indices.
  • rx.tracking is GONE (#877). With no handover it was a constant 0, and a probe that cannot vary is not a diagnostic. It returns with Mode 2, if Mode 2 does. The receiver publishes 15 probes, not 16.

5. Matched filter (I&D default, RRC opt-in)

As built, this is the cascade's terminal polyphase stage, not a separate FIR (§1.4) — but the two shapes and their meaning are unchanged:

  • I&D / boxcar (default): the matched filter for a rectangular NRZ symbol pulse (and the natural front for a despread chip stream).
  • RRC (opt-in): rrc_taps(beta, sps, span) — matched to an RRC-shaped transmitter. It stays in normalized units: it is a primitive, not a receiver (§7).

Use m_out >= 4 with pulse="iandd"

The rectangle is one symbol wide, so at m_out = 2 its matched filter degenerates to a two-tap sum and the eye barely opens — measured lock statistic −0.34 at m_out = 2 against +0.95 at 4 on the same NRZ stream, and acquisition itself fails about half the time (4/8 seeds locked at 14 dB Es/N0, against 8/8 at both 4 and 8). The matched converter reports this itself: narrow_pulse is a real property and construction raises a UserWarning.

m_out = 8 is the default because that is where an I&D matched filter reaches the coherent bound. The rectangle is one symbol wide, so its filter is an m_out-tap sum spanning it, and a smaller m_out samples the same integral more coarsely. Measured on QPSK against EVM_dB = -(Es/N0)_dB: at 18 dB Es/N0, m_out = 8 lands 0.41 dB off the bound where m_out = 4 loses 3.11 dB; at 14 dB it is 0.25 dB against 1.71 dB — the gap widens as noise stops hiding it.

There is a second, M-dependent reason. z^M auto-convolves the spectrum M times, spreading energy over ~M·Rs, and whatever exceeds the update rate folds back. A clean strobe raises to a constant with nothing to fold, but every departure from clean is splattered M-fold and aliased — so the nonlinearity's tolerance for a coarse matched filter collapses as M grows. Measured by halving m_out from 8 to 4, each M at its own SER=1e-3 anchor: BPSK 1.7 dB, QPSK 1.6 dB, 8PSK 3.0 dB — the last also sitting 0.87 dB from the fully-scattered EVM floor.


6. Symbol timing

The receiver embeds ratesync_loop_tRateSync's timing loop itself, factored out of ratesync_state_t so the cascade and the loop are separable. That split was made bit-exact deliberately, and it is what keeps a single implementation of Gardner + the PI filter + the lock statistic serving both objects. Timing stays modulation-agnostic (|·|²), so it settles in parallel with carrier acquisition — and in Mode 1 the two loops are fully decoupled (§1.1), so neither waits on the other in either direction.

The strobe geometry is the terminal stage's: every m_out-th output is the on-time strobe, and the one m_out/2 back is the transition gate. There is no second timing mechanism — the terminal accumulator alone controls timing, and the polyphase arm is that phase's fractional read-out.

SymbolSync's second selectable TED (ted="dttl") is reachable here (RATESYNC_TED_DTTL), but the receiver stays hardcoded to Gardner: DTTL's hard-decision device is only valid for constellations with independent, rectangular I/Q boundaries (BPSK/QPSK), not the 8PSK this receiver also supports.

6.1 The TED's only normalisation is its own slope

A timing detector's raw output is the timing error multiplied by three things the detector did not choose:

factor whose business how it is handled
signal amplitude the AGC's, upstream a level contract (§1.1), not an estimate
transition density nobody's — it is data left alone
the detector's own slope against the pulse the detector's computed at construction

Only the third belongs inside the TED, and it is the only one that can be computed rather than estimated. The matched pair's composite is a raised cosine in closed form (wfm_rc_h()), so for i.i.d. symbols the mean detector output is a construct-time expression:

Gardner:  S(tau) = sum_k g(tau-1/2-k) * [ g(tau-k) - g(tau-1-k) ]
DTTL:     S(tau) = g(tau-1/2) - g(tau+1/2)

symsync_ted_slope() evaluates |dS/dtau| at the lock point and the loop stores its reciprocal, so the hot path is one multiply. Validated against the slope measured open-loop through a real HB + matched cascade: Gardner within 1.3–8.6% across roll-off 0.1…0.9, DTTL within 0.2%. The rectangle falls out at its analytic values, Gardner 1.4997 and DTTL 2.0000.

What this replaced was wrong in three separate ways

The shipped code divided by a 1%-per-symbol average of |on|² + |mid|².

It is an quantity, and only one of the two detectors has an law. Gardner's amplitude axis came out right; DTTL's loop gain was left proportional to 1/A — a 4× swing over a 4× level change, in the detector BPSK and QPSK select.

It normalises the wrong term. Amplitude is not the detector's contribution; its slope is. Measured through the cascade, the normalised slope varied 10.6× between roll-off 0.1 and 0.9, so bn = 0.01 meant something an order of magnitude different at the two ends of the supported range — silently, because nothing reads a loop bandwidth back.

Being an average, it lagged. Seeded on the first post-prime strobe — which lands in the cascade's amplitude ramp, orders of magnitude below steady state — it ran the loop at thousands of times its designed gain through exactly the interval that decides acquisition. That wound the integrator past pull-in: on a fine sweep of initial timing offsets, a 0.3-symbol-wide band took 7000–25403 symbols to recover. With the lag gone the same band acquires in 133–266 at every offset, and the peak normalised error falls from 38 to 0.13.


7. Rates and units

Design, not yet built. The receivers take sample_rate_hz and symbol_rate_hz; the primitives they compose stay normalized.

That boundary is a rule, not an exception: an object that owns a complete signal chain takes physical units; a stream processor takes normalized ones. Eleven objects already sit on the physical side — acq, carrier_acq, dll, burst_demod, doppler_channel and the measure suite among them — so this ends the receivers' status as the outliers rather than setting a precedent. DDC, RateConverter, RateSync, resamp and the loops are unchanged.

sps does not disappear; it becomes the derived double sample_rate_hz / symbol_rate_hz and leaves every API. Nothing about the cascade planner, the terminal accumulator or the irrational-rate property changes — this is the face, not the DSP.

Everything currently normalized converts with it, and each conversion deletes arithmetic a caller is doing by hand:

now then
bn_carrier, bn_timing — cycles/symbol Hz
init_norm_freq — cycles/sample at the input rate center_freq_hz
pull-in \|Δf\| < F/(2M) pull_in_hz
get_norm_freq freq_hz
get_timing_rate — tracked samples/symbol clock offset in ppm

Both rates are required; there is no sane default sample rate, so the sps = 8.0 default goes with them.

The conversion already exists, by hand, in the composers

dsss_receiver_core.c holds a symbol rate in Hz, multiplies by an integer sps to get a target rate, and converts back the other way for its Doppler estimate. Two things fall out of that when the receivers take Hz: those conversions go, and — because that sps is a size_t — the double-sps property this architecture advertises stops being silently lost on every DSSS-composed receiver.

The same file carries a hand-written dsss_rx_derive_m_out(), which is §8's m_out rule implemented in the wrong place. It has already drifted once, returning an illegal m_out for odd sps and turning a create() failure into a process abort.

That is now a duplicate of a shipped primitive, not merely a misplaced rule. gh-644 gave the rule a canonical home — mpsk_rx_derive_m_out(cap, strict) in mpsk_rx_loops.h, which both receivers call — and dsss_receiver_core.c was not migrated onto it, so the tree carries two implementations of one rule. Their return values already differ below sps = 2: the shared rule refuses (0), while dsss_rx_derive_m_out() floors at 2.

The outcome is the same, and that is worth saying rather than implying otherwise — measured, both m_out = 0 and m_out = 2 at sps = 1 are rejected by mpsk_receiver_create(), so migrating changes which rule refuses, not whether it does. The duplication is a drift hazard, not today's bug.

Today's bug is next to it: dsss_receiver_create() guards only sps < 1, so sps = 1 is accepted, reaches that rejection, and is fed to dp_xnn() — an abort-on-OOM helper — on a NULL that means "invalid argument". DsssReceiver(sps=1) therefore SIGABRTs the interpreter with no exception and no message, which is the same failure this note already records for odd sps, still live at one value. Both are #782.


8. The construction surface

Partly built — the derivations landed (gh-644); the units and esn0_floor_db have not. §8.1 is the surface as it stands today and §8.2 is what remains. The principle is unchanged: the caller states the link, not the loops. One number they possess — the Es/N0 they must still work at — drives both the loop bandwidths and the lock indicator.

8.1 What is derived today, and the mapping

Zero means derive. Each parameter keeps its place in the signature, so a caller who wants to pin one still can; passing 0 — which every one of these validators previously rejected, so no working call site can be relying on it — asks the object for its own answer. That is what makes this additive rather than a break, and it is why the surface still lists seventeen parameters while a caller supplies four.

The minimal call, with every derivable knob left at zero:

#include "mpsk_receiver/mpsk_receiver_core.h"
#include <math.h>
#include <stdio.h>

int
main (void)
{
  mpsk_receiver_state_t *rx = mpsk_receiver_create (
      4, 8.0,                  /* m, sps — the signal                      */
      0,                       /* m_out       -> derived                   */
      MPSK_RX_PULSE_IANDD, 0.35, 8,
      0.01,                    /* bn_carrier — a design axis               */
      0.0,                     /* zeta        -> derived                   */
      0.01,                    /* bn_timing  — a design axis               */
      0.0,                     /* lock_thresh -> derived                   */
      0.0, 0,                  /* init_norm_freq, differential             */
      0,                       /* num_phases  -> derived                   */
      1,
      0.0);                    /* bn_agc_ratio -> derived                  */
  if (!rx)
    return 1;                  /* every zero above is a request, not a
                                  rejected argument — which is the whole
                                  claim this section makes */
  printf ("m_out=%zu zeta=%.4f num_phases=%zu lock_thresh=%.4f "
          "bn_agc_ratio=%.4f\n",
          mpsk_receiver_get_m_out (rx), mpsk_receiver_get_zeta (rx),
          mpsk_receiver_get_num_phases (rx),
          mpsk_receiver_get_lock_thresh (rx),
          mpsk_receiver_get_bn_agc_ratio (rx));

  /* The doc gate compiles this and requires exit 0, so the five numbers
     printed above are CHECKED here rather than transcribed below. */
  int ok = mpsk_receiver_get_m_out (rx) == 8
           && fabs (mpsk_receiver_get_zeta (rx) - 0.7071) < 1e-4
           && mpsk_receiver_get_num_phases (rx) == 64
           && fabs (mpsk_receiver_get_lock_thresh (rx) - 0.4999) < 1e-4
           && fabs (mpsk_receiver_get_bn_agc_ratio (rx) - 0.05) < 1e-4;
  mpsk_receiver_destroy (rx);
  return ok ? 0 : 1;
}

which prints what it chose:

m_out=8 zeta=0.7071 num_phases=64 lock_thresh=0.4999 bn_agc_ratio=0.0500

The Python face is the same call, and — this is the point of the readbacks — reports the same five numbers:

>>> from doppler.track import MpskReceiver
>>> rx = MpskReceiver(m=4, sps=8.0, bn_carrier=0.01, bn_timing=0.01)
>>> (rx.m_out, round(rx.zeta, 4), rx.num_phases,
...  round(rx.lock_thresh, 4), round(rx.bn_agc_ratio, 4))
(8, 0.7071, 64, 0.4999, 0.05)
parameter today
m, sps supplied — the signal description
pulse, rrc_beta, rrc_span supplied — the waveform, and only when it is not NRZ
bn_carrier, bn_timing supplied — the two real design axes (§8.2 derives these)
init_norm_freq supplied — the carrier seed
m_out derived — the largest even count in 2..8 the rate allows
zeta derived1/√2; a constant, not a computation
num_phases derived — 64, the measured saturation, against 1024
lock_thresh derivedσ_H0·η(Pfa) = 0.4999 at Pfa = 5e-6
bn_agc_ratio derived — 20× slower than the slowest loop it feeds
differential, agc supplied — §8.2 removes them
acq_to_track, nda_tap GONE — #877 and #832

Everything derived is reported, on the same argument as RateConverter.stages: a caller who can read back what was chosen can check it. get_m_out, get_zeta, get_num_phases, get_lock_thresh and get_bn_agc_ratio are those readbacks.

The real twin's nominal carrier is fs/4, and its default is not

MpskReceiverR is designed for a real IF at fc ≈ fs/4, and the reason is structural: the fs/4 shift is embedded in the R2C halfband's coefficients, so the down-conversion is free and the LO only removes the residual offset from fs/4. That is what ddcr_create_matched(-(2·init_norm_freq + 0.5)) says — the +0.5 is the filter's shift, so init_norm_freq = 0.25 tunes the LO to exactly zero. Every worked example in the header uses 0.25.

The shipped default is 0.0, which asks the LO to undo the filter's own shift and puts the signal where the halfband folds. Measured, BPSK at Es/N0 30 dB, sps = 32, mean |Im|/|Re| on the settled constellation (0 is perfectly de-rotated):

stimulus tuned to lock \|Im\|/\|Re\|
IF at fs/4 0.25 +0.998 0.026
IF at fs/4 0.0 (default) +0.463 0.276
IF at DC 0.0 (default) +0.943 0.164
IF at 0.2·fs 0.20 +0.996 0.029

So the default is off-design rather than broken — it demodulates a real signal at DC, at ~6× worse constellation quality than the point the architecture was built for. Unlike the five parameters above, 0.0 cannot become a "derive" sentinel: it is a legal value today with call sites relying on it, so moving the default to 0.25 is a behaviour change and belongs in its own step, not folded in with the additive derivations.

The real twin's m_out rule contradicted the constructor it feeds

This section previously gave the real twin min(8, 2·floor(sps/4)). That rule yields a value mpsk_receiver_create_real() rejects at exactly the rates where deriving would help most: sps = 8 yields 4 (needs 8 > 8) and sps = 16 yields 8 (needs 16 > 16), against its sps > 2·m_out constraint — Ddcr needs a decimation ratio below 0.5.

A derivation whose answer cannot be built is worse than a default, so the rule is now stated against the constraint rather than the rate: mpsk_rx_derive_m_out(cap, strict) takes sps inclusively for the complex twin and sps/2 strictly for the real one, and both twins call the one function. Measured after the fix, every derived value constructs at sps 6…256, and sps = 4 on the real twin refuses — correctly, since behind the halfband it cannot carry even two outputs per symbol.

8.2 What remains

The surface is a C surface. Every derivation below happens inside mpsk_receiver_create(), calling detection's C primitives; the readbacks are C getters. There is no Python factory, no compose.py helper and no Python assembly of a front end and two loops — jm generates the binding over the C object and nothing else. This is the project's C-first rule, and it is worth restating here because a construction surface that derives things is exactly where a convenience wrapper starts to look reasonable.

mpsk_receiver_create (sample_rate_hz, symbol_rate_hz,     /* the LINK        */
                      m, pulse, rrc_beta, rrc_span, carrier_freq_hz,
                      acquire_time_s, doppler_rate_hz_s,  /* the REQUIREMENT */
                      coherent,
                      esn0_floor_db, pd, pfa)             /* the DETECTOR    */

Exactly two arguments are required — sample_rate_hz and symbol_rate_hz. Everything else defaults, m included, and m defaults to 2: the first target is a continuous BPSK receiver (§0), so MpskReceiver(fs, rs) is that receiver rather than a special case of it.

The Python face is that signature, keyword-capable, with the defaults jm reads from objects/mpsk_receiver.toml:

MpskReceiver(sample_rate_hz=8e6, symbol_rate_hz=1e6)      # a BPSK receiver
MpskReceiver(sample_rate_hz=8e6, symbol_rate_hz=1e6, m=4) # ... and a QPSK one

The three groups are the three questions a caller can actually answer:

group what it states when to pass it
link what is on the wire pulse/rrc_beta when the transmitter is not NRZ; carrier_freq_hz when the signal is not centred (required on the real face)
requirement how fast lock is needed, how fast it moves acquire_time_s, doppler_rate_hz_s when the dynamics are known; coherent=False when nothing downstream pins absolute phase
detector the indicator's design point esn0_floor_db/pd/pfa to move it off 4.0 dB / 0.99 / 1e-5

carrier_freq_hz, not center_freq_hz. An earlier draft of this section used the latter and no manifest ever did: dll, async_dsss_receiver and mpsk_receiver all ship carrier_freq_hz. The code was right and this page now follows it.

Three changes remain, and only the first two touch the signature. §8.1's derivations are deliberately independent of all of them — they are dimensionless and read identically whichever units the rates arrive in, which is why they could land first.

parameter what remains
sps, init_norm_freq become sample_rate_hz / symbol_rate_hz / carrier_freq_hz — the units change, §7
bn_carrier derived — the max of the three binding constraints below
bn_timing derived from bn_carrier; the AGC must stay slower than both (§8.1)
lock_thresh derived from (Es/N0, Pd, Pfa) rather than from Pfa alone — §8.1 derives the Pfa half
nda_tap DONE — one tap, the strobe (§3.3, #832)
acq_to_track, warmup_syms DONE — no second mode to gate (#877); warmup_syms went with the Costas arm
agc gone — the AGC is load-bearing (§1.1); its ratio is already derived (§8.1)
differential moves to bits(), defaulting on (§2.1)

bn_carrier is the max of three constraints, each an equation already in §5 rather than a new invention — a loop bandwidth is not a fact a caller has, it is the answer to a requirement they do have:

constraint from rule
seeding pull_in_hz (§3.4) bn ≥ M·\|Δf\| per symbol
acquisition acquire_time_s lock time scales ~1/bn, at 5–10% of 5/bn (§4.1)
ramp headroom doppler_rate_hz_s θ_ss = 2π·r/wn² < π/(2M)wn² > 4M·r (§5.8)

Every derived value stays readablerx.bn_carrier, rx.sps, rx.lock_thresh — so a caller asks what they got rather than having had to supply it. That is the pattern §8.1 established for five parameters, applied to the rest, and it is what keeps "derived" from meaning "hidden".

Three rows this table used to disagree with itself about

docs/design/mpsk.md §12 §4.3 specified the same constructor and derived nda_tap from pull_in_hz, acq_to_track from m (on at M = 8) and differential from coherent. That page has been folded into this one (§10), and where the two disagreed this section is the answer:

  • nda_tap is gone, not derived. One tap (#832); a derivation that can select a tap keeps three taps alive to select from.
  • acq_to_track is gone, not derived. There is no second mode to gate (§2.1), so there is nothing for a derivation to choose. M = 8's ±π/8 margin is a reason to reconsider the discriminator, not to keep a handover.
  • differential moves to bits(). coherent stays as a construction input because it is a fact about the link, but what it selects is the demap default, not a second constructor parameter.

Two readbacks earn their place beyond the parameters themselves once those land — pull_in_hz (§3.4), which tells a caller how accurately they must tune, and the declare latency (§4.1), which tells them how long the indicator takes. Neither ships today.

esn0_floor_db is a design floor, not a measurement

The receiver never learns the true Es/N0. Everything derived from it is a design point, which is why the name says floor.

It does not by itself determine a loop bandwidth. It sets the upper bound, below which loop SNR collapses; the lower bound comes from the dynamics — how fast the carrier actually moves — which the caller has not stated. With no dynamics given the derivation takes the loop-SNR-limited value and reports it. A receiver on a drifting oscillator wants the other end of that range, and stating a maximum drift is the one more number that would pin it.


9. The constellation — labelling, and the one decision rule

Everything above decides when to sample and at what phase. This section is what happens to the sample once it arrives: the map from a byte to a point, the hard decision back, and the labelling convention that makes a near-miss cost one bit instead of several.

It is specified here rather than in a page of its own because it is the same topic and there is one home per topic — but note the scope: mpsk is not part of the receiver, it is the primitive the receiver decides with. The doppler.mpsk module functions (mpsk_map, mpsk_demap, the differential pair) and mpsk_rx_loops' per-symbol slicer are two faces of it, and §10 lists it as reused-as-is for exactly that reason.

9.1 One rule, inlined — not a table lookup

mpsk_slice is the whole decision: one atan2, a lround to the nearest constellation index, and a Gray encode. It is JM_FORCEINLINE in the header rather than a call, because the receiver runs it once per symbol — not per sample — and the header is what every C caller compiles against.

The alternative, an O(M) search for the nearest point by correlation, is what a reader tends to write first. It is not wrong, it is just more work for the same answer: on the unit circle nearest in phase and nearest in Euclidean distance are the same ordering, so the rounding form is exact, not an approximation of the search. That equivalence is the external truth the C test measures the slicer against, and it is worth stating because a second copy of the rule had already appeared in the suite — see §9.5.

9.2 Geometry, and why φ0 is not the same for all M

Unit amplitude throughout; index k sits at exp(j(2πk/M + φ0)):

M φ0 points why that offset
2 0 {+1, −1} real axis — a real-IF receiver's natural alignment
4 π/4 (±1 ± j)/√2 axis-separable: I and Q each carry one bit
8 0 exp(jkπ/4) a point on the real axis, as BPSK has

QPSK's π/4 is the one that earns its exception. With it, the decision reduces to the signs of I and Q independently, and the two bits are carried on separate axes — which is what makes QPSK two independent BPSK channels rather than a 4-ary decision. Without it the constellation would sit on the axes and every decision would couple the components.

9.3 Gray labelling — the byte IS the label

The byte a caller passes is not a constellation index that gets Gray-coded on the way out; it is already the Gray label, and mpsk_constellation decodes it to an index internally. That choice is what lets a caller treat the byte as bits: log2(M) bits, LSB-first, and a slip to an adjacent point flips exactly one of them.

The property that matters is cyclic and holds at the wrap: for every k, popcount(gray(k) ^ gray((k+1) mod M)) == 1. A labelling correct everywhere except across the 0/M−1 seam is the classic near-miss, and it costs a full extra bit error on exactly the transitions a noisy symbol is most likely to make.

At high SNR almost every symbol error is a slip to a neighbour, so this is what makes BER ≈ SER / log2(M) rather than something worse.

9.4 ahat is the decision, and the carrier loop's error signal

mpsk_slice returns the label and writes the unit-amplitude point back through ahat. The second output is not a convenience: Im(y · conj(ahat)) is the decision-directed carrier phase error, and it is only a valid error signal because ahat is on the unit circle regardless of |y|. A decision scaled by the received amplitude would make the loop gain depend on AGC state — which is the same failure the NDA arm's self-normalisation avoids in §3.2, arrived at from the other direction.

9.5 Differential mode — what it buys, and what it costs

Coherent decision needs absolute phase, and an M-PSK carrier loop can only resolve phase modulo 2π/M (§3.5). Differential mode removes that ambiguity by carrying the information on phase differences: mpsk_diff_map accumulates gray_decode(label) into a running index from an implicit zero-phase start, and mpsk_diff_demap recovers each label from the difference between consecutive sliced indices.

The invariance is stronger than "immune to the M-fold ambiguity". Any constant phase offset shifts every sliced index by the same amount, so it cancels in the difference — not only the M discrete rotations. What it does not survive is a phase that moves within the sequence, which is why this resolves an ambiguity and does not replace a carrier loop.

The cost is measured, and ~2x is an asymptote rather than a constant. native/validation/mpsk_diff_penalty.c runs both modes over one shared noise realisation — paired, so the seed's luck cancels out of the ratio — and anchors the coherent path to closed-form theory so that a defect shared by both paths cannot divide out to a plausible 2.0. Measured penalty (SER_diff / SER_coh):

Es/N0 BPSK QPSK 8PSK
4 dB 1.96x 1.85x 1.44x
8 dB 2.00x 1.96x 1.73x
12 dB (starved) 1.99x 1.92x
14 dB (starved) (starved) 2.03x

So the header's ~2x is right where a receiver actually operates, and optimistic in the caller's favour at low SNR — 8PSK at 4 dB pays 1.44x, not 2x. The convergence is slowest for the largest M, which is the sensible direction: the penalty comes from referencing each decision against a noisy previous symbol instead of a clean phase, and that second noisy reference matters least when the decision regions are already wide.

Cells marked starved collected too few errors for the run length to resolve, and are not evidence — the harness marks them rather than printing a number that looks like one.

The first symbol is also not free: it references the implicit zero-phase start, so a constant rotation leaves out[0] wrong and every later symbol right.

9.6 The open items

  • The ~2x differential SER penaltyresolved (§9.5). Measured across M and Es/N0; it is a high-SNR asymptote, not a constant, and is smaller than 2x at low SNR. Gated on one cell by validate_mpsk_diff_penalty --check.
  • A second copy of the decision rule in the test suiteresolved. native/tests/test_carrier_mpsk_core.c carried a private nearest_label() — an O(M) correlation search — instead of calling mpsk_slice, so the carrier-loop test scored against its own slicer rather than the library's, with no gate able to notice a disagreement. It now delegates, and the equivalence it silently assumed is proven in test_mpsk_core.c §5b.
  • mpsk_core is in no libraryresolved, #747. mpsk_core and util_core were the two component cores left out of libdoppler.a, so mpsk_map/mpsk_demap could not be linked by a C caller and could not appear in a C doc snippet, which compiles against that archive. The Python face was unaffected — the extension links the core directly — which is exactly why it went unnoticed. Both are in the archive now (ar t build/libdoppler.a lists mpsk_core.c.o and util_core.c.o; nm shows mpsk_map/mpsk_demap defined), so a C caller and a C doc fence can both reach them.
  • LSB-first bit packing is a claim of the header's, not of this module's code. mpsk never packs bits; it deals in whole labels. The unpacking lives in the receiver's bits() path, and that is where the convention has to be pinned.

9.7 Soft decisions

Shipped as mpsk_soft_demap(x, llr, m, n0). This subsection is folded in from the mpsk.md §9.7 page that designed it — which opened "there is no soft anything in it: five functions, all hard-decision", a sentence the implementation has since made false.

The convention, for each bit i of a symbol's Gray label:

L_i = log( P(b_i = 0 | y) / P(b_i = 1 | y) )

Positive means bit 0, so the hard decision is bit = (L < 0) and agreement with mpsk_demap is a testable identity rather than a hope. Bits are LSB-first, matching how the label byte already packs them (§9.3).

Under AWGN with y = a + n, E[|n|²] = N0 and unit-amplitude points, the shipped form is max-log:

L_i = ( min_{a: b_i(a)=1} |y - a|²  -  min_{a: b_i(a)=0} |y - a|² ) / N0

n0 is a parameter, and has to be for the value to be an LLR rather than a monotone score. At M = 2 this collapses to 4·Re(y)/n0, which is what the header's doctest pins.

Two things are open, and neither is a paragraph that can be written from theory:

  1. What max-log costs at 8PSK, in dB. The prototype measured it in nats of LLR error, which is not a unit anyone sizes a link in. The honest number is an Eb/N0 offset on a decoded BER curve, measurable now that conv and rs ship. Literature says 0.1–0.2 dB for Gray-mapped 8PSK; this repository does not ship literature numbers, so the header claims no figure until it is measured here.
  2. Whether exact-LLR should be selectable. Follows from the first: adding a switch before knowing whether the difference is 0.05 dB or 0.5 dB is designing for a hypothetical.

A soft differential demapper is out of scopempsk_diff_demap decides from a phase difference, and the two symbols' noise is correlated through the shared reference, which is a genuinely different derivation. It becomes an issue if a caller appears, not a paragraph here.

Soft decisions are an OUTPUT, not a telemetry-only view

The receiver already produces soft decisions in two places, and neither is an output a caller can compose with:

  • steps() returns the recovered symbol, which is soft in the sense that matters — unquantized — but it is a constellation point, not an LLR;
  • the sym.i / sym.q probes emit Re(y) / Im(y) per recovered symbol (§4.3), which is the same value on the telemetry plane.

What is missing is the one a decoder consumes. conv_core.h already documents that it takes mpsk_soft_demap's convention (its llr parameter, "positive means bit 0"), so the seam between this receiver and the shipped Viterbi decoder is specified on the decoder's side and unimplemented on the receiver's. Today the bridge is hand-wired: native/validation/rx_coding_gain.c calls mpsk_soft_demap(sym, nsym, llr, nsym, 2, 1.0f) itself — with n0 hard-coded to 1.0 — which is how the ≥6.1 dB coding-gain result is obtained. From Python there is no path at all; mpsk_soft_demap appears only in mpsk's own tests.

So the receiver owes a soft-bit output beside bits() — the same shape, returning float LLRs rather than packed hard bits, delegating to mpsk_soft_demap rather than reimplementing it. That also answers the n0 question this design deferred: the receiver carries the AGC that normalises amplitude (§1.1), so it is the one object in the chain that knows the scale its symbols arrive at, and a caller hand-passing 1.0 is doing so because nothing offered them better.

Until that lands, bits() is the only composable output and it throws away exactly the information an outer code is there to use.

The shipped soft path has no certified envelope

mpsk's validation report is 24 limits over the hard-decision surface and mentions soft zero times, so mpsk_soft_demap is public API that no certification covers. That is a gap in the report, not a claim about the code — the C tests do exercise it. It is the first thing to fix the next time mpsk is re-certified.


10. Component reuse

Everything here is reused, not reimplemented:

Piece Verdict
MatchedDDC / MatchedDdcr the whole front end — mix, decimate, match
RateConverter terminal polyphase stage matched filter and fractional delay, fused
RateConverter pre-terminal tap the fixed-rate domain — AGC, and now the carrier disc
ratesync_loop_t timing loop — RateSync's own, factored out for reuse
carrier_nda_disc NDA math — shared with the standalone CarrierNda
loop_filter PI every loop embeds it by value — as-is
lockdet the hysteretic lock indicator (§4)
detection the Pd/Pfa/verify-count chain (§4.1)
mpsk slicer/demap (mpsk_slice, mpsk_demap, mpsk_diff_demap) decision + bits + differential — as-is (§9)
mpsk_rx_loops_t shared verbatim by both receiver types (§1.2)
Dll(segments) optional DSSS front-end — pipeline, not fused

Retired from this receiver by the cascade rebuild (all still first-class objects in their own right): lo driven directly, boxcar as an NDA arm, SymbolSync's Gardner+Farrow loop, and a dense fir matched filter.


11. The record — resolved and open

  • NDA discriminator formresolved. M-th-power via repeated squaring of the unit-magnitude sample (§3.2), with the lock signal left unscaled so it reads ~1.0 at lock for every M. It used to carry a per-M lock_scale of 1 / 0.619 / 0.412, which made the statistic's ceiling M-dependent and the default threshold unreachable at 8PSK.

  • Arm normalizationresolved, twice. There is no arm AGC and no clip: the discriminator normalizes by its own |z|^M, which removes the constructive-ISI peaks the clip used to bound approximately.

  • bn_carrier normalisationchanged twice. Sample-rate → symbol-rate at the rebuild, and → Hz in §7. The first was a silent break; the second is not, because both rates become required arguments.

  • Real-input supportresolved, shipped. track.MpskReceiverR (§1.2).

  • Which construction parameters are design axesresolved and shipped for five of them (gh-644, §8.1). m_out, zeta, num_phases, lock_thresh and bn_agc_ratio derive when passed 0, and each is reported back by a getter — the readback is the half that makes 0 an instruction rather than a mystery. Additive, because every one of those validators previously rejected 0, so no working call site could depend on it. Two things changed value rather than only gaining a derivation: num_phases 1024 → 64 (the measured saturation point) and lock_thresh 0.5 → 0.4999 (the same number, now derived — see §4.2). bn_carrier, bn_timing and the waveform parameters stay supplied: they are the design axes, which is the line this decision drew.

  • Cold carrier pull-in on the strobe tapresolved by §3.3, superseding the previous answer. The strobe tap made carrier acquisition depend on symbol timing, costing roughly a third of data seeds at m_out = 4. Gating the steer on the timing loop's lockdet was implemented and then removed as a default: across a 24-cell sweep it changed exactly one cell, and what it mainly bought was measurability — with the steer frozen until timing declares, the carrier transient starts at a known instant, which is convenient for instrumenting an acquisition and is not a property of a working receiver. The previous remedy was "choose another tap"; Mode 1's is structural — the carrier loop no longer reads a timed sample at all (#536).

  • The handover's update-period defectclosed by deletion, and Mode 2's to face again if it returns. The carrier loop filter's update period was set once from the acquisition tap's clock and never re-set when the decision-directed discriminator took over at the symbol rate (§2.3). Both the second tap (#832) and the second discriminator (#877) are gone, so there is one clock and no transition at which it could go stale.

  • What the handover was worthmeasured, and the reason it went. On the same record with acq_to_track off and on, it moved ~99% of the recovered symbols by up to 0.37 on a unit-radius constellation, and changed the symbol error rate by a mean factor of 0.9999 (t = 0.28) across the ten cells where it engaged, winning 6 of 10. At the 8PSK anchor in mpsk_receiver_ber.c — the one operating point where it shipped enabled, on the ±π/8 margin argument — it was worth 0.09 dB (0.44 → 0.53 dB of loss) against a settling window it more than doubled. The reasoning for it was sound and the measurement did not support it (#877).

  • An above-ceiling lock statistic as a free diagnosticretired, 2026-07-27, by limiting the lock signal. The idea was that lock > 1 is impossible for a valid constellation, so it detects "discriminator input is garbage" with no new computation. Limiting made the statistic hard-bounded in ±1, so the condition can no longer arise — in exchange for a threshold that maps to a false-alarm probability at every M. A future AGC gain fault has to be caught from the AGC's own gain, not from this statistic.

  • DSSS re-measurementopen. Both DSSS receivers sit downstream of this engine and have not been re-tuned for it. The localisation says the fault is unrecovered carrier phase in the pre-despread Costas loop, which is that chain's own carrier loop, not this receiver's (#535). That localisation is only trustworthy because it was not made from a bit error rate alone: every measurement pairs BER with two truth-free validators — self-referenced EVM and blind M2M4 SNR. Their disagreement carries the diagnosis: a healthy M2M4 beside a collapsed EVM says "the amplitudes are fine, the phase is not", which no error rate could have told us.

  • Mode 2undefined. Whether a decision-directed handover returns, and on what terms, is open. §2.3 records what it must face.


12. The collapse — one object, two faces

Landed. MpskReceiverR was a separate type until #806; it is now a view over this object. This section is the record of that change, folded in from the mpsk.md §12 page that specified it, because a page that has done its job is a section rather than a peer.

12.1 What the split cost, measured

mpsk_rx_loops_t — both loops, the demapper, the telemetry attachment and all five §8.1 derivations — was already one struct in one header, embedded in both twins by value. The twins differed in exactly two places:

  • the front end, ddc_state_t * against ddcr_state_t *;
  • one rate convention — the real face's LO runs at half the input rate, because its R2C halfband decimates 2:1, so ddcr's tuning law norm_freq = -(2·f_c + 0.5) puts a 0.5 in its accessor.

Everything else was duplication, and it was measured rather than asserted: mpsk_receiver_r_core.c was 372 lines and 30 public functions, 16 of them pure delegations. Only create, destroy, reset, the two norm_freq accessors, set_norm_freq, get_clipped and the state triplet genuinely differed — each by a front-end call or a factor of two, never by algorithm.

12.2 Why it could not be done sooner, and what unblocked it

The project's own axis is that a difference in constructor is a flavor and a difference in method signature is a separate type — and steps()/bits() take cf32 against f32. That made the dtype a type difference by rule.

just-makeit#1012, shipped in jm 0.62.0, removed the constraint that forced it: a view method restating a parent's name may now declare its own signature when it binds its own C symbol via fn. The dtype became expressible as a flavor, so it became one:

[[mpsk_receiver.views.methods]]
name   = "steps"
fn     = "mpsk_receiver_steps_real"
params = [{ name = "x", type = "float[]" }]

The core carries union { ddc_state_t *c; ddcr_state_t *r; } fe plus an int real, and the tag is read on cold paths only — create, destroy, reset, telemetry, the frequency accessors and the state triplet. The hot path has two step entry points, each force-inlined onto one shared mpsk_rx_fold(), so the front end is a compile-time fact inside the sample loop and the tag costs nothing there.

Two things worth knowing before the next view of this kind:

  • The state MAGIC is keyed on the face (MPSK/MPSR), so a blob from one is refused by the other at the envelope rather than reinterpreted or caught three levels down in a child. The layouts are otherwise identical, which is why one triplet serves both and neither version needed a bump.
  • A view's own create_error_message does not reach the binding yet. The accessor resolves it (gh-580) but jm apply's view replay does not carry the key into the scratch tree the fragment renders from — filed as just-makeit#1017, worked around by widening the parent's message to state both faces' sps bound.

12.3 The step that cashed it

The collapse permits one test home; it does not create one. Left alone the merged object would simply have inherited whichever twin's test file survived, and the asymmetry would have persisted silently — the refactor delivering everything except the thing that motivated it. test_mpsk_receiver_r_core.c was therefore folded into test_mpsk_receiver_core.c (§15–23) rather than deleted, and every shared claim now has one owner:

shared claim before now
set_telemetry complex only §22 — the real front end's AGC forward
level invariance complex (real: 1) §11 + §21
the AGC is slower than every loop complex only §9 + §21, both front ends
"the LO runs at half the input rate" neither §23, two halves, both sabotaged

The first three were migrations and carried no new risk. The fourth is the one that mattered: nothing had ever asserted it, and it is where the gh-765 freq_scale bug lived (fixed in #772 with no guard left behind). §23 pins it in two halves because lo_sps enters in two places, each with its own sabotage:

half stimulus gate sabotage measured
loop GAIN ramp θ_ss = 2πr/wn², both faces, ±8% lo_sps = sps on the real face lag 2.00× the law at both ramp rates
READBACK step norm_freq within 20% of df mpsk_rx_lo_to_input() → 1.0 off by exactly df, 5× the tolerance

Each sabotage leaves the other half green, so the two are independent claims rather than one asserted twice. The stimulus for the first has to be a ramp: the bug survived every step test in the tree because a type-2 loop nulls a frequency step regardless of gain (§5.8).

One measurement lesson came out of building it. The estimator is the signed mean of the discriminator output, then |·| — not the mean of |e|. Under a ramp the lag is a constant offset the loop is holding, so the signed mean estimates it and the loop's own jitter averages out; mean|e| carries a positive bias that grows as the lag approaches the jitter, and at r = 3e-7 on the real face that bias alone read 44% high and failed a correct receiver.

And §2's claim — the loops behave identically regardless of front end — became writable for the first time here, because one object with two faces can run the same vector through both and compare.

12.4 What the collapse decided

  • strobe is the tap. Measured on the continuous flavor's own waveform — NRZ, modulation off then dense, under a coupled Doppler ramp: lock 0.935 quiet, 0.860 at the data onset, 0.920 at the end, against mf_out's 0.478 and mf_in's 0.417 at the onset. An unmodulated NRZ carrier is sampling-phase invariant, so the strobe tap's timing dependency costs nothing exactly where timing is impossible.
  • mf_in's cost is a stated price, and the arm filter is declined. Its node carries 10·log10(bank_sps) dB of excess noise bandwidth — measured 6.01 dB at bank_sps = 4, identical at 6.79, 12 and 20 dB Es/N0, which is a pure bandwidth ratio rather than an SNR-dependent effect. Recovering it costs serialized state on every object carrying the tap, and strobe reads the node already matched to the signal for free.
  • The TED is not cosmetic. On a rectangular pulse, Gardner deepens the data-onset lock dip from 0.075 to 0.306 — four times, from the detector choice alone. Use DTTL with I&D.