Skip to content

A Framed Link, Checked by Its Description

One FrameDesc describes a frame — a sync word, a payload, a CRC-16 — and that same description is used on both ends of the link. wfmgen builds the waveform from it; MpskReceiver recovers the symbols; the description then finds and judges every frame. Nothing about the frame is written twice: the sync word is field 0, the frame length comes from the layout, and the verdict comes from FrameDesc.check.

The example runs BPSK and QPSK at Es/N0 = 14 dB with a carrier offset the receiver has to remove, 200 frames of 48 payload bits each.

What it shows

Phase ambiguity is resolved by the sync word. A carrier loop settles on one of M equivalent phases (two for BPSK, four for QPSK) and cannot tell which. Each phase is tried, and the one whose bits repeat the description's sync word at the frame period is kept — the way a real receiver resolves it.

Every frame after the lock transient passes, with the right payload. The first frame or two are lost while the loops settle; the rest pass FrameDesc.check, and each passed payload is one that was sent.

The check is a real detector. One flipped bit fails it. At 4 dB Es/N0 more than half the frames are damaged and fail, and none passes carrying a payload that was never sent.

The script asserts all of this and exits non-zero if any claim breaks. Its C twin is native/examples/framed_link_demo.c.

The code

import numpy as np

from doppler.mpsk import mpsk_demap
from doppler.track import MpskReceiver
from doppler.wfm import (
    STAGE_CRC16,
    Composer,
    FrameDesc,
    Segment,
    field_bits,
)

SYNC = field_bits("0x1ACFFC1D")  # 32 bits: field 0 of the description
PAYLOAD = 48
SPS = 8
SETTLE = 12  # frames the carrier and timing loops may take to settle

d = FrameDesc()
d.add_field("sync", SYNC)
d.add_data("payload", PAYLOAD)
d.add_derived("crc", 16)
d.add_stage_over(STAGE_CRC16, "payload", "crc")
d.build()
NB = d.nbits  # the frame's length, from the layout
POFF = d.field_off(d.field_index("payload"))  # where the payload starts


def transmit(kind, esn0_db, frames, seed=4):
    """Frames of random payload -> (truth chunks, complex baseband)."""
    rng = np.random.default_rng(seed)
    data = rng.integers(0, 2, frames * PAYLOAD, dtype=np.uint8)
    seg = Segment(
        type=kind,
        sps=SPS,
        fs=1.0,
        freq=0.0002,  # a carrier offset the receiver must remove
        pulse="rrc",
        level=-6.0,
        snr=esn0_db,
        snr_mode="esno",
        seed=seed,
        frame=d,
        data=data,
    )
    return data.reshape(-1, PAYLOAD), np.asarray(Composer([seg]).compose())


def receive(m, iq):
    """Matched-filter symbols from MpskReceiver."""
    rx = MpskReceiver(
        m=m,
        sps=SPS,
        m_out=SPS,  # one matched-filter output per symbol
        pulse="rrc",
        rrc_beta=0.35,
        rrc_span=8,
        bn_carrier=0.005,
        bn_timing=0.01,
        lock_thresh=0.65,
    )
    return rx, np.asarray(rx.steps(iq))


def to_bits(sym, m, k):
    """Symbols -> bits for carrier-phase ambiguity ``k`` (MSB first)."""
    bps = {2: 1, 4: 2}[m]
    rot = (sym * np.exp(-2j * np.pi * k / m)).astype(np.complex64)
    lab = mpsk_demap(rot, m)
    shifts = np.arange(bps - 1, -1, -1)
    return ((lab[:, None] >> shifts) & 1).astype(np.uint8).ravel()


# Retires when dp_syncword_search gets a Python face (#1803).
def sync_starts(bits):
    """Offsets where the description's sync word matches exactly."""
    win = np.lib.stride_tricks.sliding_window_view(bits, SYNC.size)
    return np.flatnonzero((win == SYNC).all(axis=1))


def slice_frames(sym, m):
    """Resolve the phase ambiguity by the sync word, then cut frames."""
    best = None
    for k in range(m):
        bits = to_bits(sym, m, k)
        st = sync_starts(bits)
        ok = np.intersect1d(st, st + NB)  # repeats at the frame period
        if best is None or ok.size > best[2].size:
            best = (k, bits, ok)
    k, bits, ok = best
    s0 = int(ok[0]) % NB if ok.size else 0
    n = (bits.size - s0) // NB
    return k, bits[s0 : s0 + n * NB].reshape(n, NB)


def link(kind, m, esn0_db, frames=200):
    chunks, iq = transmit(kind, esn0_db, frames)
    rx, sym = receive(m, iq)
    k, got = slice_frames(sym, m)
    verdicts = [d.check(f) for f in got]
    # Which sent frame is got[0]? Frames are unique, so match a passing one.
    first = next(
        (
            i
            for i, v in enumerate(verdicts)
            if v.passed
            and any(
                np.array_equal(got[i, POFF : POFF + PAYLOAD], c)
                for c in chunks
            )
        ),
        None,
    )
    return rx, chunks, got, verdicts, k, first