Skip to content

DsssBurstReceiver — the Composed Burst Chain

Four bursts decoded at their exact samples, the same answer at every block size, and every per-burst read-back checked against the scene

The composed form of BurstAcquisition -> refine -> BurstDemod, the burst DSSS receive chain, in one C object: DsssBurstReceiver. Its continuous counterpart is DsssReceiver; the hand-composed version of this chain — each stage demonstrated on its own — is the 5-Burst DSSS Link page, and that page is still the one to read first if you want to see what each stage does.

This page is what composing them buys. It is not fewer lines.

What the hand-off actually costs

Between acquisition and demodulation sits arithmetic that every caller was redoing, and getting subtly wrong:

  • acquisition reports an end anchor and a code phase modulo one code period. Neither is a burst start, and recovering one from the other is the refine stage — a search over whole code periods either side.
  • one preamble raises several detections. Coalescing them is a rule about a span, not a loop the caller writes.
  • the bin→frequency fold was restated at four call sites in three mutually inconsistent ways before it became dp_fftfreq().

Four properties, each asserted

The example is a gate, not an illustration: it exits non-zero if any of these stops being true.

1. Block size does not change the answer

The same capture is pushed whole, then in 64 KiB, 1000 and 333-sample blocks — the smallest 32x shorter than a single burst. All five decode the same four bursts at the same samples, with dropped == 0.

def decode(block_size):
    """Push the whole capture in blocks of `block_size`; return starts+bits."""
    rx = receiver()
    starts, bits = [], []
    for off in range(0, capture.size, block_size):
        out = np.asarray(rx.push(capture[off : off + block_size]))
        if out.size:
            bits.append(out)
            starts.extend(int(e[0]) for e in rx.events())
    return (
        starts,
        (np.concatenate(bits) if bits else np.empty(0, np.uint8)),
        rx,
    )

