A Frame as a Description¶
A frame is a list of fields that appear on the wire and a list of stages that transform them, each stage carrying the span it covers. This page is where that model is held once, generally, so a standard's framing is a configuration a caller writes rather than a framer somebody adds.
CCSDS is the example on this page, not the implementation underneath it.
It is here because its facts are published and because it is the one
configuration that exercises cover asymmetrically. Every claim below is
about the general model; where CCSDS still shapes the generic path, that is
recorded as a defect with a gate on it, not as the design.
This page states what is. What was measured to get here — the survey that counted seven spellings of a field, the prototype that proved the grammar lossless — is on the measurement record, under the same section letters.
Why — a general primitive plus a configuration¶
Every other standard-specific thing in doppler decomposed the same way.
conv_core.h owns convolutional codes as a description and dp_CCSDS_TM_CONV is
four numbers that configure it. rs_core.h owns any Reed-Solomon code over
any field and dp_CCSDS_TM_RS is five, and it states the principle outright —
"a standard picking a code is not the same fact as the code existing".
Framing was the layer where that had not happened. What existed instead was a
closed struct of four named fields
([preamble × reps | sync | payload | crc16]) whose constructor had grown to
38 positional arguments, because a fixed field list forces every field's
every parameter into the signature; and a second, disjoint assembler for
CCSDS that shared nothing with it and could not express it. Neither could
express the other, and a user wanting a frame doppler had not anticipated had
no move except to add a third framer.
The description fixed the frame, and the 38 arguments survived it. They were
never a framing problem. Each cluster of twelve — preamble_kind,
preamble, preamble_nbits, preamble_poly, … — is one field spelled
out parameter by parameter, because a field had no value of its own to pass.
The same cluster was spelled again as a CLI triple (--sync, --sync-hex,
--sync-gen), a JSON literal key plus a *_gen object, and a fifteen-argument
add_field. The missing piece is the Field as a value every face
passes: literal bits as data, and one text form — parsed by one C function —
for everything written as text (§F).
Use cases — who calls this, and what they do with the answer¶
wfmgen, generating a test waveform. Framing is an axis there rather than a waveform type:--acq-code/--syncdescribe a frame, each taking one Field (--sync 0x1ACFFC1D,--acq-code 'pn:1023:10*4'), and--datasays where the payload comes from. Coding is the next stage on that same axis — randomise this, Reed-Solomon it at depth 5, prepend this marker, convolutionally code the lot, over bits the caller supplied.- The scoring path, measuring a capture. The description is read from both ends, which is what makes a truth-free frame error rate possible. A frame with an outer code has a strictly better error detector than CRC-16: the check reports how much repair it took, so a margin being spent is visible long before it is lost.
- A Python caller analysing a capture.
frame_core.hexists because only C could hold a description andber's frame meter had no way to be fed from the language captures are analysed in. A user-defined field list is what lets that object describe a frame doppler has never seen — including a CCSDS CADU, withoutccsds_tmgrowing a binding it should not have. - A mission that is not CCSDS. The point of the generalization.
The model¶
Two lists, ordered independently, because order and coverage are different questions:
A field is a run of bits that appears on the wire. It is either
caller-supplied — a literal array, or a generated sequence — or derived,
meaning a stage produces it. derived_by names the producing stage plus
one, so a zero-initialised field is caller-supplied rather than silently the
output of stage 0.
A stage is a transform plus the span it covers, declared as a contiguous field range rather than inherited as "everything so far".
cover is the load-bearing field, and the header says why¶
The single most important constraint on this design is written down in
ccsds_tm_frame.h as a prediction of how it fails:
An order is the representation that cannot express this: any chain of optional transforms applied to "the frame" is right at three stage boundaries and wrong at the fourth, and wrong in the direction that still encodes, still decodes against itself, and syncs to nothing.
A generic frame with user-defined fields and a list of optional transforms is
precisely that chain — unless every stage carries an explicit cover.
Without it the basic case is the bug.
Two consequences worth stating separately:
- Order and coverage are independent axes. In CCSDS the ASM is inserted
third and is covered by the stage applied fourth. A single ordered list
cannot say that. The field list gives position; each stage's
covergives reach; neither is derivable from the other. - A stage may change length, and there are exactly two ways it can. A
Reed-Solomon code adds check symbols and a CRC adds 16 bits — both appear
on the wire inside the same unit, so both are derived fields and the
layout is a running sum. A convolutional code is different in kind: it
consumes the assembled frame and emits a different stream, which is
why the layout reports
frame_bitsandout_bitsas two numbers.
One CADU, drawn. The top row is the field list, ordered by position; each row
beneath it is one stage's cover, ordered by application. Block widths are
schematic — a 32-bit marker cannot be drawn to scale beside an 8920-bit
payload — so the exact spans are in the labels.
block-beta
columns 12
ASM["ASM<br/>0 – 32"]
PAY["payload<br/>32 – 8952"]:8
PAR["rs_parity<br/>8952 – 10232"]:3
space
RS["RS 255,223 depth 5 — derives rs_parity, in unit"]:11
space
RND["randomise — in unit, no length change"]:11
CONV["conv K=7 r=1/2 — emits 20464 channel symbols"]:12
Read the three cover rows, because they are the design in one picture. RS
and RANDOMISE begin at bit 32 — after the marker. CONV begins at bit 0
and takes the marker in. That asymmetry is the entire reason a stage carries
an explicit cover: a chain would be right for two of these three and wrong
for the third, in the direction that still encodes, still decodes against
itself, and syncs to nothing.
The rs_parity block is on the wire and is therefore a field, not a
length rule hidden inside the stage that produces it — which is what lets the
layout be a plain running sum over the top row.
The packed/unpacked boundary belongs to the assembler¶
Reed-Solomon wants packed octets, because an R-S symbol is a byte; a randomiser and a convolutional coder want unpacked bits, because they are bit machines. Both are right, so the conversion belongs in exactly one place rather than hidden inside a kernel that then works for one caller. The general assembler owns it, per stage, and a stage declares which representation it consumes.
F. The Field — one text form¶
A Field is wfm_field_t given a value a caller can pass: where its bits
come from, how long it is, and how many times it repeats. It is the unit a
caller hands to every face — the CLI, a JSON scene, Python and C.
A Field is data or a description, and only a description must be text.
A literal field is data — a payload is often thousands of bits, and in
Python and C its natural form is an array, which is passed as one and never
parsed. A generated field (pn, gold, dotted) is a description of
bits nobody has written down, so text is its only form. The grammar below is
therefore the one spelling for everything that is text: every generated
field, and a literal on the faces that are text themselves (the CLI, JSON) or
written short and inline. It is not a requirement that payload bits become a
string.
F.1 The grammar¶
field := seq [ "*" REPS ] REPS >= 1, default 1
seq := bin | hex | pn | gold | dotted | data
bin := [01]+ nothing else is allowed
hex := "0x" [0-9A-Fa-f]+ 4 bits per digit, MSB first
pn := "pn:" LEN ":" REG [":" SEED [":" POLY]] [":" LFSR]
gold := "gold:" LEN ":" REG ":" TA ":" SA ":" TB ":" SB
dotted := "dotted:" LEN
data := "data:" LEN LEN bits per frame from the data source (§F.5)
LFSR := "galois" | "fibonacci" default galois
Numbers take decimal or 0x hex, and a token must be consumed whole. LEN
is the output length — 0 only as a data source, where it means unbounded (§F.5) — and REG the register width, 1..64; they are named
apart for the reason the field kinds
give. LEN * REPS is at most WFM_FIELD_MAX_BITS
(the limits), and a longer Field is refused naming
that number. A 0/1 string with any other character in it is refused, never
filtered — a typo that quietly shortens a sync word is the failure this
closes.
| writes as | means |
|---|---|
11101011 |
eight literal bits |
0x1ACFFC1D |
the 32-bit CCSDS marker |
pn:1023:10 |
a 1023-bit m-sequence, 10-bit Galois register |
pn:64:7:0x5:0:fibonacci |
64 bits, seed 5, default poly, Fibonacci |
gold:64:10:0x3A6:0x15E:0x237:0x49 |
a Gold code from two registers |
dotted:16 |
1010…, 16 bits |
pn:31:5*4 |
a 31-chip code, sent four times (a preamble) |
data:1024 |
a payload: 1024 bits per frame, drawn from the data source (§F.5) |
reps belongs to the Field, because wfm_field_t carries it: a preamble
is not a fifth kind but any of the four repeated. * needs quoting in a
shell (--acq-code 'pn:31:5*4'); the docs always quote it.
A repeat is invariant: it sends the same bits again, whatever the field
is. It never draws new data. data:LEN*3 sends each payload chunk three
times — simple time diversity, for resilience — and works over a stream
precisely because nothing is re-read. The same meaning holds one level up:
--repeat sends the rendered burst again, identical data and all.
F.2 One parser, one printer¶
dp_wfm_field_parse(spec, &field, &owned, &why) is the only place the grammar
is read, and dp_wfm_field_format(&field, buf, cap) the only place it is
written. Every face calls them; none restates the grammar.
- Ownership follows the existing rule — the caller owns literal bits, the
description borrows them.
ownedreceives the allocated array for a literal andNULLfor a generated kind. - A refusal names its cause. The return is
DP_OK,DP_ERR_INVALIDorDP_ERR_MEMORY(the convention), and the optionalwhyreceives a static sentence, the shapedp_wfm_compose_from_json_why()already uses for a refused frame. The CLI prefixes the flag and the JSON reader the key. - The printed form is canonical and round-trips: hex when the length is a
multiple of four, binary otherwise; a generator prints only what differs
from its defaults.
parse(format(f)) == ffor every Field.
F.3 Every face takes a Field, in the form natural to it¶
| face | a literal field | a generated field |
|---|---|---|
| CLI | the text form; a payload's bits come from its data source (§F.5) | the text form |
| JSON | the text form (hex when the length allows, so a long payload is ¼ the characters) | the text form |
| a carried frame | {"name", "spec"} as text, or {"name", "derived_by", "bits"} for a derived field |
{"name", "spec"} |
| Python | an array, as <field>= |
field_bits(text), then as a literal |
| C | a (bits, len) pair |
dp_wfm_field_parse() |
One flag and one JSON key per field on the text faces — --sync,
--acq-code, --data-code — and one for the payload's source, --data or --data-from-file
(§F.5).
An object takes bits; module helpers make them. Frame and FrameDesc
take each field as an unpacked uint8 array and nothing else:
Frame(preamble=, sync=, payload=, crc=), each field an empty-by-default
keyword (omitted, it reaches C as NULL, 0), and
FrameDesc.add_field(name, bits). A composer source's bit fields (Synth /
Segment data=, fill=, acq_code=, data_code=) follow the
same rule: they take bits, and a str is refused with a reason naming
field_bits(). A source has no payload=: it, bits= and pattern= are
refused, naming data= (§F.5); nor has it sync= or crc=, which are
refused with any value, naming frame= (#1617). Every other form reaches them through a module-level helper
that returns bits:
| helper | from | over |
|---|---|---|
field_bits(text) |
the Field text form (§F.1) | dp_wfm_field_parse(), the one parser |
cvt.hex_to_bin(text) |
hex | dp_hex_to_bin() (exists) |
cvt.bytes_to_bin(b) |
packed octets, MSB first (§F.4) | the one unpacking primitive |
Frame(sync=field_bits("0xFAF320"), payload=field_bits("pn:1024:10")). So
the constructor has one shape, jm needs no parameter that takes "an array or
a string" (it has none: record §F.5),
and there is no dispatch and no "both set" case to refuse. The text faces
keep their grammar because they call the same parser before they build.
What this gives up: an object built from bits does not know a field was
generated. Its layout reports each field's length and bits, not
pn:1024:10. The faces that record the text form (the CLI, JSON, a carried
frame, --record) parse it in C before building, so nothing reproducible is
lost. A Python caller who needs to read a frame's specs back is the one case
this shape does not serve, and that is added when someone needs it. There is
no separate Field object: a frame's layout already reports its fields.
A derived field has no text form, and that is correct: nobody writes a
CRC trailer's bits. It is declared by the stage that produces it
(add_derived) and appears in a carried frame only as its name and length.
The spellings this replaces are refused, not aliased, each with a message
naming its replacement: --X-hex, --X-gen, --acq-reps, the five payload
flags --bits, --bits-hex, --bits-file, --payload-gen and
--payload-len (now --data, or --data-from-file for a file), and the JSON keys *_gen,
pattern, acq_reps, lit and gen. Two spellings of one thing is the
condition this section exists to end.
F.4 Where literal bits come from¶
A literal field's bits arrive in one of two shapes, and the difference is the whole of what can go wrong:
| source | shape | on which face |
|---|---|---|
| an array | unpacked — one bit per element, 0 or 1 |
Python, C |
| a binary file | packed octets, MSB first | CLI --data-from-file PATH; Python via cvt |
| a byte stream — stdin, a pipe | packed octets, MSB first | CLI --data-from-file -; Python via cvt |
One primitive unpacks. Packed octets become bits in exactly one place, a
bytes_to_bin beside hex_to_bin in cvt — the same conversion a hex
string already is, four bits at a time instead of eight. Today that
conversion is private to wfmgen.c, so the file path works from the CLI and
from nowhere else.
A Python bytes object is unpacked, because that is what the composer's
bit_pattern coercion already takes it to mean (b"\x01\x00\x01" is three
bits). Packed data is therefore never passed as bytes and hoped about: it
goes through cvt.bytes_to_bin first, by name. A value whose meaning depends
on which face received it is the defect this section exists to prevent.
A constant field is read once; a payload is drawn from, frame by frame. A preamble, a sync word or a spreading code is the same in every frame, so its source is read once into the field. A payload is not — see §F.5.
F.5 The payload is a data source¶
Two use cases decide the shape, and neither is served by a payload that is one fixed block:
- A finite burst, all data known a priori — a file, an array, a message: split across as many frames as it takes, then the burst ends.
- An infinite stream — a pipe or a live source: chunked into frames for as long as it runs.
A payload that is one block cycled to fill the run, so that every
frame carries the same bits, serves neither. Both use cases need the same
two things instead: the frame declares how many bits a frame carries, and
a data source feeds it. There is no cycling mode: a finite burst runs
once, and more than once is --repeat.
The payload is a Field of kind data. data:LEN means LEN bits per
frame, drawn from the frame's data source. It is the one field kind whose
bits are not in the description, which is exactly what a payload is, and it
keeps the frame a description: its layout, its length and every stage's
cover are known without the data.
The data source is declared once, by exactly one of two flags.
--data takes a Field, and is the flag continuous DSSS already uses for the
same question. --data-from-file takes a path, or - for stdin. Both
given is refused, naming the pair:
| source | a | frames |
|---|---|---|
--data-from-file PATH |
finite source | ceil(bits / LEN), then the burst ends |
--data, a literal Field or a generated one with LEN > 0 |
finite source | the same |
--data-from-file - |
stream — stdin | until the input ends |
--data, a generated Field, pn:0:REG[:SEED] |
stream, seeded | until --count; a receiver regenerates it (#1717) |
--code-only (not a source: --data none is refused) |
no data | code only — continuous DSSS, unchanged |
A file and stdin carry packed octets, unpacked by the one primitive of §F.4.
The last partial chunk is padded with a declared fill. A finite source
that does not divide into LEN-bit chunks fills its last frame from
--fill, itself a Field (0x00, pn:…), so every frame keeps one length
and a receiver's frame length never changes. Without a declared fill the run
is refused, naming the remainder: a pad nobody chose is data nobody sent.
How many fill bits the last frame carries is recorded (--record, the SigMF
metadata) as truth for scoring, not as a signal to the receiver.
A stream that runs dry sends idle frames. Paced (--realtime), an
underrun emits frames whose payload is the fill, so the carrier and the frame
timing never break and a receiver stays locked; each is counted and reported.
When the input ends — a closed pipe, not a pause — the last frame is padded
by the rule above and the run ends cleanly.
A receiver is designed in the know, so the frame carries no signalling of
its own. A receiver is built against the description it expects: it knows
the payload is LEN bits at a known offset, and that an idle frame's payload
is the declared fill. If a link wants to say more in band — a length, a
frame count, an idle flag — that is a field at a known position, declared
in the description like any other, and the receiver reads bits N:M because
it was designed to. The generator does not interpret it. A field whose
value depends on the data (a per-frame length) would be a derived field and
so a stage kind; none is added until a link needs one.
Repeating over a data source follows the one rule of §F.1: the same bits
again. data:LEN*N repeats each chunk in its frame; --repeat repeats a
finite burst. An infinite stream has no end to repeat, so --repeat with a
stream source is refused rather than given a second meaning.
What a record can replay. A finite source is replayed byte for byte: its
bits (or its file and a hash of it) are in the record. A stream is not — the
record holds its description (--data-from-file -, pn:0:23:0x5), and a generated source replays because
it is a function of its seed; stdin replays only if the same bytes are fed
again.
This is designed here so that Field and the one frame are shaped for it; it is built as its own pass through the lifecycle, after the Field and the surface table land (#853). That pass is The Payload as a Data Source: the rules this section leaves open, what it deletes, and its unknowns.
R. One frame representation¶
The description is the frame, and there is no other way to say one. A frame that could be said two ways needed a compiler between them, a rule refusing both at once, and a record that had to choose which to write; with one way, all three go.
| representation | fate |
|---|---|
wfm_frame_desc_t |
the frame: fields + stages |
a source's frame |
the description, carried as given |
the flat framing fields on a wfm_source_t — rs_depth, randomise, attach_asm, convolutional, interleave_* |
deleted. sync and crc stay as the common frame's fields until #1617; the payload is a data source (§F.5) |
the CLI coding flags --asm, --rs-depth, --randomise, --conv, --interleave, --interleave-unit |
deleted; a coded frame is a --frame file |
| the by-name compiler that built a source's frame from those fields | deleted with what it compiled; a source's frame is its description, or dp_wfm_frame_fixed() of the common fields |
| the four-field frame struct, its named layout and its describe bridge; the four-field DSSS helpers and the synth's four-field DSSS entry | deleted; their callers read the description |
ccsds_tm_frame_spec_t + dp_ccsds_tm_frame_desc_of() |
deleted: literal-only, and a second derivation of covers |
ccsds_tm_frame_cfg_t |
kept — it configures the codec's kernels, not a generated frame |
Frame's 38 arguments, add_field's 15, add_hex, add_value |
replaced by one bit array each; text through field_bits |
What a source still carries, and why it is not framing. A DSSS source's acquisition code and data code are how its bits become chips — the spreading, not the frame — so they stay on the source as Fields, beside the frame they spread and the data source that fills it (§F.5). Which fields a spreader sends as the unspread preamble is the DSSS path's rule to state when it moves onto the description; the unknowns carry it.
The CLI takes a frame two ways, and they are not two representations.
--frame FILE reads a description — the only way to add a coding stage, and
how a CCSDS CADU is written. For the common frame, --acq-code, --sync,
--crc and --data-len are the fields of one fixed layout,
[preamble × reps | sync | data | crc], which one C function builds into a
description before anything else sees it. Both at once are refused. Nothing
downstream of the CLI knows which was used: the record stores the
description.
The four enumerations — three closed, one open¶
native/inc/doppler/wfm/wfm_frame.h is the SSOT for every name here; this page owns
the reasoning, not the declarations. The fourth enumeration being open is what
decides whether the generalization is real: a set only doppler can extend is a
third framer with extra steps.
1. Fields — where a run of bits comes from¶
A field's kind answers one question only: who produces the bits.
| kind | bits come from | parameters |
|---|---|---|
LITERAL |
a 0/1 array the caller owns | the array |
PN |
dp_pn_create() — one LFSR |
poly, seed, reg_bits, lfsr |
GOLD |
dp_gold_create() — two LFSRs |
taps_a, seed_a, taps_b, seed_b, reg_bits |
DOTTED |
alternating 1010… |
none — a line at Rs/2 to settle on |
Two properties sit across the kinds rather than inside them, which is what
keeps the table four rows long instead of eight: reps repeats the run
verbatim, so a preamble is not a fifth kind but any of the four repeated; and
derived_by names the stage that produces the field instead of the
caller, which is what makes a CRC trailer and a block of check symbols the
same concept.
For the generated kinds, len is the output length and reg_bits the
register width. They are named apart because conflating them is easy and
costly: a PN sequence's period is 2^reg_bits - 1 and has nothing to do with
how many bits the field wants.
This enumeration is closed, and unlike stages it has no extension point —
the generator is a switch in wfm_frame.c, so a new sequence kind is a pull
request against the header. That asymmetry with stages is deliberate but it is
worth naming: sequences are a small, bounded set of ways to make bits;
transforms are not.
2. Stages — what transforms the fields it covers¶
| stage | does | length effect | derives | kernel from |
|---|---|---|---|---|
CRC16 |
dp_crc16_ccitt over the head |
+16 bits, in unit | its trailer | built in |
INTERLEAVE |
permute depth × unit_bits |
none | nothing | built in |
RS |
a Reed-Solomon code, interleaved | + check symbols, in unit | its parity | a table |
RANDOMISE |
XOR a pseudo-random sequence | none | nothing | a table |
CONV |
a convolutional code | × emit_num / emit_den, new stream |
nothing | a table |
The length effect column splits the table three ways rather than two.
CRC16 and RS add bits that appear on the wire inside the same frame,
so what they add is a derived field and the layout simply sums. INTERLEAVE
and RANDOMISE change no length at all. CONV alone consumes the assembled
frame and emits a different stream.
Only two stages are built in, and the rule for which is a layering fact, not
a judgement of importance. A CRC and a block interleaver need no
configuration a standard has to supply. An outer code, a randomiser and an
inner code are each configured by the component that owns them, and
ccsds_tm must depend on wfm_frame.h to describe a CADU — so if
wfm_frame.c called ccsds_tm's kernels the two would form a cycle. The
kernels travel in the other direction instead, as a table.
The three names RS, RANDOMISE and CONV are CCSDS's three stages, and
that is the one place the general enumeration is shaped by the standard. They
carry no kernel here — only a reserved number and a generic description.
3. What a stage kind must implement¶
Three slots, and the arithmetic for a kind lives in exactly one place:
in_unit— rewritenbits in place, where the span lies. Serves a CRC, an outer code and a randomiser alike, because of the invariant below: the op receives the whole cover, reads the information at its head and writes whatever it derives into its tail.emit— consume the assembled frame and write a different stream. Exactly one ofin_unitandemitis set.outmay overlapin: the frame is assembled in the tail of the caller's buffer and the stream written from its head, so an implementation must read each input bit before writing the output that displaces it.undo— the receive side. Optional, and its absence is information. An inner code has none by design: it is streaming, emits decisions late, and is undone before frame synchronisation, so a frame checker never sees channel symbols. Such a stage is reported not checked rather than passed — different answers, and a receiver that conflated them would call an unverified frame good.
One report shape for every checking stage, rather than a struct per code, is
what lets a caller compare them: units / ok / corrected / symbols.
ok == units with a rising symbols is margin being spent.
4. The open end — how the set grows, and how far¶
A stage's kind is an open uint32_t, not the enumeration above. Kernels
come from wfm_frame_ops_t, a table looked up by kind that extends the
built-ins rather than replacing them — the caller's table is searched first,
so a component supplying an outer code does not have to restate the CRC.
WFM_STAGE_USER is the first value reserved for callers, and doppler never
allocates at or above it.
That is what makes the answer to "a mission that is not CCSDS" a configuration rather than a pull request against a header. Turbo, LDPC and a channel interleaver are shaped like rows in that table rather than like a third framer, which is the test this enumeration has to pass.
The seam is C-level, and that limit is real. A Python caller can declare
a WFM_STAGE_USER kind and cannot supply its kernel, so build() refuses:
kernels stay in C by decision
(#1125,
#1140). From Python the
reachable set is therefore the built-ins plus whatever table the C entry point
wires in — which today is CCSDS's three. The certification report records this
as a gap rather than a feature; see
validation/wfm_frame/results.md
finding F2. A page that claimed the Python face was open would be describing
the C header, not the object.
The invariants, which are refusals¶
Each is enforced where the description is read, not documented and hoped for. All exist because the failure mode of this design is a frame that still assembles, still decodes against itself, and syncs to nothing.
| invariant | why | enforced in |
|---|---|---|
| A stage's cover is what it OCCUPIES on the wire — information and derived check symbols together; what it reads is the cover minus what it derives | both from one declaration, so the two cannot disagree | wfm_frame.h (the struct's contract) |
| A derived field must be the LAST field of its producing stage's cover | lets one in_unit signature serve every in-place stage; the alternative is parity in the middle of the data |
wfm_frame.c:156 |
| At most one emitting stage, and it must cover the whole frame | two streams is not a frame; a partial emit has no defined wire order | wfm_frame.c:206-215 |
| A stage whose kind is in neither table is a refusal, never a skip | a stage that quietly did not run is the exact failure this design prevents; it is pre-flighted before any byte is written | wfm_frame.c:451-458 |
| A stage covering no caller-supplied bits derives nothing | a CRC over an empty payload protects nothing and is not emitted | wfm_frame.c:162 |
A Field is refused, never repaired — a stray character, a zero length, a register outside 1..64 |
a typo that silently shortens a sync word still assembles and syncs to nothing | dp_wfm_field_parse (§F; lands with #853) |
A CLI frame is said one way — --frame and the fixed-layout field flags together are refused |
two descriptions of one frame is a render that can disagree with its own length check | wfmgen.c (§R; lands with #853) |
The limits, as numbers¶
| limit | value | what it bounds |
|---|---|---|
WFM_FRAME_MAX_FIELDS |
16 | fields per description |
WFM_FRAME_MAX_STAGES |
8 | stages per description |
WFM_FRAME_CRC_BITS |
16 | the only CRC width |
WFM_FRAME_NAME_MAX |
16 | field-name bytes, NUL included |
WFM_FIELD_MAX_BITS |
261120 | bits in one Field, LEN * REPS |
The field and stage bounds were raised against a measurement rather than a feeling: the deepest description doppler builds is six fields and five stages, and the descriptor is a POD carried by value, so the cost is bytes on a stack frame.
The Field bound is derived from the stages, not picked. The longest frame a
shipped stage accepts is the Reed-Solomon codeblock at its deepest
interleaving: 255 symbols × 8 bits × depth 8 = 16320 bits. The margin is
16, the smallest power of two that still admits a Field one whole period
of the default CCSDS randomiser long (131071 bits; a margin of 8 gives 130560,
511 short). A Field is a finite run of a frame, so anything longer is a
stream, which is the data source's job (§F.5). The bound is what lets the
parser refuse a huge LEN before anything allocates the caller's number
(#1622), and
ccsds_tm/desc.c re-derives it from the R-S constants at compile time, so a
deeper codeblock fails the build instead of outgrowing it. Two further limits are structural rather than numeric: a cover is
contiguous, and a stage derives at most one field.
Two configurations, and they are the design's falsification targets¶
A generalization that cannot express what already ships is not a generalization. Both fall out as data, with no special case in the assembler.
1. doppler's existing frame¶
fields: [ preamble × reps | sync | payload | crc derived_by=crc16 ]
stages: [ crc16 cover={payload} ]
It also fixes a thing the closed struct stated in prose and could not express
— the CRC covers the payload alone, and the preamble sits outside the group
— because cover says so instead of a comment saying so.
2. one CCSDS CADU¶
fields: [ ASM | payload | rs_parity derived_by=outer ]
stages: [ rs(255,223,E=16) depth=I cover={payload, rs_parity}
randomise cover={payload, rs_parity}
conv(K=7, r=1/2) cover={ASM, payload, rs_parity}
emits_unit: b -> 2b ]
A stage's free parameter carries its configuration: rs's depth is the
interleaving depth, and randomise's selects which generator, since
131.0-B-6 specifies two and only the matching receiver derandomises a given
waveform. That is deliberately not something a kernel picks for itself.
The coverage asymmetry the whole ccsds_tm slice exists to get right — outer
code no, randomiser no, inner code yes — stops being three hand-written
struct members and becomes a field range a user can write. The falsification
is the strongest available: the general assembler's output equals
dp_ccsds_tm_frame_encode()'s byte for byte.
Both targets are met.
Where CCSDS still shapes the generic path¶
The layering holds in one direction and the code says so: ccsds_tm depends
on wfm/wfm_frame.h, which knows nothing about CCSDS — no include, no
constant, no default, no kernel. The direction into the descriptor from
the layers above it is now clean as well.
Where a caller meets CCSDS¶
Three components include ccsds_tm to compose it — frame_core.c,
wfm_synth_bridge.c and burst_demod_core.c — in the acyclic direction the
design intends; each is the place a caller is meant to meet the standard's
kernels. The marker a mission copies out of the Blue Book is
doppler.ccsds.asm_bits(), over a ccsds component that sits beside the
general layer, not under it. Its rule is narrow, and stated on the component's
own header so it travels with the code: a literal a mission copies out of
the Blue Book belongs there; a transform does not. How each of these was
settled is on the measurement record.
What is deliberately not extraction¶
- The Python door is the right shape.
ccsds_tmhas no binding and is not getting one, so a caller meets the outer code, the randomiser and the inner code by describing a CADU — three fields and three covers. That class is not a CCSDS entry point wearing a general name; it is the general door, and CCSDS being reachable through it is the design working. - CCSDS stays, as the example. It is falsification target 2 for a reason:
its facts are published, and it is the one configuration that exercises
coverasymmetrically. Extraction moves it from under the general layer to beside it. Nothing about it is deleted.
The gate¶
wfm/wfm_frame.h and wfm_frame.c contain no ccsds_tm include and call
no ccsds_tm kernel. make ccsds-isolation-check enforces exactly that,
inside make lint. It reads the two files the primitive is made of, strips
comments — the header explains the layering by naming the component on the
other side of it — and fails on an include, on a call reached through a
forward declaration, or on reading nothing at all.
That is the rule the header states about itself, and no more: a consumer
composing the two is the design working. frame -> ccsds_tm -> wfm_frame
is acyclic and deliberate.
What a cycle would actually cost is not a link error. It is a general layer that quietly acquires a standard's defaults, and a build that starts depending on link order — a failure that arrives late and reads as something else. Cheap and exact is the right shape for that.
Unknowns¶
The numbers and shapes this design still does not know, each stated before it is measured:
- Contiguous vs bitmask
cover. Every configuration so far covers a contiguous field range. A standard that interleaves coverage would need a mask, and nothing has demanded one. - Whether 16 fields and 8 stages are the right bounds. They were sized against the deepest description that exists, not against a class of them.
- ~~The migration cost of the DSSS path.~~ Measured when it moved
(item 11): nothing new was needed. The spreader already took a
description (
dp_wfm_dsss_desc_chips), so the four-field form was a wrapper, and every caller now buildsdp_wfm_frame_fixed()or carries its own. The unspread preamble stays outside the description, as §R states. Every replacement was byte-identical. - Whether
*Nsurvives real use. It needs shell quoting. Today a repetition count appears 8 times across the docs and examples, always on the acquisition code and always4, and 4 times in the flag-matrix golden — rare enough that quoting is cheap, common enough that a separate key would be a second spelling of one field. Revisit if a user trips on it. - The three jm scaffold checks are measured (record §F.5). An array-or-string parameter does not exist, so an object takes bits and module helpers convert to them (§F.3). An omittable parameter does exist, for arrays and strings. The surface table's extra keys survive a re-save.
Deliberately not in scope¶
- Virtual fill. A frame off the
223 × Ioctet grid still has no path through the outer code (#813). The generalization makes that refusal user-visible, which is an argument for fixing it, not for hiding it behind padding. - New coding stages. Turbo and LDPC are configurations this model should
grow into; the block interleaver already ships as
INTERLEAVE. None is being added here. - The receive side.
BurstDemodandDsssBurstReceiverstill take raw code arrays and a hand-computedframe_syms. Moving them onto a description is its own pass through the lifecycle: A Receiver Built from a Frame Description (#1620).