A Receiver Built from a Frame Description¶
BurstDemod and DsssBurstReceiver will take the frame's description, the
same wfm_frame_desc_t the transmitter spreads, and derive from it
everything they are told by hand today: the sync word, how many symbols a
frame occupies, how long a burst is, and whether a frame passed its check.
This page is that pass through the lifecycle (#1620, part E of
#853).
It changes what a receiver is told, not what it decides. The receiver
still stops at decisions (DsssBurstReceiver §10): push() returns
one bit per frame symbol and llrs() the matching soft values, and undoing
the frame stays Frame.deframe(). The model the description comes from is
A Frame as a Description; this page adds only its
receive side.
Why a page of its own. frame-description.md states the model and is
already 722 lines. This is a separate pass with its own inventory, deletions
and plan, the same shape as the payload pass beside it (#1650). The
model page's "not in scope: the receive side" entry links here.
Status (2026-10-03). Steps 1-5 of §11 have landed: both receivers are
built from a description only. The frame_syms constructors,
dp_burst_demod_set_sync() and the hand CRC are deleted (#1620 step 5);
what remains is data:LEN on the receive side and the Python transmit side.
frame_syms survives as a read-only result of the layout.
1. Why¶
A receiver is told frame_syms, a number every caller computes by adding up
field lengths that the description already holds. The inventory (§5) counts
21 such sums in 17 files, and 16 more literals in header examples. They
agree with the transmitter only because each caller wrote the same
arithmetic, and one did not: bench_dsss_burst_receiver_core.c passes
PAYLOAD (32) for a 61-symbol frame, so its benchmark times half a decode
and no burst ever passes its CRC (#1669).
The description is read from both ends everywhere else. The transmitter
spreads it (dp_wfm_dsss_desc_chips), and the scorer checks against it
(dp_wfm_frame_check, Frame.deframe()). The receiver is the one reader
handed a copy of its numbers instead. DsssBurstReceiver also restates the
frame's shape in C: dsss_br_frame_valid assumes sync | payload | CRC-16
(native/src/dsss_burst_receiver/dsss_burst_receiver_core.c:30,42).
2. Use cases — who builds a receiver from a description¶
No CLI or scene face builds a receiver: wfmgen transmits only, and nothing
under native/src/app references either receiver. The faces are C and
Python.
- A test, validator or benchmark (C and Python) builds its stimulus and its receiver from one description, and compares the frame bits with the transmitted frame bit for bit.
- An example or guide reader (Python) decodes a
wfmgencapture. It builds aFramefrom the Field texts it passed towfmgen(Frame(sync=field_bits("1111100110101"), …), Barker-13), thendeframe()s each row and slices the payload atfield_off. - A C integrator receiving a CADU builds the description with
dp_ccsds_tm_frame_describe(), with no inner code (§3.4), and checks each row withdp_wfm_frame_check()anddp_ccsds_tm_frame_ops(). - A stream receiver (C and Python) receives frames whose payload is
data:LEN: a data field ofLENbits with no bits of its own (§8). It compares each payload with the regenerated or recorded source.
What each of them does with push()'s rows is unchanged:
frame[field_off(i):][:field_bits(i)] after deframe() (§10.3).
3. The shape¶
3.1 The constructors¶
Both receivers take the description in place of the sync word and
frame_syms. The spreading stays: the acquisition code, its repetitions and
the data code are how bits become chips, not the frame (§R), and a DSSS
description does not carry the preamble
(native/inc/doppler/wfm/wfm_frame.h:848).
before (deleted in step 5) now
dp_burst_demod_create (data_code, len, dp_burst_demod_create_desc (
spc, …, frame_syms, est_segments) data_code, len, frame, spc, …)
+ dp_burst_demod_set_sync (d, sync, n) (the sync word is the frame's)
dp_dsss_burst_receiver_create (acq, …, dp_dsss_burst_receiver_create_desc (
sync, sync_len, reps, …, frame_syms, acq, …, data, …, frame,
…) reps, …)
BurstDemod(data_code, …, frame_syms=) BurstDemod(data_code, frame, …)
+ set_sync(sync)
DsssBurstReceiver(acq_code, data_code, DsssBurstReceiver(acq_code,
sync, …, frame_syms=) data_code, frame, …)
In C, frame is const wfm_frame_desc_t *. In Python it is a Frame or a
FrameDesc (objects/frame.toml), whose state already holds that
description (dp_frame_state_t.d, native/inc/doppler/frame/frame_core.h).
3.2 What is derived, and from what¶
Each value comes from a function that already exists, and the transmitter reads the same one.
- The sync word is field 0's bits (§3.3). Today it is the
syncargument, orset_sync(). frame_symsisout_bitsfromdp_wfm_frame_desc_layout(), one symbol per bit. Today it is the caller's sum.- The burst length is
dp_wfm_dsss_desc_nchips (d, acq_len, reps, data_len) * spcsamples. Today it is restated atdsss_burst_receiver_core.c:125. frame_validisdp_wfm_frame_desc_crc_ok (d, bits) == 1. Today it is a hand CRC-16 over an assumed layout.
frame_syms counts from the sync word on, sync included. That is what
the demodulator slices (burst_demod_core.c:498 onward), and the header
example asserts bits == frame, sync first. The struct's own comment says
"AFTER the sync word" (burst_demod_core.h:81); that is wrong, and it goes
with the parameter.
3.3 Field 0 is the sync word¶
The demodulator correlates the symbols against the sync word to find the
frame and resolve the BPSK sign, and slices from there. With a description,
the sync word is field 0. That holds for both configurations that exist:
dp_wfm_frame_fixed() without a preamble puts sync first, and
dp_ccsds_tm_frame_describe() puts the marker at field 0 (its fields have no
names). The owner chose this over the field named sync (D1, §10).
Field 0 is refused, and the constructor returns NULL, when it:
- is empty, derived, or a data field: the receiver needs bits it knows;
- is covered by any stage: a stage that rewrites the sync word on the wire leaves the receiver correlating against bits nobody sent;
- is named
preamble.dp_wfm_frame_fixed()with a preamble puts it at field 0 under that name, and a DSSS preamble is not a field of the spread frame. Refused by name, so that mistake is aNULLrather than a receiver that correlates against its own preamble.
3.4 What is refused¶
- A description with an emitting stage (a convolutional code). The code covers the sync word, so frame synchronisation would have to run after the Viterbi (§10.4). It is refused, rather than accepted and never synced. A CADU without the inner code is accepted: the outer code and the randomiser do not cover its marker.
- A description
dp_wfm_frame_desc_layout()refuses. Its reason is the reason; this object does not write a second one.
3.5 The receiver copies what it keeps¶
The receiver copies the description, with field 0's bits, at create. It
never borrows: it is fed across many push() calls, which is why it already
copies the codes (dsss_burst_receiver_core.c:92). A Python Frame may
therefore be dropped after the receiver is built.
4. Goals, and when this is done¶
- No caller computes
frame_symsby hand. Once the parameter is gone this holds by construction: there is nothing to pass, and the compiler and the binding refuse a caller that tries. - The receiver and the transmitter are built from one description, in
every example and test. In C that is
dp_wfm_dsss_desc_chips()and the receiver over onewfm_frame_desc_t. In Python it is aSource(frame=)built from the receiver'sFrame(§8.3). - Byte-identical against the current receivers on the existing burst suite. Same stimulus, same parameters, the old constructor against the new: identical frame bits, LLRs, symbols, events, read-backs and state blob.
- One statement of the sync word.
set_sync()and field 0 could disagree, soset_sync()goes.
4.1 How byte-identity is measured¶
- An A/B C test, while both constructors exist (plan step 2). Every
fixture in
test_burst_demod_core.candtest_dsss_burst_receiver_core.cruns through both constructors, and the outputs are compared withmemcmp: thedemod()/push()bits,llrs(),symbols(),events(), theest_*read-backs andget_state().make testruns it. - The certified reports.
make validate-checkreruns each validator and compares it with the committedresults.md. The reports undersrc/doppler/dsss/tests/validation/burst_demod/and…/dsss_burst_receiver/must pass unedited. - The Python suite and every documented example, through the targets below.
make test
make validate-check
make test-python PYTEST_ARGS='-k "burst or dsss_source"'
make test-examples-python
make test-examples-c
make test-snippets
make test-stubs
The stimulus moves separately (plan step 4). A C test's hand-built burst
becomes dp_wfm_dsss_desc_chips() over the same description, and that PR
asserts once that the two produce the same chips before it deletes the hand
builder. The receiver's identity and the stimulus's identity are then two
checks, not one entangled one.
5. Inventory¶
Measured at 26b57b8c (origin/main) with the commands shown.
docs/c-api/** is generated and excluded, as are CHANGELOG.md and
changelog.d/.
5.1 Hand-computed frame lengths — 21 lines in 17 files¶
git grep -nE '(FRAME_SYMS|\bFRAME\b|frame_syms)\s*(=|\().*\+' \
-- ':!docs/c-api' ':!CHANGELOG.md' ':!changelog.d'
- C tests, benches and the demo, 4:
test_burst_demod_core.c:18,test_dsss_burst_receiver_core.c:30,bench_burst_demod_core.c:47,native/examples/dsss_burst_receiver_demo.c:74. - Python tests, 5:
test_burst_demod.py:25,test_dsss_burst_receiver.py:26,357,wfm/tests/test_dsss_source.py:38,characterization/dsss_burst_receiver/characterize.py:229. - Python validators, 4:
validation/burst_demod/validate.py:147,332,542,validation/dsss_burst_receiver/validate.py:114. - Python benches, 1:
dsss/benchmarks/_burst_stimulus.py:55. - Python examples, 3:
dsss_burst_pipeline_demo.py:535,dsss_burst_receiver_demo.py:110,dsss_realtime_file_demod.py:69. - Docs, 4:
docs/api/python-dsss.md:91,docs/guide/wfmgen/waveforms.md:637,docs/design/dsss-burst-receiver.md:815,1028.
test_dsss_burst_receiver.py:358 then adds the outer code's check symbols
by hand, 32 * 8 * rs_depth: a second derivation of what
dp_wfm_frame_desc_layout() already sums.
The grep finds sums, not every hand-stated length. Passed positionally,
bench_dsss_burst_receiver_core.c:179 states PAYLOAD where the frame is 61
symbols (#1669), and no grep for a sum sees it. Once the parameter is
deleted, neither form can exist.
5.2 Literal lengths in header examples — 16¶
There are 14 Python @code examples: 8 in burst_demod_core.h
(frame_syms=93, once len(frame)) and 6 in dsss_burst_receiver_core.h
(frame_syms=32). Two C @code blocks pass a positional literal:
burst_demod_core.h:35 (256) and dsss_burst_receiver_core.h:17 (61).
Each becomes a Frame in the rewritten example, and make test-stubs runs
the Python ones.
5.3 Constructor calls that pass raw codes and a length — 66¶
git grep -nE '\b(BurstDemod|DsssBurstReceiver)\(' \
-- ':!docs/c-api' ':!native' ':!CHANGELOG.md' ':!changelog.d' ':!*.pyi'
git grep -nE '(BurstDemod|DsssBurstReceiver)\(' -- native/inc
git grep -nE 'dp_(burst_demod|dsss_burst_receiver)_create *\(' \
-- native/tests native/benchmarks native/examples native/validation
- Python outside the headers, 28 in 15 files: 24 in tests, benches, validators and examples, and 4 in docs.
- Python header
@code, 14 in 2 files. - C, 24 in 5 files:
test_burst_demod_core.c16,test_dsss_burst_receiver_core.c5, the two benches 1 each, the demo 1.
There is one more, internal call: DsssBurstReceiver composing BurstDemod
(dsss_burst_receiver_core.c:111). native/validation/ has no caller.
set_sync() is called 23 times in 10 files outside the object itself.
5.4 Frames built by hand on the transmit or scoring side — 27¶
git grep -lE 'BurstDemod|DsssBurstReceiver' -- '*.py' \
| xargs grep -cE 'crc16\('
git grep -cE 'dp_crc16_ccitt *\(' -- native/tests/test_burst_demod_core.c \
native/tests/test_dsss_burst_receiver_core.c \
native/benchmarks/bench_burst_demod_core.c \
native/benchmarks/bench_dsss_burst_receiver_core.c
16 crc16( calls in 8 Python files and 11 dp_crc16_ccitt calls in
4 C files build or check a sync | payload | CRC-16 frame by hand. These
are the sites the "one description" goal moves onto Frame.bits(),
crc_ok() and deframe() in Python, and onto dp_wfm_dsss_desc_chips() in
C.
6. What is deleted¶
- The
frame_symsconstructor parameter on both receivers (burst_demod_core.h:230,dsss_burst_receiver_core.h:221, bothobjects/*.toml), replaced by the layout'sout_bits. sync/sync_lenonDsssBurstReceiver, andBurstDemod.set_sync()(dsss_burst_receiver_core.h:221,objects/burst_demod.toml), replaced by field 0.DSSS_BR_CRC_BITSand the hand CRC indsss_br_frame_valid(dsss_burst_receiver_core.c:30,42-56), replaced bydp_wfm_frame_desc_crc_ok().- The restated burst length (
dsss_burst_receiver_core.c:125), replaced bydp_wfm_dsss_desc_nchips(). frame_bitsbesideframe_symson the receiver's state: two fields holding one value (set atdsss_burst_receiver_core.c:87,123). One field stays.burst_demod_core.c's unusedccsds_tm_frame.hinclude andBURST_DEMOD_CRC_BITS(burst_demod_core.c:3,12).ccsds_tm,convandrsin both receivers'depends_on, and the comment inobjects/burst_demod.toml:24-28claiming the demodulator uses them (D4).- The 21 sums, 16 literals and 66 call sites of §5, and the 27 hand-built frames of §5.4, each replaced by one description per caller.
Neither core calls a coding, framing or sequence function today. This returns nothing:
git grep -nE '\bdp_(rs|conv|ccsds_tm|wfm|pn|gold)_[a-z_]+ *\(' \
-- native/src/burst_demod native/src/dsss_burst_receiver
After this pass they call dp_wfm_ functions, so wfm_frame stays, with the
pn, gold and cvt it needs, and the three coding components go.
Kept: frame_syms as a read-only property on both. A caller still
sizes a buffer by it and strides llrs() by it. It is reported, no longer
told.
7. ccsds_tm_frame_cfg_t is kept¶
1620 asks for it to be re-evaluated, and the receive side gives no reason¶
to delete it.
- Neither receiver touches it.
burst_demod_core.cincludes its header and uses nothing from it; the include goes (§6). - It is what a CADU's description is made from.
dp_ccsds_tm_frame_describe (cfg, frame_len, bits, &d)turns its four knobs into awfm_frame_desc_t, and that is how a C caller builds a receiver for a CADU (§2). The receiver consumes the cfg's output, not the cfg. - It configures the codec's own kernels.
dp_ccsds_tm_frame_layout,_encodeand_decoderead it directly, and the general assembler's output is checked against_encodebyte for byte (the model's falsification target 2). It has users in 6 files outsideccsds_tm, none of them a receiver:
git grep -l ccsds_tm_frame_cfg_t -- native \
':!native/inc/doppler/ccsds_tm' ':!native/src/ccsds_tm'
Whether _encode/_decode should stay as a second implementation of a
chain the general assembler also implements is a question about ccsds_tm,
not about receivers. This pass does not decide it.
8. Interaction with the payload pass (F, #1619)¶
F makes the payload data:LEN: a field with a length and no bits of its
own, filled per frame from a data source (#1650, the design;
#1660, step 2, the field kind).
8.1 The receiver needs a data field's length and nothing else¶
The receiver reads lengths from dp_wfm_frame_desc_layout(), and #1660
makes a data field lay out at its length, so a CRC covers it. frame_syms,
the burst length and the CRC's position therefore come from a data:LEN
description exactly as from a literal one, without a data bit. A generated
payload (pn:LEN:REG) carries its length in wfm_seq_t.len and lays out
the same way. The receiver never renders a payload, so it never needs the
source, the fill or the seed.
data:LEN*REPSis one draw sentREPStimes, and lays out asLEN * REPSbits, like any repeated field.- An idle or padded frame has the same length, by F's rule that every frame keeps one length. The receiver returns it like any other frame; recognising fill is the caller's job, since the caller knows the fill.
- A data field is never the sync word (§3.3). #1660 already refuses
data:LENoutside the payload slot on every transmit face.
8.2 The Python door for a data field¶
FrameDesc.add_field(name, bits) takes bits only (§F.3), and
field_bits("data:1024") raises by design (#1660). The door for a
data:LEN payload is FrameDesc.add_data(name, n), over the C
dp_frame_add_data, which appends the WFM_SEQ_DATA field the Field parser
builds from data:n (D5, #1786). A Source(frame=) holding it
renders the same samples as a scene's "frame" key or wfmgen --frame with
that text, byte for byte, over a multi-frame data source
(test_frame_source.py; test_frame_core.c holds the two fields equal).
The receive half is not built. deframe() and check() read the layout
that build() writes, and build() refuses a description with a data
field, because it materialises one frame and a data field has no bits of its
own. The existing receiver test still works around it with zeros,
"geometry, when the bits arrive later"
(src/doppler/dsss/tests/test_dsss_burst_receiver.py:397); plan step 6
replaces it. What build() and bits() mean for a data description is an
open question, #1789.
8.3 The transmit side in Python takes the description¶
Source takes frame=, a FrameDesc or a Frame
(#1703, the first half
of #1617), and data= with data_len=, a payload drawn from a
data source (F step 6,
#1721). So a Python test
builds the transmitter from the receiver's Frame directly. A data:LEN
payload renders through data= on the common frame ([preamble | data]), or
through a frame= description carrying a data:LEN field from
FrameDesc.add_data (§8.2), which is also how a sync word and a CRC are
given: a source carries neither (#1617). The Python half of goal 2 waits on moving the
existing tests and examples (step 7), not on #1617, whose
remainder moves sync and crc off wfm_source_t.
8.4 Dependencies¶
- Plan steps 2 to 5 need nothing from F.
- Step 6 builds on #1660 (
WFM_SEQ_DATA), which is merged. - Step 7 builds on
Source(frame=)and F step 6 (Source(data=)), both built, and on step 6 for adata:LENdescription.
9. Unknowns, stated before they are measured¶
- U1. Whether jm binds a cross-module
FrameDescargument to aconst wfm_frame_desc_t *. An init paramobject = "<comp>"resolves to a capsule the producing component publishes (resolve_object_refin jm's_config.py, gh-1224;capsule_type, gh-1235). The frame is in thewfmmodule and the receivers indsss.objects/frame.tomlpublishes no capsule today. Whether one can reach a member (expr = "&self->handle->d") and be accepted from aFrameDescview instance is not measured. It is measured on a fresh jm scaffold, not in this tree. Fallback: the capsule carries the defaultdp_frame_state_t *, and each receiver gains a one-line C create function that passes&f->dto the C constructor. The C face still takes the description, and no binding logic is written. - U2. Whether an unbuilt
FrameDescis safe to pass. The receiver lays the description out itself, sobuild()should not matter. Fallback: refuse an unbuilt description, namingbuild(). - U3. Whether the state blob moves. The blob packs running state; the
sync word and the length are configuration that
create()restores, so it should not change. The A/B test comparesget_state()bytes. Fallback: if a field moves, bumpDSSS_BURST_RECEIVER_STATE_VERSION(7 today), and claim byte-identity for the outputs, not the blob. - U4. Whether any caller relies on
frame_symsdiffering from the frame. TheDsssBurstReceiverheader examples passframe_syms=32against a 13-bit sync with no frame behind it, and a refusal test passesPAYLOAD(test_burst_demod_core.c:209). Found during migration. Fallback: such a caller describes the frame it actually slices.
10. Decided¶
The owner decided all five in review of #1673 on 2026-09-30, each as recommended, with one addition to D1. The trade each one accepts is kept beside it.
- D1. The sync word is field 0. It holds for both configurations that
exist, and CCSDS's fields have no names. Addition: a field 0 named
preambleis refused by name (§3.3). Trade: a leading field of any other name is taken as the sync word, so a mislabelled description is a caller error. Not chosen: the field namedsync, which would also refuse a CADU, whose field 0 has no name. - D2. One constructor, taking the description.
Frame(sync=field_bits(…), payload=…, crc="crc16")is the bit-array door #1620 names, so a second flavour takingsync=andframe_syms=would be two spellings of one receiver. Trade: every caller of §5.3 changes, and it ships as a breaking change with a changelog entry. - D3. A description with no CRC is not valid, and the capture releases
the window. That is today's behaviour (
dsss_br_frame_validreturns 0 belowsync_len + 16bits), so it is byte-identical. Trade: an unprotected link never claims a span, so a decoy after it is not suppressed either. Not chosen: valid on demodulation, the rule before #1181. - D4. The verdict reads the CRC only, through
dp_wfm_frame_desc_crc_ok()inwfm_frame, soccsds_tm,convandrsleave the link lines. Trade: a frame with an outer code and no CRC is judged not valid (D3), though R-S could say it decoded. Not chosen:dp_wfm_frame_check()with the CCSDS ops, which would put a code's verdict back in the receiver, where §10 took it out. - D5. The Python door for
data:LENisFrameDesc.add_data(name, n). Trade: one more method on the frame object. Not chosen: Field text inadd_field, which reopens the "an object takes bits" rule of §F.3.
11. Plan — one PR per step¶
- This page. Gate: the owner's review.
- C: the description constructor, beside the old one, on both
receivers, with the derivations of §3.2 and the refusals of §3.3 and
§3.4. It carries the A/B test of §4.1 over every fixture, and a C test
per refusal, the field 0 named
preambleamong them. Each is proven by sabotage:out_bitsoff by one, a burst length off by a chip, the CRC check skipped, thepreamblerefusal removed. Gate:make test. (#1669, the benchmark's wrong length, is fixed on its own first.) - Python: the frame publishes a capsule, and the receivers take
object = "frame". U1 is measured first, on a fresh scaffold. Gate:make drift-check,make test-stubs, and the Python A/B over the same fixtures. - Callers move to one description. C tests, benches and the demo build
their bursts with
dp_wfm_dsss_desc_chips(), asserting once that the chips match the hand builder, which is then deleted. Python tests, validators, benches, examples and doc fences build aFrame. Gate: the targets of §4.1. - Delete the old constructors,
set_sync(), the hand CRC, the dead include, the three link-line entries and the duplicate field, and rewrite the header examples on aFrame. The A/B test goes with the old constructor; from here the validators' reports carry byte-identity. Gate:make drift-check,make test-stubs,make validate-check,make lint. data:LENon the receive side, after #1660:dp_frame_add_dataandFrameDesc.add_data(D5), and a receiver test over adata:LENdescription whose frames are compared with the source. Gate:make test,make test-python.- The Python transmit side from the description, over
Source(frame=)andSource(data=), which exist: the remaining Python tests and examples buildSource(frame=)from the receiver'sFrame. Gate: the targets of §4.1.
Each code PR carries a changelog.d/ fragment and Refs #1620; step 7
closes it.