Skip to content

File costas_core.h

FileList > costas > costas_core.h

Go to the source code of this file

Costas carrier-tracking loop (integer-NCO de-rotation + PI loop). More...

  • #include "clib_common.h"
  • #include "dp_state.h"
  • #include "jm_perf.h"
  • #include "lo/lo_core.h"
  • #include "lockdet/lockdet_core.h"
  • #include "loop_filter/loop_filter_core.h"
  • #include "dp_tlm/dp_tlm_core.h"
  • #include <math.h>
  • #include "telemetry/telemetry_core.h"

Classes

Type Name
struct costas_state_t
Costas loop state.
struct costas_tlm_t
Telemetry attachment: a borrowed context + this object's probe ids. NULL ctx (the default) means detached — every probe site is then a single predicted-not-taken branch per symbol. Zeroed in state blobs and preserved across set_state (DP_DEFINE_POD_STATE_TLM).

Public Functions

Type Name
void costas_configure (costas_state_t * state, double bn, double zeta)
Recompute the loop-filter gains for a new ( bn ,zeta ) without disturbing the frequency/phase estimate.
void costas_configure_lock (costas_state_t * state, double up_thresh, double down_thresh, uint32_t n_up, uint32_t n_down)
Re-tune the carrier lock detector's thresholds and verify counts.
costas_state_t * costas_create (double bn, double zeta, double init_norm_freq, size_t tsamps, double bn_fll)
Create a Costas instance.
void costas_destroy (costas_state_t * state)
Destroy a Costas instance and release all memory.
double costas_get_bn (const costas_state_t * state)
double costas_get_bn_fll (const costas_state_t * state)
double costas_get_last_error (const costas_state_t * state)
double costas_get_lock_metric (const costas_state_t * state)
int costas_get_locked (const costas_state_t * state)
Current carrier lock decision (1 = locked, 0 = not), from the verify-counted detector on the lock-metric EMA (see costas_configure_lock).
double costas_get_nco_freq (const costas_state_t * state)
Effective NCO frequency command (loop-filter output = integrator + proportional), cycles/sample. Mean rides a ramp with no lag, unlike the integrator-only get_norm_freq.
double costas_get_norm_freq (const costas_state_t * state)
void costas_get_state (const costas_state_t * state, void * blob)
Serialize the full loop state into blob .
void costas_init (costas_state_t * s, double bn, double zeta, double init_norm_freq, size_t tsamps, double bn_fll)
Initialise a Costas loop in place (no allocation).
void costas_reset (costas_state_t * state)
Re-seed the loop to its create-time frequency/phase; keep config.
void costas_set_bn (costas_state_t * state, double val)
void costas_set_bn_fll (costas_state_t * state, double val)
void costas_set_norm_freq (costas_state_t * state, double val)
int costas_set_state (costas_state_t * state, const void * blob)
Restore state; DP_OK, or DP_ERR_INVALID if the envelope rejects.
int costas_set_telemetry (costas_state_t * state, dp_tlm_t * tlm, const char * prefix, uint32_t decim)
Attach (or detach) a telemetry context and register the carrier loop's probes on it. Registers four probes, emitted once per dumped symbol and further thinned by decim: "<prefix>.lock" (the |Re P|/|P| lock-metric EMA, 1 = phase-locked), "<prefix>.e" (the PLL discriminator output — the loop stress), "<prefix>.freq" (the tracked NCO frequency, cycles/sample) and "<prefix>.locked" (the verify-counted lock decision, 0/1 — see costas_configure_lock). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h ).
size_t costas_state_bytes (const costas_state_t * state)
Serialized-state byte size.
size_t costas_steps (costas_state_t * state, const float _Complex * x, size_t x_len, float _Complex * out, size_t max_out)
De-rotate a cf32 block with the carrier NCO, integrate-and-dump each symbol, and emit one decision-directed Costas prompt per symbol.
size_t costas_steps_max_out (costas_state_t * state)
void costas_tlm_flush (const costas_state_t * s)
Emit the carrier loop's telemetry records for the symbol just dumped.
JM_FORCEINLINE JM_HOT void costas_update (costas_state_t * s, float _Complex P)
Per-symbol carrier update: discriminator -> loop filter -> steer NCO.
JM_FORCEINLINE JM_HOT float _Complex costas_wipeoff (costas_state_t * s, float _Complex x)
Per-sample carrier wipe-off: de-rotate x by the NCO, advance it.

