DsssBurstReceiver — the Composed Burst Chain¶
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
refinestage — 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 == 0is a configuration fact, not a measurement. This receiver was built withmax_rate=0.0, so the chirp axis is not searched. A caller who wants a rate has to ask for one.est_snr_dbis 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=noneis simply a shorter frame — the receiver is told a smallerframe_syms, and the DeFramer reportsrx_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 withoutrs_depthand 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.
Related pages¶
- 5-Burst DSSS Link — the same chain, hand-composed, each stage on its own
- DsssReceiver — the continuous counterpart
- DSSS Acquisition: Pd/Pfa — the search stage characterised
