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. 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
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().
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 == 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.cn0_dbhz_estanddemod_cn0_dbhzare 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_chipsis 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 ofdemod_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 smallerframe_syms, the receiver returns the frame's bits unchanged, 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. 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 norsstage, 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¶
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.
Related pages¶
- Spread frames on
--type dsss— the reference for a description on a dsss source - DsssReceiver — the continuous counterpart
- DSSS Acquisition: Pd/Pfa — the search stage characterised

