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. Each stage on its own is its API page: BurstAcquisition, BurstDemod.

This page is the end-to-end walkthrough: a frame description, the burst train wfmgen transmits from it on all three of its faces, the receiver, and the frames checked against the same description. It ends with a BER curve. What composing the stages buys 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().

One description, three faces, one capture

The frame is a description: its fields in wire order, and the CRC stage with the span it covers. It is written twice in the script, once as the JSON that wfmgen --frame reads and a scene's "frame" key holds, and once as a FrameDesc, by the same names:

# The frame, as a DESCRIPTION: fields in wire order, and the stage that
# fills the CRC over the span it covers. Written once as JSON -- the file
# `wfmgen --frame` reads and the value a scene's "frame" key holds -- and
# once as the Python object, by the same names. Property 0 below is what
# proves the two are one description: they render byte-identical bursts.
FRAME = {
    "fields": [
        {"name": "sync", "spec": SYNC_SPEC},  # found, not decoded
        {"name": "payload", "spec": PAYLOAD_SPEC},
        {"name": "crc", "bits": 16, "derived_by": 1},  # stage 0 fills it
    ],
    "stages": [{"kind": "crc16", "first_field": 1, "n_fields": 2}],
}

desc = FrameDesc()
desc.add_field("sync", field_bits(SYNC_SPEC))
desc.add_field("payload", field_bits(PAYLOAD_SPEC))
desc.add_derived("crc", 16)
desc.add_stage_over(STAGE_CRC16, "payload", "crc")  # CRC-16 over both
desc.build()

FRAME_SYMS = desc.nbits  # the frame's length is the description's to say

The burst train is one type="dsss" segment played four times, rendered on every face wfmgen has: Python objects with frame= taking the FrameDesc, a JSON scene with the description under "frame", and the command line with --frame spread.json. The three captures are asserted byte-identical, so whichever door a capture came in by, it is the same samples. The FrameDesc handed to the transmitter is the object the frames are checked against at the end.

# One dsss segment, played N_BURSTS times: the engine tiles the preamble,
# spreads the described frame by the data code, sizes the on-time itself,
# and draws fresh noise per burst. `gap_noise` stays at its default, so the
# floor runs through the gaps, as it does in a real capture.
#
# Python: the description is the FrameDesc object itself.
python_face = Composer(
    [
        Segment(
            type="dsss",
            fs=FS,
            snr=ESN0_DB,
            snr_mode="esno",  # Es/N0 of the DATA_SF-chip data symbol
            seed=SEED,
            sps=SPC,  # samples per CHIP
            acq_code=acq_code,
            acq_reps=REPS,
            data_code=data_code,
            frame=desc,
            repeats=N_BURSTS,
            off_samples=GAP,
        )
    ]
).compose()

# A scene: the same segment as JSON, the description under "frame", every
# code as its Field text. A scene has no `acq_reps`: the repetition rides
# in the preamble's Field, as `*REPS`.
scene = {
    "version": 1,
    "segments": [
        {
            "type": "dsss",
            "fs": FS,
            "snr": ESN0_DB,
            "snr_mode": "esno",
            "seed": SEED,
            "sps": SPC,
            "acq_code": f"{ACQ_SPEC}*{REPS}",
            "data_code": DATA_SPEC,
            "frame": FRAME,
            "repeats": N_BURSTS,
            "off_samples": GAP,
        }
    ],
}
scene_face = Composer.from_json(json.dumps(scene)).compose()

# The command line: the description is a file, and `--frame` reads it.
argv = [
    "wfmgen", "--type", "dsss", "--fs", f"{FS:.0f}",
    "--snr", f"{ESN0_DB:g}", "--snr-mode", "esno",
    "--seed", f"{SEED}", "--sps", f"{SPC}",
    "--acq-code", f"{ACQ_SPEC}*{REPS}", "--data-code", DATA_SPEC,
    "--frame", "spread.json",
    "--repeats", f"{N_BURSTS}", "--off", f"{GAP}",
    "-o", "train.cf32",
]  # fmt: skip
with tempfile.TemporaryDirectory() as tmp:
    Path(tmp, "spread.json").write_text(json.dumps(FRAME, indent=2))
    # `cli._runnable()` is the one locator for the bundled C binary.
    subprocess.run([cli._runnable(), *argv[1:]], cwd=tmp, check=True)
    cli_face = np.fromfile(Path(tmp, "train.cf32"), dtype=np.complex64)

assert np.array_equal(python_face, scene_face), "Python vs scene differ"
assert np.array_equal(cli_face, python_face), "CLI vs Python differ"
capture = python_face

