Skip to content

A CCSDS CADU, as a Frame Description

what an outer code repairs, and what it refuses

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:

clean frame        : 6/6 ok, 0 repaired
   80 symbols  6/6 units good   80 repaired  ok

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