That is not free. It is what the history ring and the internally sliced push are for, and it was broken in three separate places at once (#1008): an early return that never looked at x, a break that left the tail unwritten, and a discard that did not count. A block carrying several bursts lost all but the first.

2. A burst split across two calls is held, not lost

Feed half a burst and push() returns nothing — deliberately. A burst is returned when it is complete, not when it is guessed at. Feed the rest and it comes out whole, bit-exact, wherever the split fell.

cut = truth[0] + BURST_LEN // 2
rx = receiver()
first = np.asarray(rx.push(capture[:cut]))
held = rx.pending
second = np.asarray(rx.push(capture[cut:]))
print(
    f"  fed {cut} samples (mid-burst): {first.size // FRAME_SYMS} frame(s), "
    f"pending={held}"
)
print(
    f"  fed the rest:                  {second.size // FRAME_SYMS} frame(s), "
    f"pending={rx.pending}"
)
assert first.size == 0, "a half-arrived burst must not be emitted"
assert held == 1, "pending must report the burst being held"
assert rx.pending == 0, "pending must clear once the burst is emitted"
assert np.array_equal(second[PAYLOAD_OFF:][:PAYLOAD], payload), (
    "the split burst is not exact"
)
print("  -> held, then returned whole. Read pending before you stop feeding.")

pending is how you can tell the difference between "nothing here" and "I am holding one". Read it before you stop feeding a stream: closing a file or a socket while it is non-zero discards a burst that would have decoded, and no other read-back distinguishes that case from an empty capture — dropped counts samples the ring refused, n_bursts counts what was demodulated, and a truncated burst is neither.

3. refine_span is the minimum burst spacing

Two detections closer together than refine_span are treated as the same preamble and merged. So bursts packed tighter than it are lost rather than reported, and the span is a property to read rather than a constant to assume:

# The spacing is READ from the receiver, not computed here: detections closer
# than `refine_span` are coalesced as one preamble, so a capture that packs
# them tighter loses bursts rather than erroring. A margin over the minimum
# because sitting on an inequality is how a geometry change breaks a demo.
probe = receiver()
REFINE_SPAN, RETAIN_SPAN = probe.refine_span, probe.retain_span
SPACING = REFINE_SPAN + REFINE_SPAN // 5
GAP = SPACING - BURST_LEN
assert GAP > 0, "the geometry cannot fit a gap at this spacing"

The boundary is sharp. At spacing exactly refine_span, one burst of four is lost; one sample more recovers all four. Both spans were internal until #1011 — the only way to learn the minimum spacing was to read the C, and the header's own formula for it was 2.4x low.

4. Every read-back, checked against the scene

One push() can complete several bursts, and the scalar properties (rx.cn0_dbhz_est and friends) describe only the last one. events() hands back the same fields per burst, and between them they are the object's entire diagnostic surface:

field what it is what it is checked against here
preamble_start exact stream position of the preamble the burst's true start, sample for sample
doppler_hz_est signed coarse Doppler, from the search grid must sit inside the bin that contains the truth
doppler_res_hz that grid's bin width fs / (sf * spc)acq transforms sf*spc verbatim
cn0_dbhz_est C/N0 lower bound implied by the hit Es/N0 + 10·log10(Rs) of the scene that was generated
est_freq_hz residual frequency after refine + demod a hundredth of one search bin — and it beats that by far
est_rate_hz chirp-rate estimate zero, because max_rate=0 switches that axis off
est_snr_db the estimator's own peak-to-mean confidence a floor — it is not a link SNR
refine_margin runner-up code period over the winner strictly under 1, or the wrong period won
frame_valid every check that RAN came out good 1, on every burst
frame_checked checking stages actually reversed 1 with a CRC, 0 with none — a different fact from a fail
# The scalar properties describe the LAST burst only — one push() can
# complete several and one set of scalars cannot speak for all of them.
# `events()` hands back the same fields per burst, and between them they
# are the whole diagnostic surface: where the burst was, what the
# search grid thought its Doppler was and how wide that grid is, what C/N0
# the hit implies, what the estimator refined the residual frequency and
# chirp rate to and how confident it was, how far the winning preamble beat
# its runner-up, and whether the CRC checked out.
_, _, rx_all = results[capture.size]
events = np.asarray(rx_all.events(rx_all.events_max_out()))

# What the scene says each read-back must be. Derived from the geometry
# above, not from a previous run of this script: `acq` transforms sf*spc
# verbatim, so the bin width is fs over that, and a segment generated at
# Es/N0 with a DATA_SF-chip symbol carries C/N0 = Es/N0 + 10log10(Rs).
BIN_HZ = FS / (ACQ_SF * SPC)
CN0_DBHZ_TRUE = ESN0_DB + 10.0 * np.log10(CHIP_RATE / DATA_SF)

print("\nevery read-back, per burst (events(), one row per decoded burst):")
print(
    f"  {'#':>2} {'start':>7} {'dopp_hz':>8} {'res_hz':>8} {'cn0_dBHz':>9} "
    f"{'freq_hz':>8} {'rate_hz':>8} {'conf_dB':>8} {'margin':>7}"
)
for i, e in enumerate(events):
    print(
        f"  {i:>2} {int(e['preamble_start']):>7} "
        f"{e['doppler_hz_est']:>8.1f} {e['doppler_res_hz']:>8.1f} "
        f"{e['cn0_dbhz_est']:>9.2f} {e['est_freq_hz']:>8.2f} "
        f"{e['est_rate_hz']:>8.2f} {e['est_snr_db']:>8.2f} "
        f"{e['refine_margin']:>7.3f}"
    )
print(
    f"  scene: bin {BIN_HZ:.1f} Hz, C/N0 {CN0_DBHZ_TRUE:.2f} dB-Hz "
    f"(Es/N0 {ESN0_DB:.0f} dB at {CHIP_RATE / DATA_SF / 1e3:.1f} ksym/s), "
    "true offset 0 Hz, no chirp"
)

assert events.size == N_BURSTS, "one event per decoded burst, always"
for i, e in enumerate(events):
    where = f"burst {i}"
    # The record must stand on its own: its start is the burst's start.
    assert int(e["preamble_start"]) == truth[i], (
        f"{where}: event says {int(e['preamble_start'])}, burst starts at "
        f"{truth[i]}"
    )
    # The search grid: its width is derived, and its estimate must sit in
    # the bin that contains the truth (0 Hz).
    assert e["doppler_res_hz"] == BIN_HZ, (
        f"{where}: bin width {e['doppler_res_hz']} != fs/(sf*spc) {BIN_HZ}"
    )
    assert abs(e["doppler_hz_est"]) <= BIN_HZ / 2, (
        f"{where}: coarse Doppler {e['doppler_hz_est']:.1f} Hz is outside "
        "the bin containing the true 0 Hz"
    )
    # ...and the whole reason the chain does not stop at acquisition: the
    # refined residual is finer than the grid by orders of magnitude.
    assert abs(e["est_freq_hz"]) < BIN_HZ / 100, (
        f"{where}: refined residual {e['est_freq_hz']:.2f} Hz is no better "
        f"than a hundredth of the {BIN_HZ:.0f} Hz search bin — refine and "
        "demod are not adding anything over the grid"
    )
    # A zero here is a CONFIGURATION fact, not a measurement: max_rate=0
    # switches the chirp axis off. Pass a non-zero max_rate to ask for one.
    assert e["est_rate_hz"] == 0.0, (
        f"{where}: chirp rate {e['est_rate_hz']} Hz/s from a receiver built "
        "with max_rate=0, which does not search that axis"
    )
    # The C/N0 estimate is a LOWER BOUND, so it may not run hot; the scene
    # says what it is bounding.
    assert e["cn0_dbhz_est"] <= CN0_DBHZ_TRUE + 1.5, (
        f"{where}: C/N0 estimate {e['cn0_dbhz_est']:.2f} dB-Hz runs hot "
        f"against the scene's {CN0_DBHZ_TRUE:.2f} dB-Hz — it is documented "
        "as a lower bound, and per-burst estimator spread here is a few "
        "tenths of a dB"
    )
    assert e["cn0_dbhz_est"] >= CN0_DBHZ_TRUE - 3.0, (
        f"{where}: C/N0 estimate {e['cn0_dbhz_est']:.2f} dB-Hz is more than "
        f"3 dB under the scene's {CN0_DBHZ_TRUE:.2f} dB-Hz"
    )
    # `est_snr_db` is the estimator's own peak-to-mean confidence, NOT a
    # link SNR — do not compare it with Es/N0.
    assert e["est_snr_db"] > 10.0, (
        f"{where}: estimator confidence {e['est_snr_db']:.1f} dB — the "
        "winning row barely stood out from its own mean"
    )
    assert 0.0 < e["refine_margin"] < 1.0, (
        f"{where}: refine margin {e['refine_margin']:.3f} — the runner-up "
        "period was not beaten by the winner"
    )

# The scalars are not a second source: they ARE the last row.
last = events[-1]
for name in events.dtype.names:
    assert getattr(rx_all, name) == last[name], (
        f"scalar {name} disagrees with the last event row — the scalars "
        "describe the most recent burst and nothing else"
    )
print(
    f"  -> all {N_BURSTS} rows check out against the scene; the scalar "
    "read-backs equal row -1, which is all they ever claim to be"
)

The bottom two panels of the figure are those rows plotted. The left one is the reason the chain does not stop at acquisition: the search grid can only promise half a bin — 1961 Hz at this geometry — and refine plus demod resolve the residual to well under a hertz, three orders of magnitude inside it. The right one puts the quality read-backs beside the scene that produced them: the C/N0 estimate lands within about a decibel of the scene's 57.1 dB-Hz on every burst, from a bound that is allowed to be pessimistic and must not run hot.

Two of these are easy to misread, so the example asserts them rather than mentioning them:

  • est_rate_hz == 0 is a configuration fact, not a measurement. This receiver was built with max_rate=0.0, so the chirp axis is not searched. A caller who wants a rate has to ask for one.
  • est_snr_db is confidence, not SNR. It is the winning estimator row's peak-to-mean ratio. Comparing it with the link's Es/N0 will give a number that looks meaningful and is not.

The receiver stops at hard and soft decisions

This object used to assume sync | payload | CRC-16, then briefly held a frame description of its own — four knobs, a checker and a frame_valid read-back. Neither is a physical-layer fact, and the second put a CCSDS coverage policy inside a header that says it "knows nothing about CCSDS" (#1022).

So the chain is three objects, each knowing one thing:

layer object knows
physical DsssBurstReceiver the codes, the sync word, and how many symbols follow it
frame wfm.FrameDesc / Frame the fields, the stages, and what each covers
codes coding.Viterbi, coding.ReedSolomon the arithmetic a stage calls

push() returns frame_syms bits per burst — the frame as received, sync word first — and llrs() returns the same decisions as soft values. That is the whole output. Undoing the frame is a separate call:

# The receiver stopped at decisions: `push()` handed back FRAME BITS and no
# opinion about them (doppler#1022). Turning those into a payload — and into
# a verdict — needs the frame's description, which is what `FrameDesc` is.
# One object, built once, describing exactly what the generator built.
empty = np.empty(0, np.uint8)
deframer = FrameDesc(empty, empty, empty)
deframer.add_field(SYNC)  # found, not decoded
deframer.add_field(np.zeros(PAYLOAD, np.uint8))  # the geometry
deframer.add_field(empty, derived_by=1, derived_bits=16)  # the CRC, by stage 0
deframer.add_stage(0, first_field=1, n_fields=2)  # CRC-16 over both
deframer.build()

deframed_ok, payloads = [], []
for k in range(N_BURSTS):
    frame = ref_bits[k * FRAME_SYMS : (k + 1) * FRAME_SYMS]
    got = np.asarray(deframer.deframe(frame))
    deframed_ok.append(deframer.rx_ok == deframer.rx_units == 1)
    payloads.append(got[PAYLOAD_OFF : PAYLOAD_OFF + PAYLOAD])

print("\ndeframed by wfm.FrameDesc (the receiver has no opinion):")
print(
    f"  {sum(deframed_ok)}/{N_BURSTS} frames check out, "
    f"{sum(int(np.array_equal(p, payload)) for p in payloads)}/{N_BURSTS} "
    "payloads bit-exact"
)
assert all(deframed_ok), "every frame's CRC must check out"
for k, got in enumerate(payloads):
    assert np.array_equal(got, payload), f"burst {k} payload is not exact"
print("  -> decide, then deframe. Two objects, one frame, no shared secret.")
  • crc=none is simply a shorter frame — the receiver is told a smaller frame_syms, and the DeFramer reports rx_checked == 0: carries no check is not the check failed, and an FER conflating them would score every unprotected frame as an error.
  • A randomiser round-trips when both descriptions carry it; a receiver that does not derandomise gets bits the CRC rejects rather than a silently wrong payload.
  • An outer code REPAIRS inside deframe(), before the payload is sliced. Measured on this chain: eight injected bit errors reach the payload without rs_depth and are gone with it.

What the receiver gained by giving all that up is that it can be pointed at any frame: it needs a template to correlate and a length to slice, and nothing else.

Soft bits, for whatever decodes them

push() returns hard bits; llrs() returns the same decision seen a second way, one value per frame symbol:

bits = rx.push(x)                    # hard, per burst, frame rows
llr = rx.llrs(rx.llrs_max_out(1))    # soft, same order, same rows

The convention is mpsk_soft_demap's — positive means bit 0, so (llr < 0) reproduces exactly the bits push() returned, which the tests assert rather than assume. A hard decision costs roughly 2 dB of the coding gain a soft-input decoder exists to deliver, and until recently that number was computed and freed one line before the slicer.

They are scaled, not raw: 2·a·r/n0, with n0 estimated from the symbols themselves (after derotation the real axis carries the signal and the imaginary axis carries noise alone). A Viterbi would not care — it is invariant to a positive scale — but LLRs from different bursts are not comparable without one, and the scaled version is a measurement in its own right: every 6 dB of Es/N0 multiplies it by about four (measured 17.9 → 67.2 → 259.2 at 6, 12 and 18 dB).

The inner code is the one stage nothing here undoes yet: it covers the sync word, so a hard-decision correlator cannot find a coded frame at all and frame sync would have to run after the Viterbi. The soft bits it needs now exist; the ordering does not.

The same thing in C

The C example demonstrates the same sections and prints the same numbers, because both build their capture from one wfmgen scene through the same engine — wfm_compose_create() in C, Composer/Segment in Python. Neither tiles a preamble, spreads a frame, appends a CRC or draws noise. (The per-burst estimates of the read-back section are the one place the two faces differ in the last decimal: the payload bits are drawn differently, so the noise the estimator sees is not the same realisation. Every check the two apply is the same one, derived the same way from the scene.)

What the C face shows that the Python one cannot is the part the binding does for you: the lifecycle you manage yourself, and that the output buffer is the caller's — sized from push_max_out() on the block being pushed, not from payload_len, because one call may complete several bursts.

Why the preamble is 255 chips, not 511

acq transforms sf * spc verbatim: the code-axis correlation is circular, so zero-padding the replica or the epoch would change the correlation rather than interpolate it. An m-sequence is 2^n - 1 chips — 31, 127, 511, 2047 — every one prime or near-prime, which is the worst case for an FFT. At 127 chips and spc=4 that is 508 = 2²·127, and pocketfft falls back to Bluestein: 9.70 µs against 0.75 µs for a smooth length, and 21 MSa/s against 52 end to end.

255 chips at spc=2 is 510 = 2·3·5·17 — smooth, and twice the autocorrelation ratio of the 127-chip code. Better on both axes rather than a trade.

Rounding to 512 is the trap: no binary code of that length has good periodic autocorrelation (an extended m-sequence gives 8.0; the best of a 4000-code random search reached 10.7), and this object's own certification brackets what that costs — ratio 31 found every burst offset, 1.07 lost 47% of them.