Macros

Type Name
define COSTAS_EPS 1e-12f
define COSTAS_LOCK_ALPHA 0.1
define COSTAS_STATE_MAGIC [**DP\_FOURCC**](dp__state_8h.md#define-dp_fourcc)('C', 'S', 'T', 'S')
define COSTAS_STATE_VERSION 3u /\* v3: lockdet decision rule \*/

Detailed Description

A continuous BPSK carrier-recovery loop: per sample it de-rotates the input with the integer-phase lo NCO (carrier wipe-off); every tsamps samples it dumps the coherent integrate-and-dump accumulator, runs a decision-directed Costas phase discriminator, filters the error through an embedded 2nd-order loop_filter_state_t, and steers the NCO frequency + phase. It tracks a small residual carrier offset (the bulk Doppler is removed upstream by FFT acquisition); the steering NCO is lo, so the phase is bounded and exactly reproducible (no double-accumulator drift).

The block API (costas_steps) is the Python face; the JM_FORCEINLINE costas_wipeoff()/costas_update() are the C composition API a despreader / tracking channel inlines into its own sample loop.

Lifecycle: costas_create -> (steps / configure / reset)* -> costas_destroy, or embed by value with costas_init().

Set bn_fll > 0 to enable FLL assist (a wide-pull-in frequency-lock loop aiding the PLL) for large or fast-moving residuals; bn_fll = 0 is a pure Costas PLL.

costas_state_t *c = costas_create(0.05, 0.707, 0.01, 64, 0.0);
float _Complex sym[16];
size_t k = costas_steps(c, rx, rx_len, sym, 16);  // one prompt per symbol
double f = c->nco.norm_freq;                       // tracked residual
costas_destroy(c);

Public Functions Documentation

function costas_configure

Recompute the loop-filter gains for a new ( bn ,zeta ) without disturbing the frequency/phase estimate.

void costas_configure (
    costas_state_t * state,
    double bn,
    double zeta
) 

Re-derives the PI coefficients from the loop bandwidth and damping and installs them live. The NCO frequency, phase and loop integrator are left untouched, so a converged loop keeps tracking straight through the re-tune — narrow the bandwidth once pulled in for lower phase jitter, or widen it to chase a faster-moving residual.

Parameters:

  • state Costas state. Must be non-NULL.
  • bn Loop noise bandwidth, normalised to the symbol rate.
  • zeta Damping factor (0.707 = critically damped).
    >>> from doppler.track import Costas
    >>> c = Costas(bn=0.05, zeta=0.707, init_norm_freq=0.01, tsamps=16)
    >>> c.configure(0.02, 1.0)              # narrow the loop, over-damp
    >>> (round(c.bn, 3), round(c.norm_freq, 3))  # new gains, est kept
    (0.02, 0.01)
    

function costas_configure_lock

Re-tune the carrier lock detector's thresholds and verify counts.

void costas_configure_lock (
    costas_state_t * state,
    double up_thresh,
    double down_thresh,
    uint32_t n_up,
    uint32_t n_down
) 

The always-on lock decision steps a verify-counted detector (lockdet_core.h) on the |Re P|/|P| lock-metric EMA once per dumped symbol: locked flips up after n_up consecutive symbols with the metric above up_thresh and drops after n_down consecutive symbols below down_thresh. The defaults derive from the metric's own H0 statistics — with no carrier, |Re P|/|P| = |cos(theta)| for a uniform theta, whose mean is 2/pi (~0.637) and per-symbol std ~0.31; the COSTAS_LOCK_ALPHA = 0.1 EMA reduces that to ~0.071, so the default declare threshold 0.85 sits ~3 sigma above the no-carrier mean, with the drop threshold at 0.78 for level hysteresis and 8-up/32-down verify counts for time hysteresis (declare fast, drop reluctantly — the EMA already correlates adjacent looks, so the counts guard against band-edge dwell rather than compounding i.i.d. probabilities). A live lock survives the re-tune; the in-flight verify run restarts.

Parameters:

  • state Costas state. Must be non-NULL.
  • up_thresh Declare threshold on the lock-metric EMA.
  • down_thresh Drop threshold (<= up_thresh for level hysteresis).
  • n_up Consecutive above-threshold symbols to declare; clamped to >= 1.
  • n_down Consecutive below-threshold symbols to drop; clamped to >= 1.
    >>> from doppler.track import Costas
    >>> c = Costas(bn=0.05, zeta=0.707, tsamps=64)
    >>> c.locked
    False
    >>> c.configure_lock(0.9, 0.8, 4, 16)   # tighter declare, faster drop
    

function costas_create

Create a Costas instance.

costas_state_t * costas_create (
    double bn,
    double zeta,
    double init_norm_freq,
    size_t tsamps,
    double bn_fll
) 

Parameters:

  • bn Loop noise bandwidth (default 0.05).
  • zeta Damping factor (default 0.707).
  • init_norm_freq Seed carrier frequency, cycles/sample (default 0.0).
  • tsamps Samples per symbol (default 64).
  • bn_fll FLL-assist bandwidth (default 0.0 = pure PLL).

Returns:

Heap-allocated state, or NULL on allocation failure.

Note:

Caller must call costas_destroy() when done.


function costas_destroy

Destroy a Costas instance and release all memory.

void costas_destroy (
    costas_state_t * state
) 

Parameters:

  • state May be NULL.

function costas_get_bn

double costas_get_bn (
    const costas_state_t * state
) 

function costas_get_bn_fll

double costas_get_bn_fll (
    const costas_state_t * state
) 

function costas_get_last_error

double costas_get_last_error (
    const costas_state_t * state
) 

function costas_get_lock_metric

double costas_get_lock_metric (
    const costas_state_t * state
) 

function costas_get_locked

Current carrier lock decision (1 = locked, 0 = not), from the verify-counted detector on the lock-metric EMA (see costas_configure_lock).

int costas_get_locked (
    const costas_state_t * state
) 


function costas_get_nco_freq

Effective NCO frequency command (loop-filter output = integrator + proportional), cycles/sample. Mean rides a ramp with no lag, unlike the integrator-only get_norm_freq.

double costas_get_nco_freq (
    const costas_state_t * state
) 


function costas_get_norm_freq

double costas_get_norm_freq (
    const costas_state_t * state
) 

function costas_get_state

Serialize the full loop state into blob .

void costas_get_state (
    const costas_state_t * state,
    void * blob
) 


function costas_init

Initialise a Costas loop in place (no allocation).

void costas_init (
    costas_state_t * s,
    double bn,
    double zeta,
    double init_norm_freq,
    size_t tsamps,
    double bn_fll
) 

The by-value counterpart to costas_create(): a tracking channel that embeds a costas_state_t initialises it here. Seeds the NCO at init_norm_freq and the loop integrator to the matching per-symbol frequency so de-rotation is correct from the first sample.

Parameters:

  • s State to initialise. Must be non-NULL.
  • bn Loop noise bandwidth, normalised to the symbol rate.
  • zeta Damping factor (0.707 = critically damped).
  • init_norm_freq Seed carrier frequency, cycles/sample.
  • tsamps Samples per symbol (the integrate-and-dump period).
  • bn_fll FLL-assist bandwidth (0 = pure PLL).

function costas_reset

Re-seed the loop to its create-time frequency/phase; keep config.

void costas_reset (
    costas_state_t * state
) 

Drops the lock and rewinds the NCO, loop integrator and integrate-and-dump accumulators to the create-time seed frequency, while retaining the configured loop bandwidth, damping and lock-detector thresholds. Reprocess the same input after a reset and the output is bit-identical.

Parameters:

  • state Must be non-NULL.
    >>> import numpy as np
    >>> from doppler.track import Costas
    >>> tsamps = 16
    >>> rng = np.random.default_rng(3)
    >>> bits = rng.integers(0, 2, 1500) * 2 - 1
    >>> sig = np.repeat(bits.astype(np.complex64), tsamps)
    >>> k = np.arange(len(sig))
    >>> rx = (sig * np.exp(2j * np.pi * 0.002 * k)).astype(np.complex64)
    >>> c = Costas(bn=0.05, zeta=0.707, tsamps=tsamps)
    >>> _ = c.steps(rx)
    >>> round(c.norm_freq, 4) != 0.0     # loop pulled onto the residual
    True
    >>> c.reset()
    >>> c.norm_freq                       # back to the create-time seed
    0.0
    >>> c.lock_metric
    0.0
    

function costas_set_bn

void costas_set_bn (
    costas_state_t * state,
    double val
) 

function costas_set_bn_fll

void costas_set_bn_fll (
    costas_state_t * state,
    double val
) 

function costas_set_norm_freq

void costas_set_norm_freq (
    costas_state_t * state,
    double val
) 

function costas_set_state

Restore state; DP_OK, or DP_ERR_INVALID if the envelope rejects.

int costas_set_state (
    costas_state_t * state,
    const void * blob
) 


function costas_set_telemetry

Attach (or detach) a telemetry context and register the carrier loop's probes on it. Registers four probes, emitted once per dumped symbol and further thinned by decim: "<prefix>.lock" (the |Re P|/|P| lock-metric EMA, 1 = phase-locked), "<prefix>.e" (the PLL discriminator output — the loop stress), "<prefix>.freq" (the tracked NCO frequency, cycles/sample) and "<prefix>.locked" (the verify-counted lock decision, 0/1 — see costas_configure_lock). Passing NULL detaches. Setup path, never hot: call before the producer thread starts stepping; the context is borrowed and must outlive the attachment (SPSC rules in dp_tlm/dp_tlm_core.h ).

int costas_set_telemetry (
    costas_state_t * state,
    dp_tlm_t * tlm,
    const char * prefix,
    uint32_t decim
) 

Parameters:

  • state Must be non-NULL.
  • tlm Telemetry context to attach, or NULL to detach.
  • prefix Probe-name prefix, e.g. "car" or "ch0.car".
  • decim Emit every decim-th symbol; >= 1.

Returns:

DP_OK, or DP_ERR_INVALID when the probe table cannot take all four probes (the attach fails whole; the object stays detached).

>>> import numpy as np
>>> from doppler.track import Costas
>>> from doppler.telemetry import Telemetry
>>> tlm = Telemetry(1 << 12)
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=64)
>>> c.set_telemetry(tlm, "car")
>>> sorted(tlm.probe_names)
['car.e', 'car.freq', 'car.lock', 'car.locked']
>>> x = np.ones(64 * 100, dtype=np.complex64)
>>> _ = c.steps(x)
>>> recs = tlm.read()   # four records per dumped symbol
>>> len(recs) == 4 * 100
True


