Skip to content

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.4999
Lock 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 64u
Matched-filter bank arms, derived: the measured saturation point.
define MPSK_RX_ZETA_DEFAULT 0.70710678118654752
Loop 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

JM_FORCEINLINE double mpsk_rx_agc_bn (
    double bn_carrier,
    double bn_timing,
    double ratio
) 

function mpsk_rx_config_carrier

(Re-)size the carrier loop filter for the tap's update rate.

void mpsk_rx_config_carrier (
    mpsk_rx_loops_t * l
) 

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.

JM_FORCEINLINE size_t mpsk_rx_derive_m_out (
    double cap,
    int strict
) 

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:

  • cap Upper bound on m_out from the caller's constraint: sps for the complex twin, sps/2 for the real one.
  • strict Non-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.

JM_FORCEINLINE  JM_HOT void mpsk_rx_disc (
    mpsk_rx_loops_t * l,
    float _Complex z
) 

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:

  • l Loops.
  • z The 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:

  • l Loops. Must be non-NULL.
  • ys The terminal-stage outputs the front end just produced.
  • n How many.
  • y_out Receives the recovered symbol when the return is 1.
  • ted RATESYNC_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.

double mpsk_rx_freq_est (
    const mpsk_rx_loops_t * l
) 


function mpsk_rx_loops_get_state

Serialize the loops' mutable state into blob .

void mpsk_rx_loops_get_state (
    const mpsk_rx_loops_t * l,
    void * 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:

  • l Loops to initialise. Must be non-NULL.
  • m Constellation order M (2, 4, 8).
  • sps Samples per symbol at the receiver's input.
  • lo_sps Samples per symbol at the LO's own rate: sps for a complex front end, sps/2 for a real one, whose halfband decimates before the LO.
  • m_out Terminal outputs per symbol (even, >= 2).
  • bn_carrier Carrier loop noise bandwidth, per symbol.
  • zeta Damping factor for both loops.
  • bn_timing Timing loop noise bandwidth, per symbol.
  • ted RATESYNC_TED_GARDNER or RATESYNC_TED_DTTL.
  • lock_thresh Declare 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.
  • differential bits(): differential (rotation-invariant) demap.
  • bn_agc_ratio Scales 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.

void mpsk_rx_loops_reset (
    mpsk_rx_loops_t * l
) 

Parameters:

  • l Must be non-NULL.

function mpsk_rx_loops_set_state

Restore the loops' mutable state from blob .

int mpsk_rx_loops_set_state (
    mpsk_rx_loops_t * l,
    const void * 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.

size_t mpsk_rx_loops_state_bytes (
    const mpsk_rx_loops_t * l
) 


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 .

void mpsk_rx_set_freq_est (
    mpsk_rx_loops_t * l,
    double 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 .

JM_FORCEINLINE  JM_HOT void mpsk_rx_steer (
    mpsk_rx_loops_t * l,
    double pe
) 

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).

int mpsk_rx_symbol_to_bits (
    mpsk_rx_loops_t * l,
    float _Complex y,
    uint8_t * bits
) 

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:

  • l Loops. Must be non-NULL.
  • y One matched-filtered output from the front end.
  • sym Receives the recovered symbol when the return is 1.
  • ted RATESYNC_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 .

void mpsk_rx_tlm_flush (
    const mpsk_rx_loops_t * l,
    float _Complex y
) 


function mpsk_rx_updates_per_symbol

How many times per symbol the chosen tap updates the carrier loop.

JM_FORCEINLINE double mpsk_rx_updates_per_symbol (
    const mpsk_rx_loops_t * l
) 

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_ALPHA `0.01`

define MPSK_RX_AGC_BW_RATIO

#define MPSK_RX_AGC_BW_RATIO `0.05`

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_AGC_RATIO_DEFAULT `MPSK_RX_AGC_BW_RATIO`


define MPSK_RX_EPS

#define MPSK_RX_EPS `1e-12`

define MPSK_RX_LOCK_DOWN

#define MPSK_RX_LOCK_DOWN `0.8`

define MPSK_RX_LOCK_N_DOWN

#define MPSK_RX_LOCK_N_DOWN `32u`

define MPSK_RX_LOCK_N_UP

#define MPSK_RX_LOCK_N_UP `8u`

define MPSK_RX_LOCK_THRESH_DEFAULT

Lock threshold, derived: sigma_H0 * eta(Pfa) atPfa = 5e-6 .

#define MPSK_RX_LOCK_THRESH_DEFAULT `0.4999`

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_MAGIC `DP_FOURCC ('M', 'R', 'X', 'L')`

define MPSK_RX_LOOPS_STATE_VERSION

#define MPSK_RX_LOOPS_STATE_VERSION `7u`

define MPSK_RX_M_OUT_DEFAULT

#define MPSK_RX_M_OUT_DEFAULT `8`

define MPSK_RX_NUM_PHASES

#define MPSK_RX_NUM_PHASES `1024u`

define MPSK_RX_NUM_PHASES_DEFAULT

Matched-filter bank arms, derived: the measured saturation point.

#define MPSK_RX_NUM_PHASES_DEFAULT `64u`

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.

#define MPSK_RX_ZETA_DEFAULT `0.70710678118654752`

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