File mpsk_rx_loops.h¶
FileList > inc > mpsk_receiver > mpsk_rx_loops.h
Go to the source code of this file
The two loops an M-PSK receiver closes, independent of its front end. More...
#include "agc/agc_core.h"#include "viterbi/viterbi_core.h"#include "carrier_nda/carrier_nda_core.h"#include "clib_common.h"#include "dp_state.h"#include "jm_perf.h"#include "lockdet/lockdet_core.h"#include "loop_filter/loop_filter_core.h"#include "mpsk/mpsk_core.h"#include "ratesync/ratesync_core.h"#include "dp_tlm/dp_tlm_core.h"#include <complex.h>#include <math.h>
Classes¶
| Type | Name |
|---|---|
| struct | mpsk_rx_loops_t The receiver's loops: timing, carrier, demapper. |
| struct | mpsk_rx_tlm_t Telemetry attachment for the receiver's own two probes; the timing and carrier probes ride their own sub-attachments. |
Public Types¶
| Type | Name |
|---|---|
| enum | mpsk__rx__loops_8h_1a99fb83031ce9923c84392b4e92f956b5 Matched-filter pulse shape. Aliases of the cascade's own vocabulary so one set of names covers the whole family. |
Public Functions¶
| Type | Name |
|---|---|
| JM_FORCEINLINE double | mpsk_rx_agc_bn (double bn_carrier, double bn_timing, double ratio) |
| void | mpsk_rx_config_carrier (mpsk_rx_loops_t * l) (Re-)size the carrier loop filter for the tap's update rate. |
| JM_FORCEINLINE size_t | mpsk_rx_derive_m_out (double cap, int strict) Terminal outputs per symbol, derived: the largest even count in 2..8 the caller's own rate constraint allows. |
| JM_FORCEINLINE JM_HOT void | mpsk_rx_disc (mpsk_rx_loops_t * l, float _Complex z) Run the NDA discriminator on one tapped sample. |
| JM_FORCEINLINE JM_HOT int | mpsk_rx_fold (mpsk_rx_loops_t * l, const float _Complex * ys, size_t n, float _Complex * y_out, int ted) Fold one front end's burst of outputs into both loops. |
| double | mpsk_rx_freq_est (const mpsk_rx_loops_t * l) Tracked carrier offset in cycles/sample at the LO's rate — the loop's own estimate, excluding the front end's configured centre. |
| void | mpsk_rx_loops_get_state (const mpsk_rx_loops_t * l, void * blob) Serialize the loops' mutable state into blob . |
| void | mpsk_rx_loops_init (mpsk_rx_loops_t * l, int m, double sps, double lo_sps, size_t m_out, double bn_carrier, double zeta, double bn_timing, double bn_agc_ratio, int ted, double lock_thresh, int differential) Initialise the loops in place (no allocation). |
| void | mpsk_rx_loops_reset (mpsk_rx_loops_t * l) Re-seed both loops to their post-init state; keep configuration. |
| int | mpsk_rx_loops_set_state (mpsk_rx_loops_t * l, const void * blob) Restore the loops' mutable state from blob . |
| size_t | mpsk_rx_loops_state_bytes (const mpsk_rx_loops_t * l) Bytes mpsk_rx_loops_get_state() writes. |
| void | mpsk_rx_set_freq_est (mpsk_rx_loops_t * l, double val) Overwrite the tracked carrier offset (cycles/sample at the LO's rate) so the next output de-rotates by exactly val . |
| int | mpsk_rx_set_telemetry (mpsk_rx_loops_t * l, dp_tlm_t * tlm, const char * prefix, uint32_t decim) Attach (or detach) telemetry across both loops; see mpsk_receiver_set_telemetry() , which forwards here. |
| JM_FORCEINLINE JM_HOT void | mpsk_rx_steer (mpsk_rx_loops_t * l, double pe) Filter a carrier phase error and update freq_ctrl . |
| int | mpsk_rx_symbol_to_bits (mpsk_rx_loops_t * l, float _Complex y, uint8_t * bits) Slice one recovered symbol to its log2(M) hard bits (LSB-first). |
| JM_FORCEINLINE JM_HOT int | mpsk_rx_take_output (mpsk_rx_loops_t * l, float _Complex y, float _Complex * sym, int ted) Fold one terminal-stage output into both loops. |
| void | mpsk_rx_tlm_flush (const mpsk_rx_loops_t * l, float _Complex y) Emit the receiver's own probes plus the timing loop's. Out-of-line on purpose; callers gate on l->tlm.ctx . |
| JM_FORCEINLINE double | mpsk_rx_updates_per_symbol (const mpsk_rx_loops_t * l) How many times per symbol the chosen tap updates the carrier loop. |
Macros¶
| Type | Name |
|---|---|
| define | MPSK_RX_AGC_ALPHA 0.01 |
| define | MPSK_RX_AGC_BW_RATIO 0.05 |
| define | MPSK_RX_AGC_RATIO_DEFAULT [**MPSK\_RX\_AGC\_BW\_RATIO**](mpsk__rx__loops_8h.md#define-mpsk_rx_agc_bw_ratio)AGC bandwidth ratio, derived: 20x slower than the slowest loop it feeds. The RATIO is the part that is not negotiable (see the block above); the value is MPSK_RX_AGC_BW_RATIO , and it is a parameter only because the right separation depends on how fast the channel's LEVEL moves against its phase and timing. Zero asks for the default rather than being rejected. |
| define | MPSK_RX_EPS 1e-12 |
| define | MPSK_RX_LOCK_DOWN 0.8 |
| define | MPSK_RX_LOCK_N_DOWN 32u |
| define | MPSK_RX_LOCK_N_UP 8u |
| define | MPSK_RX_LOCK_THRESH_DEFAULT 0.4999Lock threshold, derived: sigma_H0 * eta(Pfa) atPfa = 5e-6 . |
| define | MPSK_RX_LOOPS_STATE_MAGIC [**DP\_FOURCC**](dp__state_8h.md#define-dp_fourcc) ('M', 'R', 'X', 'L') |
| define | MPSK_RX_LOOPS_STATE_VERSION 7u |
| define | MPSK_RX_M_OUT_DEFAULT 8 |
| define | MPSK_RX_NUM_PHASES 1024u |
| define | MPSK_RX_NUM_PHASES_DEFAULT 64uMatched-filter bank arms, derived: the measured saturation point. |
| define | MPSK_RX_ZETA_DEFAULT 0.70710678118654752Loop damping, derived: 1/sqrt(2) , critically damped. |
Detailed Description¶
Everything a receiver does after its down-converter emits a terminal-stage output: the symbol-timing loop, the carrier loop, and the slicer/demapper state. It owns no filter, no NCO and no cascade — it consumes matched-filtered outputs and produces the two control values that steer whatever produced them.
front end (Ddc / Ddcr) mpsk_rx_loops_t
───────────────────── ───────────────
LO mix ─ cascade ─ matched filter ──y──> carrier disc ──> freq_ctrl ─┐
^ ^ timing TED ──> rate_ctrl ─┤
└────────────────────┴──────────────────────────────────────────── ┘
That split is the whole reason two receiver types cost barely more than one. MpskReceiver drives a Ddc (complex input) and MpskReceiverR drives a Ddcr (real input, halfband R2C front end); both call the same mpsk_rx_take_output() on every output their front end emits, so the loops are one implementation rather than two peers that can drift apart. The timing half is literally RateSync's — ratesync_loop_t, embedded here — so a fix to the TED or its normaliser reaches RateSync and both receivers at once.
One discriminator, on the symbol strobe¶
The carrier loop runs the M-th-power NDA error (carrier_nda_disc) on the on-time strobe, from the first symbol to the last. Only that sample is a constellation point — the other terminal outputs fall between symbols, where the matched filter is averaging two of them, so their M-th power carries ISI rather than carrier phase.
This costs less than it appears to. The strobe fires every m_out-th output whatever the timing loop currently believes, so the NDA loop still pulls in before timing lock — it just does so on one consistent phase of the pulse instead of all of them.
There is no second, decision-directed discriminator to hand over to. One sat behind an opt-in flag until doppler#877, and measurement is why it is gone: across the ten paired cells where it engaged at all it moved 99% of the recovered symbols — by up to 0.37 on a unit-radius constellation — while changing the symbol error rate by a mean factor of 0.9999, t = 0.28. That is scatter, not a gain. A live branch that buys nothing measurable can only be a source of divergence between two receivers that should agree, so NDA is what this receiver does rather than what it does first (docs/design/mpsk.md §3.3).
Units¶
bn_carrier and the timing loop's bn are both normalised to the symbol rate, so one setting means the same thing at every input rate — the same argument RateSync makes for referencing its control to the terminal stage. The discriminators produce a phase error in radians; freq_ctrl must be cycles per sample at the LO's own rate, which is the input rate for a complex front end and half it for a real one (the halfband decimates before the LO). Each receiver reports its own lo_sps for that reason, and the conversion lives in freq_scale.
Public Types Documentation¶
enum mpsk__rx__loops_8h_1a99fb83031ce9923c84392b4e92f956b5¶
Matched-filter pulse shape. Aliases of the cascade's own vocabulary so one set of names covers the whole family.
enum mpsk__rx__loops_8h_1a99fb83031ce9923c84392b4e92f956b5 {
MPSK_RX_PULSE_IANDD = RC_PULSE_IANDD,
MPSK_RX_PULSE_RRC = RC_PULSE_RRC
};
Public Functions Documentation¶
function mpsk_rx_agc_bn¶
function mpsk_rx_config_carrier¶
(Re-)size the carrier loop filter for the tap's update rate.
Called by mpsk_rx_loops_init(), and AGAIN by each receiver's create() once the cascade has published its bank_sps — which arrives too late for init, so the MF_IN tap would otherwise keep gains designed for the lo_sps placeholder. integ survives loop_filter_init() by contract, and every other tap re-derives the gains it already had.
function mpsk_rx_derive_m_out¶
Terminal outputs per symbol, derived: the largest even count in 2..8 the caller's own rate constraint allows.
Even by construction (the Gardner detector needs an on-time strobe and a transition gate m_out/2 back), capped at 8 because that is where an I&D matched filter reaches the coherent bound — past it the extra outputs buy nothing. The floor matters more than the cap: at low oversampling the shipped constant 8 is simply not available, and m_out = 2 with pulse="iandd" degenerates the matched filter to a two-tap sum that barely opens the eye (measured lock statistic −0.34, acquisition failing about half the time). Deriving it is what stops a caller pairing a rate and an m_out that cannot work together.
It is parameterised by the CONSTRAINT, not by the rate, because the two twins do not share one. The complex path requires sps >= m_out; the real path requires sps > 2*m_out, strictly, because Ddcr needs a decimation ratio below 0.5. design/mpsk.md §8 states the real rule as min(8, 2*floor(sps/4)) and that rule contradicts the constructor it feeds: at sps = 8 it yields 4 (needs 8 > 8) and at sps = 16 it yields 8 (needs 16 > 16) — both REJECTED by mpsk_receiver_create_real(). A derivation whose answer cannot be built is worse than a default, so the bound is passed in and honoured here.
Parameters:
capUpper bound onm_outfrom the caller's constraint:spsfor the complex twin,sps/2for the real one.strictNon-zero when the bound is strict (m_out < cap) rather than inclusive (m_out <= cap) — the real twin's case.
Returns:
An even count in 2..8, or 0 when the bound cannot carry even 2 — a refusal, not a clamp, because a receiver that cannot hand the detector two outputs per symbol has nothing to detect with.
function mpsk_rx_disc¶
Run the NDA discriminator on one tapped sample.
Shared by all three tap points so the discriminator, its AGC and its lock statistic exist exactly once however the caller chose to feed them.
The discriminator, its lock EMA and the steer all run on every sample it is handed, from the first strobe to the last. Nothing gates them: lock is an indicator the caller reads, not an input the loop obeys.
Parameters:
lLoops.zThe tapped sample.
function mpsk_rx_fold¶
Fold one front end's burst of outputs into both loops.
JM_FORCEINLINE JM_HOT int mpsk_rx_fold (
mpsk_rx_loops_t * l,
const float _Complex * ys,
size_t n,
float _Complex * y_out,
int ted
)
The whole per-sample body below the front end, and the reason the receiver is one object with two step entry points rather than two types: a complex DDC and a real DDCR differ in what they hand over, and in nothing they hand it to. Both entry points reduce to this call, so "the loops
behave identically regardless of front end" is a property of one function rather than a claim about two copies of one.
Parameters:
lLoops. Must be non-NULL.ysThe terminal-stage outputs the front end just produced.nHow many.y_outReceives the recovered symbol when the return is 1.tedRATESYNC_TED_GARDNER or RATESYNC_TED_DTTL — pass a literal for a specialised (branch-free) instantiation.
Returns:
1 if a symbol was emitted (into y_out), 0 otherwise.
function mpsk_rx_freq_est¶
Tracked carrier offset in cycles/sample at the LO's rate — the loop's own estimate, excluding the front end's configured centre.
function mpsk_rx_loops_get_state¶
Serialize the loops' mutable state into blob .
function mpsk_rx_loops_init¶
Initialise the loops in place (no allocation).
void mpsk_rx_loops_init (
mpsk_rx_loops_t * l,
int m,
double sps,
double lo_sps,
size_t m_out,
double bn_carrier,
double zeta,
double bn_timing,
double bn_agc_ratio,
int ted,
double lock_thresh,
int differential
)
Parameters:
lLoops to initialise. Must be non-NULL.mConstellation order M (2, 4, 8).spsSamples per symbol at the receiver's input.lo_spsSamples per symbol at the LO's own rate:spsfor a complex front end,sps/2for a real one, whose halfband decimates before the LO.m_outTerminal outputs per symbol (even, >= 2).bn_carrierCarrier loop noise bandwidth, per symbol.zetaDamping factor for both loops.bn_timingTiming loop noise bandwidth, per symbol.tedRATESYNC_TED_GARDNER or RATESYNC_TED_DTTL.lock_threshDeclare threshold for the carrier lock indicator, on the lock EMA; the drop threshold sits at MPSK_RX_LOCK_DOWN x it, and both directions are verify-counted. The EMA's H0 sd is CARRIER_NDA_LOCK_NORM_SD (0.1132) for every M, so this divided by that is the threshold in noise sigmas and its per-look Pfa is Q(that) — 0.5 is 4.42 sigma, Pfa 5e-6. See carrier_nda_core.h.differentialbits(): differential (rotation-invariant) demap.bn_agc_ratioScales the front end's AGC off the SLOWEST of the two loop bandwidths; must be in (0, 1). See mpsk_rx_agc_bn().
function mpsk_rx_loops_reset¶
Re-seed both loops to their post-init state; keep configuration.
Parameters:
lMust be non-NULL.
function mpsk_rx_loops_set_state¶
Restore the loops' mutable state from blob .
Returns:
DP_OK, or DP_ERR_INVALID if any envelope rejects.
function mpsk_rx_loops_state_bytes¶
Bytes mpsk_rx_loops_get_state() writes.
function mpsk_rx_set_freq_est¶
Overwrite the tracked carrier offset (cycles/sample at the LO's rate) so the next output de-rotates by exactly val .
function mpsk_rx_set_telemetry¶
Attach (or detach) telemetry across both loops; see mpsk_receiver_set_telemetry() , which forwards here.
int mpsk_rx_set_telemetry (
mpsk_rx_loops_t * l,
dp_tlm_t * tlm,
const char * prefix,
uint32_t decim
)
function mpsk_rx_steer¶
Filter a carrier phase error and update freq_ctrl .
The negation is load-bearing. A DDC mixes with its LO directly (x * lo_step_ctrl(...)), where carrier_nda's older loop mixed with the conjugate (x * conjf(lo_step_ctrl(...))), so the same physical de-rotation is the opposite sign on this port. Without it the loop is positive feedback and the M-th-power S-curve's stable and unstable equilibria swap: the receiver locks hard onto the half-way grid, timing and symbol count look perfect, and the only tell is the carrier lock metric sitting at a steady negative value (-0.48 for QPSK, where +0.62 is a real lock) while every symbol lands on a decision boundary.
function mpsk_rx_symbol_to_bits¶
Slice one recovered symbol to its log2(M) hard bits (LSB-first).
Returns:
The bit count written to bits.
function mpsk_rx_take_output¶
Fold one terminal-stage output into both loops.
JM_FORCEINLINE JM_HOT int mpsk_rx_take_output (
mpsk_rx_loops_t * l,
float _Complex y,
float _Complex * sym,
int ted
)
The receiver's whole per-output body, shared verbatim by the complex- and real-input types. On an on-time strobe it writes the recovered symbol and returns 1.
Parameters:
lLoops. Must be non-NULL.yOne matched-filtered output from the front end.symReceives the recovered symbol when the return is 1.tedRATESYNC_TED_GARDNER or RATESYNC_TED_DTTL — pass a literal for a specialised (branch-free) instantiation.
Returns:
1 if this output was an on-time strobe, 0 otherwise.
function mpsk_rx_tlm_flush¶
Emit the receiver's own probes plus the timing loop's. Out-of-line on purpose; callers gate on l->tlm.ctx .
function mpsk_rx_updates_per_symbol¶
How many times per symbol the chosen tap updates the carrier loop.
The tap's whole point, as one number: it is both the factor by which the discriminator's unambiguous frequency range grows over the symbol-rate case, and the loop filter's update rate (see config_carrier).
Macro Definition Documentation¶
define MPSK_RX_AGC_ALPHA¶
define MPSK_RX_AGC_BW_RATIO¶
define MPSK_RX_AGC_RATIO_DEFAULT¶
AGC bandwidth ratio, derived: 20x slower than the slowest loop it feeds. The RATIO is the part that is not negotiable (see the block above); the value is MPSK_RX_AGC_BW_RATIO , and it is a parameter only because the right separation depends on how fast the channel's LEVEL moves against its phase and timing. Zero asks for the default rather than being rejected.
define MPSK_RX_EPS¶
define MPSK_RX_LOCK_DOWN¶
define MPSK_RX_LOCK_N_DOWN¶
define MPSK_RX_LOCK_N_UP¶
define MPSK_RX_LOCK_THRESH_DEFAULT¶
Lock threshold, derived: sigma_H0 * eta(Pfa) atPfa = 5e-6 .
0.1132 * 4.4159 = 0.4999, which is the 0.5 that shipped — so this row changes no behaviour and is here because a number that was picked and a number that was derived look identical until one of them has to move. The limited statistic reads ~1.0 at lock for EVERY M (§4), so no per-M correction is carried.
It is sized against H0 alone, and that bounds where it means anything¶
sigma_H0 * eta(Pfa) is a FALSE-ALARM threshold: it answers "how high must
the statistic be before noise alone rarely reaches it". The other half of a detector's sizing — how often the statistic clears it when the receiver IS locked — depends on Es/N0, and "reads ~1.0 at lock" carried no Es/N0 with it until this block. Measured over the scored window, at the geometry the standard battery uses (docs/design/rx-test.md; the duty cycles are in the standard record, dp_rx_result_t::lock_duty):
| Es/N0 (BPSK) | locked duty |
statistic > 0 | |
|---|---|---|---|
| 6.79 dB | SER = 1e-3 | 100 % | 100 % |
| +1 dB | 69 % | 100 % | |
| 0 dB | 24 % | 100 % | |
| −3 dB | 0.2 % | 95 % |
At 0 dB the loops are tracking — the statistic is positive throughout, and a concatenated link over that same record delivers error-free frames (docs/design/fec-receive.md §8). What refuses is the threshold.
So this default is an UNCODED-link indicator. A caller running below its own SER = 1e-3 anchor — which is where forward error correction exists to put you — must not gate on mpsk_receiver_get_locked(). Pass a threshold sized for the link, or gate on something that works there: frame synchronization, or the node-sync statistic (node_sync_score), which in lock reads the channel symbol error rate directly.
doppler#835 carries the measurement and the options; nothing here has changed behaviour, because a threshold that moves silently is worse than one whose scope is written down.
define MPSK_RX_LOOPS_STATE_MAGIC¶
define MPSK_RX_LOOPS_STATE_VERSION¶
define MPSK_RX_M_OUT_DEFAULT¶
define MPSK_RX_NUM_PHASES¶
define MPSK_RX_NUM_PHASES_DEFAULT¶
Matched-filter bank arms, derived: the measured saturation point.
64 against a shipped 1024 — a 16x bank for no measurable gain. The arms set the fractional-timing resolution to 1/N of an output period, and the measurement (design/mpsk.md §8) finds it saturating at 64 on RRC and inert at every value on I&D.
define MPSK_RX_ZETA_DEFAULT¶
Loop damping, derived: 1/sqrt(2) , critically damped.
A constant, not a computation — nothing in this receiver moves the optimal damping, and both loops already share one value. It is a parameter only because it was once thought to be one.
The documentation for this class was generated from the following file native/inc/mpsk_receiver/mpsk_rx_loops.h