function costas_state_bytes

Serialized-state byte size.

size_t costas_state_bytes (
    const costas_state_t * state
) 


function costas_steps

De-rotate a cf32 block with the carrier NCO, integrate-and-dump each symbol, and emit one decision-directed Costas prompt per symbol.

size_t costas_steps (
    costas_state_t * state,
    const float _Complex * x,
    size_t x_len,
    float _Complex * out,
    size_t max_out
) 

The streaming Python face of the loop. For every input sample it wipes the (tracked) carrier off x with the integer-phase NCO, sums the result into the coherent integrate-and-dump accumulator, and on each symbol boundary (one every tsamps samples) dumps the accumulator as the prompt, runs the BPSK Costas discriminator to steer the NCO frequency and phase, and appends the mean-scaled prompt to the output. Loop state carries across calls, so a long capture can be fed block by block; exactly one prompt symbol comes out per tsamps input samples.

Parameters:

  • state Costas state. Must be non-NULL.
  • x Input samples, one complex baseband sample each.
  • x_len Number of input samples in x.
  • out Prompt-symbol output buffer.
  • max_out Capacity of out, in symbols.

Returns:

Number of prompt symbols written to out (one per tsamps input samples). On the Python face this is the recovered-symbol array.

>>> import numpy as np
>>> from doppler.track import Costas
>>> tsamps = 16
>>> rng = np.random.default_rng(1)
>>> bits = rng.integers(0, 2, 4000) * 2 - 1
>>> sig = np.repeat(bits.astype(np.complex64), tsamps)
>>> k = np.arange(len(sig))
>>> rx = (sig * np.exp(2j * np.pi * 0.003 * k)).astype(np.complex64)
>>> c = Costas(bn=0.05, zeta=0.707, tsamps=tsamps)
>>> sym = c.steps(rx)             # one prompt per tsamps samples
>>> sym.shape
(4000,)
>>> round(c.norm_freq, 4)         # pulled onto the 0.003 residual
0.003
>>> c.lock_metric > 0.9
True