The command it runs, as printed by the script:

wfmgen --type dsss --fs 2000000 --snr 12 --snr-mode esno --seed 1 --sps 2
       --acq-code 'pn:255:8:1*5' --data-code pn:31:5:3 --frame spread.json
       --repeats 4 --off 6020 -o train.cf32

Every run of bits is a Field: the codes are pn:255:8:1 and pn:31:5:3, one period of each m-sequence, and the payload is pn:96:7:5. The *5 on the preamble is its repetition; a scene has no separate acq_reps. The reference for every type="dsss" key is DSSS bursts.

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 capture in blocks of `block_size`; return valid starts+bits.

    A frame is kept only when its event says `frame_valid`: the receiver
    stops at decisions, so a false alarm in the noise (at the configured
    pfa) still returns a frame -- and that flag is how a caller tells it
    from a burst.
    """
    rx = receiver()
    starts, bits = [], []
    for off in range(0, capture.size, block_size):
        out = np.asarray(rx.push(capture[off : off + block_size]))
        for k, e in enumerate(rx.events()):
            if e["frame_valid"]:
                bits.append(out[k * FRAME_SYMS : (k + 1) * FRAME_SYMS])
                starts.append(int(e["preamble_start"]))
    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[:FRAME_SYMS], desc.bits(1)), (
    "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 look-back abandoned with a burst, n_bursts counts what was demodulated, and a truncated burst is neither.

3. Leave min_gap between bursts

The dead air a caller has to leave between bursts, edge to edge, is min_gap, which the object derives (refine_span + reps·code_period − burst_len, floored at zero). refine_span is the reach over which two detections count as one preamble. It bounds start-to-start separation, so it is not a gap. Both are properties to read rather than constants to assume. The demo places its bursts at min_gap, with no margin, because that guarantee is what it asserts: every burst decodes, at its exact sample, at every block size:

# The dead air between bursts is READ from the receiver: `min_gap`, edge to
# edge, is the gap it guarantees. The demo places its bursts AT it, with no
# margin, because the guarantee is the thing being shown -- the assertions
# below are what would catch it failing. (`refine_span` is a start-to-start
# reach, not a gap; spacing by it is how this demo used to be wrong.)
probe = receiver()
MIN_GAP, RETAIN_SPAN = probe.min_gap, probe.retain_span
GAP = MIN_GAP
SPACING = BURST_LEN + GAP

Where the loss starts depends on where each burst falls against the search's dwells, so the boundary is not a single sample; min_gap is the separation the object guarantees. Measured at this geometry over 40 random lead offsets (doppler#1514), every burst spaced at min_gap decoded at its exact sample. One run also returned a fifth frame, from the noise after the last burst: a false alarm at the configured pfa, with its sync word and payload wrong and frame_valid = 0. The receiver stops at decisions, so a caller separates bursts from false alarms by that flag. The demo does.

Below min_gap there is no promise, but the bound is conservative here. Since the capture emits in stream order (#1527), bursts at an eighth of it still decode 4 of 4, at every block size. Whether min_gap should come down is #1530. 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
demod_cn0_dbhz C/N0 measured on the decoded symbols the scene's own C/N0, from the other side of the bound
demod_timing_chips burst-start error the demod removed a fraction of a chip — acquisition resolves to a sample
frame_valid the frame's CRC-16 trailer checked out 1, on every burst
# 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}"
)
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['demod_cn0_dbhz']:>8.2f}"
    )
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"
    )
    # `demod_cn0_dbhz` IS the same quantity as the scene's C/N0, measured
    # on the decoded symbols instead of bounded from the acquisition hit, so
    # it is checked against the truth from BOTH sides. Its predecessor
    # `est_snr_db` could only be checked against a bare threshold: it was the
    # preamble estimator's peak-to-mean, carrying the coherent processing
    # gain, and comparing it with a link budget was a category error
    # (doppler#1304).
    assert abs(e["demod_cn0_dbhz"] - CN0_DBHZ_TRUE) < 3.0, (
        f"{where}: demod C/N0 {e['demod_cn0_dbhz']:.2f} dB-Hz is more than "
        f"3 dB off the scene's {CN0_DBHZ_TRUE:.2f} dB-Hz"
    )
    assert abs(e["demod_timing_chips"]) < 0.5, (
        f"{where}: start error {e['demod_timing_chips']:+.3f} chip sits at "
        "the half-chip search bound, so the estimate is a floor"
    )

# 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 a few 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.
  • cn0_dbhz_est and demod_cn0_dbhz are the same quantity from opposite sides. Acquisition's is a lower bound from the hit and is allowed to be pessimistic; the demodulator's is measured on the decoded symbols and is asked to land on the scene. The example asserts the bound from below and the measurement from both sides, which is why they are plotted on one axis.
  • demod_timing_chips is not an error report. Acquisition resolves a burst start to one SAMPLE, so a residual fraction of a chip is structural. The demodulator removes the whole samples, measures the rest, and takes the loss back out of demod_cn0_dbhz — the field says how much was corrected. A magnitude AT half a chip means the search saturated and the C/N0 is a floor (doppler#1304).

The receiver stops at hard and soft decisions

The receiver knows one thing about a frame besides its length: that it ends in a CRC-16. frame_valid reports whether a window passed it, because that verdict decides whether the window owns its span (#1181). The fields, the stages and what each covers are not physical-layer facts, so they live in a frame description one layer up, and the receiver carries no framing policy of its own (#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, on the same description the transmitter was given:

# The receiver stopped at decisions: `push()` handed back FRAME BITS and no
# opinion about them (doppler#1022). The verdict and the payload need the
# frame's description -- and it is the SAME object the transmitter was
# given. `check()` reverses every checking stage and reports; `deframe()`
# hands back the corrected frame, where the payload is a slice found by name.
off = desc.field_off(desc.field_index("payload"))
deframed_ok, payloads = [], []
for k in range(N_BURSTS):
    frame = ref_bits[k * FRAME_SYMS : (k + 1) * FRAME_SYMS]
    verdict = desc.check(frame)
    deframed_ok.append(verdict.passed == verdict.checked == 1)
    got = np.asarray(desc.deframe(frame))
    payloads.append(got[off : off + PAYLOAD])

sent = field_bits(PAYLOAD_SPEC)
exact = sum(int(np.array_equal(p, sent)) for p in payloads)
print("\nchecked against the description (the receiver has no opinion):")
print(
    f"  {sum(deframed_ok)}/{N_BURSTS} frames check out, "
    f"{exact}/{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, sent), f"burst {k} payload is not exact"

# ...and a check that cannot fail is not a check: one flipped payload bit
# turns the verdict over.
bad = ref_bits[:FRAME_SYMS].copy()
bad[off] ^= 1
assert desc.check(bad).passed == 0, "a corrupted frame must fail its CRC"
print("  -> one description: transmitted from, decided on, checked against.")
  • A frame with no CRC stage still decodes, but it is never frame_valid. Told a smaller frame_syms, the receiver returns the frame's bits unchanged, 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. The receiver cannot draw that line. It knows a frame only by its length, so it reads the last 16 symbols as a CRC anyway, finds no valid one, and gives every window's span back. A frame with no CRC is not valid by design (D3). The cost is that an unprotected burst never suppresses a detection inside its own span (#1769).
  • 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 when the description has no rs stage, and are gone when it has one.

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 — dp_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.

BER vs Eb/N0, through one Plan

DSSS burst BER against Eb/N0: measured points with 99% intervals on coherent BPSK and the sync-phase-limited curve

The last step is the receiver's performance: a Monte Carlo of bit error rate against Eb/N0, scored against theory. The burst carries PN data and nothing else — no CRC, no frame description — and the transmit bits and the scoring truth come from one Field string through wfm.field_bits. The script is its own gate, dsss_burst_ber_demo.py, so the sweep's runtime (about 2 s by default) stays out of the sections above.

The stimulus is one Plan. The scene is one dsss source, so Composer(type="dsss", …) takes it directly. With snr_mode="ebno", snr is the Eb/N0 of one data bit, and plan.at(ebn0, seed) takes the same number: there is no conversion to an over-fs SNR anywhere. The Plan renders the burst's signal once and draws fresh noise per trial. Its contract is asserted before anything is measured: at the scene's own Eb/N0 and the anchor seed, a draw is a full compose of the same scene, byte for byte.

BASE_EBN0_DB = 6.0
FRAME = FrameDesc()  # [sync | data]: no CRC, so nothing follows the payload
FRAME.add_field("sync", SYNC)
FRAME.add_data("payload", len(PAYLOAD))
rx = receiver()  # ONE receiver for the whole sweep: reset() between trials
# One dsss source is the whole scene, so the Composer takes it directly.
# snr_mode="ebno" makes `snr` the Eb/N0 of one data bit (a dsss source's
# symbol is the despread data bit), and Plan's at() takes the SAME number:
# no hand conversion to the over-fs SNR anywhere below.
scene = Composer(
    type="dsss",
    fs=FS,
    snr=BASE_EBN0_DB,
    snr_mode="ebno",
    seed=11,
    sps=SPC,  # samples per CHIP
    acq_code=ACQ,
    acq_reps=REPS,
    data_code=DATA,
    frame=FRAME,
    data=PAYLOAD,  # PN data, and nothing after it
    delay_samples=LEAD,
    # the receiver holds a burst until it has seen refine_span past its
    # end, so that much trailing noise is what lets it emit
    off_samples=rx.refine_span,
    gap_noise="auto",
)
plan = prepare(scene)  # the burst's signal is rendered ONCE, here

# The anchor contract: at the scene's own Eb/N0 and the anchor seed, a Plan
# draw IS a full compose of the same scene, byte for byte.
anchor = plan.at(BASE_EBN0_DB, plan.anchor_seed)
assert anchor.tobytes() == scene.compose().tobytes(), (
    "plan.at(base_ebn0, anchor_seed) is not byte-identical to compose()"
)

One receiver serves every trial. Constructing one solves its detection design each time, which is most of a trial's cost. reset() keeps the design and clears the stream, and a reset receiver decodes a draw exactly as a fresh one does — asserted on the output and on every event:

# Building a receiver solves its detection design (the Pd/Pfa threshold)
# every time; reset() keeps the design and clears the stream. A reused
# receiver must decode a trial exactly as a fresh one does -- pinned here,
# on a draw pushed AFTER the reused one has already decoded another.
rx.push(plan.at(BASE_EBN0_DB, 1))
rx.reset()
x = plan.at(BASE_EBN0_DB, 2)
fresh = receiver()
assert np.array_equal(np.asarray(rx.push(x)), np.asarray(fresh.push(x)))
assert rx.events().tobytes() == fresh.events().tobytes(), (
    "a reset() receiver's events differ from a fresh receiver's"
)
rx.reset()

Each point stops on errors, as in Measuring an Error Rate, Defensibly: 100 bit errors and at least 20 bursts, because errors inside one burst share that burst's phase estimate and are not independent. A point that has not reached its target inside ten times the trials theory needs is not quoted. The window scored is the one the receiver decoded at the burst's true start. A trial without one is counted as a miss, not as errors, so the BER is conditional on acquisition.

def measure(ebn0_db: float, seed0: int, meter: BerMeter) -> dict:
    """One Eb/N0 point: trials until TARGET_ERRORS (and MIN_TRIALS bursts).

    Each trial is a fresh noise draw of the same cached burst, pushed whole
    through the one receiver, reset between trials (output-identical to a
    fresh one, asserted above). The window scored is the one the receiver
    decoded at the burst's true start; the payload is the frame after the
    sync word, compared bit for bit with the Field's own bits.
    """
    ideal = ber_theory_ber(2, 10.0 ** (ebn0_db / 10.0))
    max_trials = max(
        MIN_TRIALS, int(BUDGET * TARGET_ERRORS / (ideal * PAYLOAD.size))
    )
    errors = bits = misses = trials = 0
    draws = set()  # every trial's noise must be its own
    while (errors < TARGET_ERRORS or trials < MIN_TRIALS) and (
        trials < max_trials
    ):
        x = plan.at(ebn0_db, seed0 + trials)
        trials += 1
        draws.add(hash(x.tobytes()))
        rx.reset()
        out = np.asarray(rx.push(x))
        hit = [
            k
            for k, e in enumerate(rx.events())
            if int(e["preamble_start"]) == LEAD
        ]
        if not hit:
            misses += 1
            continue
        frame = out[hit[0] * FRAME_SYMS :][:FRAME_SYMS]
        got = frame[len(SYNC) :]
        errors += int(np.count_nonzero(got != PAYLOAD))
        bits += PAYLOAD.size
    return {
        "ebn0": ebn0_db,
        "trials": trials,
        "misses": misses,
        "distinct": len(draws),
        "ci": meter.interval(errors, bits),
        "quoted": errors >= TARGET_ERRORS and trials >= MIN_TRIALS,
    }

The margin is derived, not fitted. BurstDemod has no tracking loop. Its carrier phase comes from the complex peak of the 13-symbol sync correlation. That estimate's error has variance 1 / (2·13·Eb/N0), and it scales the decision by cos φ. Averaging BPSK's Q over that error gives the BER this receiver must reach: about 0.2 dB worse than ideal at 0 dB, and shrinking as 1/(Eb/N0).

def sync_phase_limited_ber(ebn0_db: float) -> float:
    """BPSK BER for THIS receiver's carrier-phase estimate, not a fit.

    BurstDemod is feedforward: the residual carrier phase comes from the
    complex peak of the sync-word correlation (burst_demod_core.h, step 4),
    a sum over len(SYNC) = 13 despread symbols each at Es/N0 = Eb/N0. That
    estimate's error is Gaussian with variance 1 / (2 * 13 * Eb/N0), and a
    phase error phi scales the decision amplitude by cos(phi), so the
    expected BER is  E_phi[ Q(sqrt(2 Eb/N0) cos phi) ].  Averaged by
    Gauss-Hermite quadrature over doppler's own Q, it is ideal BPSK plus
    the loss this estimator must cost: ~0.2 dB at 0 dB, falling as 1/Eb/N0.
    """
    g = 10.0 ** (ebn0_db / 10.0)
    sigma = np.sqrt(1.0 / (2.0 * len(SYNC) * g))
    x, w = np.polynomial.hermite_e.hermegauss(40)
    ber = [ber_theory_ber(2, g * np.cos(sigma * xi) ** 2) for xi in x]
    return float(np.dot(w, ber) / w.sum())

Every point's 99% interval has to overlap the band between the two curves. Its hi must reach ideal BPSK, because a receiver cannot beat coherent detection, so a point below it means the Eb/N0 axis is wrong. Its lo must reach the sync-limited curve, or the receiver has a loss its phase estimate does not explain. The run also asserts that every trial drew distinct noise, and that misses stay within the pd = 0.999 the acquisition was designed for.

    quoted = 0
    for p in points:
        ci, eb = p["ci"], p["ebn0"]
        ideal = ber_theory_ber(2, 10.0 ** (eb / 10.0))
        limited = sync_phase_limited_ber(eb)
        # Stated only when the point stopped on errors: a point that ran
        # out of trials has not resolved its rate and is not quoted.
        loss = eb - ber_esn0_db_for_ser(2, ci.p_hat) if p["quoted"] else None
        print(
            f"{eb:6.1f} {p['trials']:6d} {p['misses']:4d} {ci.errors:6d} "
            f"{ci.p_hat:9.3e} [{ci.lo:9.3e}, {ci.hi:9.3e}] {ideal:9.3e} "
            f"{limited:9.3e} "
            + (f"{loss:+6.2f}dB" if loss is not None else "  (n/q)")
        )
        # Independent Monte Carlo: every trial drew its own noise.
        assert p["distinct"] == p["trials"], (
            f"{eb} dB: {p['trials']} trials but {p['distinct']} distinct "
            "draws -- the Plan's seed is not varying the noise"
        )
        # Acquisition met its design: the miss rate's interval admits 1-pd.
        miss = meter.interval(p["misses"], p["trials"])
        assert miss.lo <= 1.0 - PD, (
            f"{eb} dB: {p['misses']}/{p['trials']} bursts missed, more than "
            f"a pd={PD} acquisition allows"
        )
        quoted += bool(p["quoted"])
        # Both limits hold for EVERY point, quoted or not: an unresolved
        # point's interval is wide, not wrong. The receiver cannot beat
        # coherent BPSK ...
        assert ci.hi >= ideal, (
            f"{eb} dB: BER interval [{ci.lo:.3e}, {ci.hi:.3e}] lies below "
            f"ideal BPSK {ideal:.3e} -- the Eb/N0 axis is wrong"
        )
        # ... and must be no worse than its own sync-phase estimate costs.
        assert ci.lo <= limited, (
            f"{eb} dB: BER interval [{ci.lo:.3e}, {ci.hi:.3e}] lies above "
            f"the sync-phase-limited {limited:.3e} -- a loss the receiver's "
            "phase estimate does not account for"
        )
    assert quoted == len(points), (
        f"only {quoted}/{len(points)} points reached {TARGET_ERRORS} errors"
    )

Measured once with --full (0–8 dB, at least 1000 errors and 20 bursts per point: 6892 bursts, 52 s), no burst was missed. The loss against ideal BPSK, read off each measured rate, was:

Eb/N0 (dB) 0 1 2 3 4 5 6 7 8
loss (dB) 0.18 0.31 0.01 0.15 0.08 0.03 0.03 0.05 0.02

Only the 1 dB interval excludes ideal BPSK. Every interval reaches the sync-limited curve. The default run is five of these points at 100 errors (218 bursts, about 2 s).

Limits. The payload is the same in every trial, because a Plan caches the signal and only the noise varies. It is a BER and not an FER, because there is no frame to check.