A CCSDS CADU, as a Frame 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.
CCSDS is not a mode this library switches into; it is a configuration of that
description, in the same way CCSDS_TM_CONV configures a convolutional code
and CCSDS_TM_RS configures a Reed-Solomon one.
Three fields and three covers is the whole of it:
fields: [ ASM | Transfer Frame | R-S check symbols ]
stages: outer (R-S) covers { frame, check } starts behind the marker
randomiser covers { frame, check } starts behind the marker
inner (conv) covers { ASM, frame, check } marker INCLUDED
Why a span and not an order¶
The stages disagree about what they cover, and that disagreement is the one thing no individual kernel can be wrong about:
| stage | covers the ASM? | 131.0-B-3 § |
|---|---|---|
| Reed-Solomon (outer) | no | 9.5.1, 9.2.1.5 |
| pseudo-randomiser | no | 10.3.2, 10.3.4 note 1 |
| convolutional (inner) | yes | 3.2.1, 9.2.1.4 |
Those section numbers are B-3's, the issue this component was written against; 131.0-B-6 (April 2026) is current and renumbered them without changing what they say (gh-865).
9.2.1.5 states both halves in one sentence — "the ASM shall be encoded by the inner code but not by the outer code" — and 10.3.4's first NOTE states the third outright: "The ASM was not randomized and is not derandomized."
So a pipeline is the representation that cannot express this. A 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 a receiver of your own construction, and syncs to nothing. A span makes the disagreement a value a test can assert.
The rule generalises past CCSDS, which is why the flags are not named after it. A marker, a preamble and a sync word are all things a receiver finds, so all three must look the same in every frame; the data group is what gets coded and scrambled. CCSDS states that for its own ASM, and the reason it gives is exactly as true of a Barker sync word.
Describing one¶
import numpy as np
from doppler.ccsds import asm_bits
from doppler.wfm import (
STAGE_CONV,
STAGE_RANDOMISE,
STAGE_RS,
FrameDesc,
)
# The four numbers CCSDS 131.0-B-3 section 4.3 picks. The marker 9.4.1 picks
# comes from `asm_bits()` rather than from a constant expanded here:
# an MSB-first transcription written out twice is one that can disagree with
# itself. Everything else in this file is general.
K, N, E = 223, 255, 16 # RS(255,223), 16 correctable symbols
DEPTH = 5 # interleaving depth (4.3.5.1 allows 1,2,3,4,5,8)
EMPTY = np.zeros(0, np.uint8)
def transfer_frame(depth: int = DEPTH) -> np.ndarray:
"""`223 * depth` octets of Transfer Frame, as unpacked bits."""
octets = np.array(
[(i * 37 + 11) & 0xFF for i in range(K * depth)], np.uint8
)
return np.unpackbits(octets).astype(np.uint8)
def describe_cadu(payload: np.ndarray, depth: int, *, inner: bool):
"""A CADU as three fields and three covers.
`inner=False` describes the unit a frame checker sees: the convolutional
code is streaming and emits its decisions `depth` bits late, so it is
undone before frame synchronisation and a frame checker never sees
channel symbols.
"""
marker = asm_bits() # 0x1ACFFC1D, figure 9-1
d = FrameDesc(EMPTY, EMPTY, EMPTY) # start from nothing
d.add_field(marker) # 0: the ASM
d.add_field(payload) # 1: the Transfer Frame
d.add_field( # 2: the check symbols the outer code derives
EMPTY, derived_by=1, derived_bits=32 * depth * 8
)
# THE COVERS. Fields 1..2 are the data group; field 0 is the marker, and
# only the inner code reaches over it.
d.add_stage(STAGE_RS, first_field=1, n_fields=2, depth=depth)
d.add_stage(STAGE_RANDOMISE, first_field=1, n_fields=2)
if inner:
d.add_stage(
STAGE_CONV, first_field=0, n_fields=3, emit_num=2, emit_den=1
)
d.build()
return d
FrameDesc is Frame's deferred flavor: the same constructor, but it stops
before materialising so the fields are a starting point you extend. Empty
arrays begin from nothing.
From the command line¶
The same description, reached through wfmgen's flags:
$ wfmgen --type bits --bits-file transfer_frame.bits \
--rs-depth 5 --randomise --asm --conv \
--modulation bpsk --sps 1 --crc none --count 20464 -o cadu.cf32
--randomise takes an optional generator. Bare selects ccsds — 131.0-B-6
10.4.1's 131071-bit sequence, which the standard requires. Passing legacy
selects 10.4.2's 255-bit one, kept for backward compatibility only. The two
are not interchangeable on the air, so --record carries which was used
rather than a bare true.
Each flag is a stage, each is optional, and setting any of them frames the
waveform. All four over a 223 × I-octet payload with no preamble and no sync
word is a CADU. --record carries the coding, so --from-file on that
record reproduces the samples byte for byte.
A payload off the 223 × I grid is refused, not padded — virtual fill is
not implemented (gh-813),
and a silently padded codeblock is the wrong length for the receiver it was
aimed at.
What the receive side buys — the plot¶
A contiguous burst of B symbols lands as ceil(B / I) errors in each
codeword, so interleaving depth I trades no rate at all for an I-fold
longer correctable burst. At I = 5 and E = 16 the boundary is exactly 80
symbols.
Top panel — symbol errors repaired, green while the frame survives and red once it does not. The ramp is the outer code spending its budget one symbol at a time.
Bottom panel — units still good, and it is what keeps the top panel honest. Past 80 the repair count falls, and on its own that would read as "less damage". It is the opposite: a refused codeword is not repaired at all, so it contributes nothing to the height. The step down from 6 to 1 is what actually happened.
80 symbols 6/6 units good 80 repaired ok
81 symbols 5/6 units good 64 repaired REFUSED
82 symbols 4/6 units good 48 repaired REFUSED
83 symbols 3/6 units good 32 repaired REFUSED
84 symbols 2/6 units good 16 repaired REFUSED
85 symbols 1/6 units good 0 repaired REFUSED
Each symbol past the boundary costs exactly one more codeword, because a contiguous burst visits the codewords in rotation — so the failure is graded, not a cliff. The last row is five refused codewords plus the randomiser, which cannot fail and is the one unit left.
That gradation is the point. A CRC reports one bit — right or wrong. An outer code reports how much of its budget it spent, so a margin being consumed is visible long before it is lost:
Both lines "pass". Only one of them is healthy: the first spent none of its budget, the second spent all 80 symbols of it and had nothing left. A CRC cannot tell those apart, and the difference is the whole margin.
check() needs the description and the received bits and no payload truth
at all, so it works on a real capture — which is what makes a truth-free
frame error rate possible on a coded link.
Where the inner code went¶
check() reports checked = 2 of stages = 3 for a fully coded
description, and reports the convolutional stage as not checked rather
than as passed. That is deliberate and the two are different answers.
A Viterbi decoder is streaming and emits its decisions depth bits late, so
the bits of one frame are not a function of that frame's symbols alone — and
the marker that says where a frame starts is only readable once the inner
code has been undone. Frame checking therefore begins after the inner decode
and after frame synchronisation. A function taking channel symbols would have
to own a decoder, a search window and a buffer; that is a streaming receiver,
and this is the per-frame chain it would call.
See also¶
- A Frame as a Description — the design, its falsification targets, and the two rules that fell out of prototyping
- The Viterbi Decoder — the inner code, its traceback depth, and the node synchronization it needs first
- Reed-Solomon — the outer code as a description, and the two offsets a textbook omits