function costas_steps_max_out

size_t costas_steps_max_out (
    costas_state_t * state
) 

function costas_tlm_flush

Emit the carrier loop's telemetry records for the symbol just dumped.

void costas_tlm_flush (
    const costas_state_t * s
) 

Out-of-line on purpose: the emit machinery must not inline into a per-sample hot loop (inlined ring-write expansions bloat the loop body and an extern call site forces per-iteration state reloads — both measured ~20% slower detached on other loops). Callers gate on s->tlm.ctx and call this once per dumped symbol. Records "<prefix>.lock" (the |Re P|/|P| lock-metric EMA), "<prefix>.e" (the last PLL discriminator — the loop stress) and "<prefix>.freq" (the tracked NCO frequency, cycles/sample). A composing tracking channel (the DSSS despreader) calls this from its own per-epoch update.

Parameters:

  • s State with a non-NULL tlm.ctx (caller-checked).

function costas_update

Per-symbol carrier update: discriminator -> loop filter -> steer NCO.

JM_FORCEINLINE  JM_HOT void costas_update (
    costas_state_t * s,
    float _Complex P
) 

Runs the decision-directed BPSK Costas discriminator on the prompt P, filters it, and writes the new frequency (lo_set_norm_freq) plus a proportional phase nudge into the NCO. Updates the lock metric and last_error (the instantaneous loop stress). Inline for composition.

Parameters:

  • s Costas state. Must be non-NULL.
  • P The dumped integrate-and-dump prompt for this symbol.

function costas_wipeoff

Per-sample carrier wipe-off: de-rotate x by the NCO, advance it.

JM_FORCEINLINE  JM_HOT float _Complex costas_wipeoff (
    costas_state_t * s,
    float _Complex x
) 

x * conj(lo_step(nco)) — strips the (tracked) carrier ahead of the matched-filter integrate-and-dump. Inline, zero call overhead.

Parameters:

  • s Costas state. Must be non-NULL.
  • x One input sample.

Returns:

The de-rotated sample to feed the integrator.


Macro Definition Documentation

define COSTAS_EPS

#define COSTAS_EPS `1e-12f`

define COSTAS_LOCK_ALPHA

#define COSTAS_LOCK_ALPHA `0.1`

define COSTAS_STATE_MAGIC

#define COSTAS_STATE_MAGIC `DP_FOURCC ('C', 'S', 'T', 'S')`

define COSTAS_STATE_VERSION

#define COSTAS_STATE_VERSION `3u /* v3: lockdet decision rule */`


The documentation for this class was generated from the following file native/inc/costas/costas_core.h