Skip to content

Python Coding API

The doppler.coding module is the general channel codes — the code families a standard configures, rather than any standard's picks. ConvEncoder and Viterbi are the two directions of a rate-1/n convolutional code, and both take the generator polynomials, so a caller names their own code rather than choosing from a menu. The CODE itself — the generator polynomials, the encoder and the trellis arithmetic — lives in the C conv component; this is the DECODER built over one, so a caller names the polynomials and gets a decoder for them rather than picking from a fixed menu.

Soft in, hard out. decode takes log-likelihood ratios, one per channel symbol, and returns information bits. Hard-decision decoding throws away most of the coding gain the code exists to provide — roughly 2 dB of it — which is why the input is LLRs and not sliced bits. The convention is the library's: positive means symbol 0, which is what doppler.mpsk.mpsk_demap produces, so the two compose without a sign fix in between.

Only the SIGN carries the decision and only the RATIO of magnitudes carries the confidence, so an unscaled LLR stream decodes identically to a calibrated one — a caller with no N0 estimate loses nothing.

Two numbers size the decoder, and both are the caller's:

  • k, the constraint length — the trellis has 2**(k-1) states, so cost doubles with every step of k. CCSDS 131.0-B-3 section 3 uses k = 7.
  • depth, the traceback depth in bits — how far back a survivor must agree before a decision is emitted. 5 * (k - 1) is the usual rule of thumb; the first depth - 1 bits of a stream are still owed when a block returns, which is why len(decode(x)) is shorter than the symbol count divided by the rate.

State carries across calls, so a long capture can be fed in blocks and the result is bit-identical to one call — and the decoder serializes, so a chain that checkpoints can checkpoint through it (see State Serialization).

Source: native/src/viterbi/viterbi_core.c

The design, including how the butterfly and the survivor ring are laid out, is The Viterbi Decoder; the end-to-end coded link is the CCSDS Link gallery page.

Both directions, on a code the caller chose:

>>> import numpy as np
>>> from doppler.coding import ConvEncoder, Viterbi
>>> # The CCSDS inner code: G1 = 171, G2 = 133 octal, k = 7.
>>> rng = np.random.default_rng(0)
>>> bits = rng.integers(0, 2, 512).astype(np.uint8)
>>> sym = ConvEncoder([0o171, 0o133], k=7).encode(bits)
>>> sym.size  # rate 1/2: two symbols per information bit, no fill
1024
>>> # Positive LLR means symbol 0 -- the convention mpsk_soft_demap produces.
>>> llr = np.where(sym, -4.0, 4.0).astype(np.float32)
>>> out = Viterbi([0o171, 0o133], k=7, depth=35).decode(llr)
>>> out.size  # 1024 symbols at rate 1/2, less the traceback still owed
478
>>> bool(np.array_equal(out, bits[: out.size]))
True

ConvEncoder — the encoder

ConvEncoder

Build an encoder for the code the polynomials describe.

Parameters:

Name Type Description Default
poly NDArray[uint32]

Generator polynomials, one per output. The array IS the code; poly_len gives n.

required
k int

Constraint length, 2 to CONV_K_MAX.

7
invert int

Bit j complements output j.

0

Raises:

Type Description
ValueError

If construction fails. The exception message is ConvEncoder: not a usable code (need 1 to 6 non-zero polynomials, each under 2**k, and 2 <= k <= 9).

Examples:

>>> import numpy as np
>>> from doppler.coding import ConvEncoder
>>> e = ConvEncoder([0o171, 0o133], k=7, invert=0x2)
>>> e.encode(np.zeros(8, dtype=np.uint8)).size
16

reset

reset() -> None

Return the register to all-zero, keeping the code.

The boundary between two independent records, not a reconfiguration. The next encode starts from the same state a freshly created encoder is in, which is what makes a reset stream byte-identical to a fresh one.

Examples:

>>> import numpy as np
>>> from doppler.coding import ConvEncoder
>>> e = ConvEncoder([0o171, 0o133], k=7)
>>> e.reset()

encode

encode(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Encode information bits into channel symbols.

The register carries across calls, so a long record may be fed in blocks and the symbol sequence is identical to one call — which is the property a standard fixes and a chunked encoder silently breaks.

Outputs are emitted in polynomial order per input bit: for [G1, G2], out[2i] is G1's symbol for input bit i and out[2i+1] is G2's.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Receives n_in * n unpacked symbols, one per byte.

None

Returns:

Type Description
NDArray[uint8]

Symbols written, or 0 if max_out is too small — in which case out is untouched.

Examples:

>>> import numpy as np
>>> from doppler.coding import ConvEncoder, Viterbi
>>> bits = np.array([1, 0, 1, 1, 0, 0, 1, 0] * 40, dtype=np.uint8)
>>> sym = ConvEncoder([0o171, 0o133], k=7).encode(bits)
>>> llr = np.where(sym, -8.0, 8.0).astype(np.float32)
>>> out = Viterbi([0o171, 0o133], k=7, depth=35).decode(llr)
>>> bool(np.array_equal(out, bits[: out.size]))
True

encode_max_out

encode_max_out(n_in: int) -> int

Symbols conv_enc_encode writes for n_in input bits.

Exactly n_in * n — a convolutional code has no fill and no latency on

the encode side, which is the asymmetry with viterbi_decode_max_out,

where the traceback still owes bits at the start of a stream.

Parameters:

Name Type Description Default
n_in int

Number of input bits.

required

Returns:

Type Description
int

Symbols that call will write.

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the ConvEncoder has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the ConvEncoder has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the ConvEncoder has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> ConvEncoder

Enter a context manager, returning this object.

Lets a ConvEncoder be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
ConvEncoder

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the ConvEncoder.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Viterbi — soft-decision convolutional decoder

Viterbi

Build a decoder for the code the polynomials describe.

Parameters:

Name Type Description Default
poly NDArray[uint32]

Generator polynomials, one per output. The array IS the code; poly_len gives n.

required
k int

k (default: 7).

7
invert int

invert (default: 0).

0
depth int

depth (default: 35).

35

Raises:

Type Description
ValueError

If construction fails. The exception message is Viterbi: not a usable code (need 1 to 6 non-zero polynomials, each under 2**k, 2 <= k <= 9, and depth >= 1).

Examples:

>>> import numpy as np
>>> from doppler.coding import Viterbi
>>> v = Viterbi([0o171, 0o133], k=7, depth=35)
>>> v.decode(np.zeros(8, dtype=np.float32)).dtype
dtype('uint8')

reset

reset() -> None

Return to the all-zero start state, discarding the traceback.

The code and the depth are unchanged — this is the boundary between two independent captures, not a reconfiguration. The next decode refills the traceback before it emits, exactly as after create, and the all-zero state is given the winning metric, matching an encoder that starts from a reset register.

Examples:

>>> from doppler.coding import Viterbi
>>> v = Viterbi([0o171, 0o133], k=7, depth=35)
>>> v.reset()

decode

decode(
    x: NDArray[float32], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Decode soft channel symbols into information bits.

The input carries one value per channel symbol, in the convention mpsk_soft_demap produces: L = log(P(0)/P(1)), so positive means symbol 0. The branch metric for an expected symbol e is +L when e == 0 and -L otherwise, and the survivor maximises the sum — which makes the decoder agree with mpsk_demap on hard decisions by construction rather than by a second convention.

A maximum-likelihood path cannot move when every metric is scaled by a positive constant, so the LLRs need no accurate scaling — a caller with no SNR estimate may pass unscaled values.

Streaming: state carries across calls, so a long capture may be fed in blocks and the bits come out continuously. The first depth - 1 branches of a stream produce no output — the traceback walks depth - 1 steps back, so a decision needs that many branches BEHIND it — and thereafter one bit is emitted per n symbols consumed. viterbi_decode_max_out is the same statement as arithmetic, and is what a caller should size a buffer with rather than repeating this sentence: they disagreed by one until a test asserted the count against a literal.

Parameters:

Name Type Description Default
x NDArray[float32]

Input.

required
out NDArray[uint8] | None

Receives the decoded information bits, one per byte.

None

Returns:

Type Description
NDArray[uint8]

Bits written, which may be 0 while the traceback fills.

Examples:

>>> import numpy as np
>>> from doppler.coding import Viterbi
>>> v = Viterbi([0o171, 0o133], k=7, depth=35)
>>> llr = np.array([2.0, -2.0] * 128, dtype=np.float32)
>>> bits = v.decode(llr)
>>> set(np.unique(bits)) <= {0, 1}
True

decode_max_out

decode_max_out(n_in: int) -> int

Bits viterbi_decode will emit for n_in soft symbols.

Accounts for the fill still owed at the start of a stream, so a caller can

size a buffer exactly rather than conservatively.

Parameters:

Name Type Description Default
n_in int

Number of soft symbols the next call would be given.

required

Returns:

Type Description
int

Bits that call would write.

state_bytes

state_bytes() -> int

Size in bytes of this object's serialized state.

The exact length get_state returns and set_state requires. It depends on how the object was constructed (state arrays are sized at construction), so read it from the instance rather than assuming a constant.

Raises RuntimeError if the Viterbi has already been destroyed.

Returns:

Type Description
int

Byte length of one serialized state blob.

get_state

get_state() -> bytes

Serialize this object's mutable state to bytes.

Captures exactly the state that evolves as the object runs, so a blob taken now and restored later resumes from this point. Construction parameters are not included: restore into an object built the same way.

The blob is opaque and always state_bytes() long. Its layout is an implementation detail of the C core and is not a stable format across builds.

Raises RuntimeError if the Viterbi has already been destroyed.

Returns:

Type Description
bytes

Opaque snapshot, state_bytes() bytes long.

set_state

set_state(blob: bytes) -> None

Restore mutable state from a get_state() blob.

Overwrites the live state in place; the object keeps the parameters it was constructed with. Length is validated against state_bytes() before the blob is handed to the C core, and the core may reject it as well.

Raises TypeError if blob is not bytes, ValueError if its length differs from state_bytes() or the core rejects it, and RuntimeError if the Viterbi has already been destroyed.

Parameters:

Name Type Description Default
blob bytes

A get_state() blob from this type, exactly state_bytes() long.

required

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Viterbi

Enter a context manager, returning this object.

Lets a Viterbi be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Viterbi

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Viterbi.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

ReedSolomon — both directions of a block code

A Reed-Solomon code over GF(2**J) is five numbers: a symbol width, a field polynomial, a parity count, a first root and a root stride. ReedSolomon takes all five, so a caller names their own code — and both directions read the same description, which is what stops an encoder and a decoder disagreeing about what the code is.

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)          # RS(255,223) over the usual GF(256)
>>> rs.n, rs.k, rs.e
(255, 223, 16)
>>> word = rs.encode(np.arange(rs.k, dtype=np.uint8))
>>> word.size, rs.codeword_ok(word)
(255, 1)
>>> word[3] ^= 0xFF                       # a symbol, however many bits moved
>>> rs.decode(word)                       # corrected IN PLACE
1

decode corrects the array you hand it. That is the C contract reaching Python rather than a convenience: a decode that quietly worked on a copy would return the right count and leave your data wrong, so the binding demands a writable, C-contiguous uint8 array instead of accepting anything convertible.

A refusal is safe. A miscorrection is not.

decode returns -1 when the word is too far from every codeword to name one — the receiver knows. Beyond E errors it can instead land inside a different codeword's sphere, return a positive count, and pass codeword_ok: silently wrong. That is a property of any bounded-distance code, not of this implementation, and the chance of it is about sum(C(n, i)(q-1)**i for i <= E) / q**(n-k)2e-05 for RS(255,223) and 0.36 for RS(15,11). Parity is what buys the silence; frame-level accounting is what catches the rest. Measured in the gallery.

-2 is a different fact entirely: the word was not n symbols long, which is your bug rather than the channel's.

Matching the algebra is not matching the wire

The five numbers are the code. A standard adds conventions that are not properties of it, and CCSDS adds two — symbols travel in the dual (Berlekamp) basis (131.0-B 4.3.9) and codewords are interleaved (4.4.1). Construct ReedSolomon with CCSDS's five numbers and the arithmetic is right while the wire format is not:

>>> ccsds = ReedSolomon(nroots=32, field_poly=0x87, first_root=112,
...                     root_stride=11)          # 131.0-B 4.3
>>> g = np.empty(ccsds.nroots + 1, np.uint8)
>>> ccsds.generator(g)                           # Annex G prints all 33
33
>>> int(g[0]), int(g[-1])
(1, 1)
>>> textbook = np.empty(33, np.uint8)
>>> _ = ReedSolomon(nroots=32).generator(textbook)
>>> bool((g != textbook).any())                  # a different code entirely
True

generator() takes the buffer rather than handing you one, because its length is a property of the code and not of the call — a self-sizing method would carry a count parameter that could only mislead. It is there because standards publish those coefficients: it is how you check that you read the five numbers correctly, against the document rather than against this implementation. Two of the five are validated at construction for the same reason — a non-primitive field_poly and a root_stride sharing a factor with n both produce arithmetic that encodes and decodes against itself perfectly, so a round trip can never catch either.

For a whole CCSDS CADU — the dual basis, the interleaver and the coverage table — describe one with FrameDesc instead.

ReedSolomon

Create a codec for the code named by the five arguments.

Parameters:

Name Type Description Default
nroots int

Parity symbols per codeword, 2E — even, at least 2, and small enough to leave one information symbol. The code corrects E = nroots / 2 symbol errors.

required
symbol_bits int

J, the symbol width in bits, 2..8. A codeword is n = 2**J - 1 symbols, one per byte, so J = 8 gives the familiar 255.

8
field_poly int

F(x), low J bits, with x**J implicit. Must be PRIMITIVE — a polynomial that generates a subgroup instead of the field produces perfectly self-consistent arithmetic that interoperates with nothing, so the constructor checks rather than trusts. The default 29 is x**8 + x**4 + x**3 + x**2 + 1.

29
first_root int

j0: the generator's first root is a**(root_stride * j0).

1
root_stride int

s: the roots are powers of a**s. Must be coprime with n, or the nroots roots are not distinct and the code corrects fewer errors than its parity count claims (CCSDS 4.3.4 states this as a note about a**11; for a general code it is a condition, and the constructor checks it).

1

Raises:

Type Description
ValueError

If construction fails. The exception message is ReedSolomon: not a usable code — need an even nroots in 2..64 leaving at least one information symbol, 2 <= symbol_bits <= 8, a PRIMITIVE field_poly, and a root_stride coprime with 2**symbol_bits - 1.

Examples:

>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)      # RS(255,223) over the usual GF(256)
>>> rs.n, rs.k, rs.e
(255, 223, 16)
>>> ReedSolomon(nroots=4, symbol_bits=4, field_poly=0b0011).n
15

n property

n: int

Symbols per codeword, 2^J - 1.

k property

k: int

Information symbols per codeword, n - nroots.

e property

e: int

Correctable symbols per codeword, nroots / 2.

nroots property

nroots: int

Parity symbols per codeword, 2E.

symbol_bits property

symbol_bits: int

Symbol width J, in bits.

encode

encode(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Encode k information symbols into a whole n-symbol codeword.

Systematic: the information symbols are copied through untouched and the nroots parity symbols follow them, which is the order they are transmitted in. rs_encode computes the parity; this places it.

The WHOLE codeword rather than the parity alone, because that is the unit every other method here takes — rs_codec_decode, rs_codec_syndromes and rs_codec_codeword_ok all read n symbols, and a caller who wants the parity by itself can take the last nroots of the answer. (rs_encode is the other split, and is still there for a frame assembler that has already placed the information.)

out may alias in — rs_codec_encode (rs, buf, k, buf, n) appends the parity to a buffer that already holds the information, which is the call a frame assembler makes and the one rs_encode exists for.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Receives n symbols; may be in.

None

Returns:

Type Description
NDArray[uint8]

n on success, or 0 if n_in is not exactly k or out is too small — refusing rather than truncating, since a short codeword is not a codeword.

Examples:

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)
>>> info = np.arange(rs.k, dtype=np.uint8)
>>> word = rs.encode(info)
>>> word.size, bool(np.array_equal(word[: rs.k], info))
(255, True)
>>> rs.codeword_ok(word)
1

encode_max_out

encode_max_out(n_in: int) -> int

Symbols rs_codec_encode writes for n_in information symbols: a whole codeword, n.

Parameters:

Name Type Description Default
n_in int

Input.

required

Returns:

Type Description
int

Output.

decode

decode(codeword: NDArray[uint8]) -> int

Correct up to E symbol errors, IN PLACE.

rs_decode, over the caller's own buffer: the corrected symbols land in codeword itself, which is why the binding demands a writable array rather than quietly working on a copy the caller would then discard.

It either refuses or leaves a codeword. On success the key equation has zeroed every syndrome by construction, so the result passes rs_codec_codeword_ok. On refusal codeword is untouched.

A refusal is not the same claim as "more than E errors". Beyond E a bounded-distance decoder can land inside another codeword's sphere and miscorrect — a property of the code, not of this implementation — which is why this reports a COUNT rather than a verdict, and why frame-level accounting is the protection.

Parameters:

Name Type Description Default
codeword NDArray[uint8]

n symbols, corrected in place.

required

Returns:

Type Description
int

Symbols corrected, 0 for an already-valid codeword, -1 when the word is too far from every codeword to name one, or -2 when codeword_len is not n. Two negative codes rather than one because they are different kinds of fact: -1 is the channel's answer and -2 is the caller's mistake.

Examples:

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)
>>> word = rs.encode(np.arange(rs.k, dtype=np.uint8))
>>> word[3] ^= 0xFF          # one symbol, however many bits it moved
>>> word[40] ^= 0x01
>>> rs.decode(word)          # corrected in place
2
>>> bool(np.array_equal(word[: rs.k], np.arange(rs.k, dtype=np.uint8)))
True

syndromes

syndromes(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

The nroots syndromes of an n-symbol word.

All zero is the DEFINING property of the code: it needs no encoder and no decoder to check, which is what makes it usable both as a test oracle and as a receiver's error detector. rs_codec_codeword_ok is this reduced to the one bit most callers want.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Receives nroots syndromes.

None

Returns:

Type Description
NDArray[uint8]

nroots, or 0 if n_in is not n or out is too small.

Examples:

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)
>>> word = rs.encode(np.zeros(rs.k, dtype=np.uint8))
>>> bool(rs.syndromes(word).any())      # a codeword has none
False
>>> word[7] ^= 0x20
>>> bool(rs.syndromes(word).any())
True

syndromes_max_out

syndromes_max_out(n_in: int) -> int

Syndromes rs_codec_syndromes writes: nroots.

Parameters:

Name Type Description Default
n_in int

Input.

required

Returns:

Type Description
int

Output.

codeword_ok

codeword_ok(codeword: NDArray[uint8]) -> int

Is this a valid codeword? — every syndrome zero.

Parameters:

Name Type Description Default
codeword NDArray[uint8]

n symbols.

required

Returns:

Type Description
int

1 when every syndrome is zero, 0 otherwise — including when codeword_len is not n, since a word of the wrong length is not a codeword of this code.

Examples:

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32)
>>> rs.codeword_ok(np.zeros(rs.n, np.uint8))   # all-zero IS a codeword
1
>>> rs.codeword_ok(np.zeros(rs.n - 1, np.uint8))   # at the right size
0

generator

generator(out: NDArray[uint8]) -> int

The nroots + 1 coefficients of g(x), out[i] for x^i.

Exposed because standards PUBLISH them — CCSDS 131.0-B Annex G prints all 33 for E = 16 — so a caller who has just configured a code from a document can check that they read the five numbers correctly, against the document rather than against this implementation.

The caller supplies the buffer rather than being handed one, because the length is a property of the CODE and not of the call: g(x) has exactly nroots + 1 coefficients and there is no other number a caller could ask for. A self-sizing method would carry a count parameter that means nothing, which is a worse trade than one line of allocation.

Parameters:

Name Type Description Default
out NDArray[uint8]

Receives nroots + 1 coefficients; out[i] is the coefficient of x^i, so out[nroots] is 1.

required

Returns:

Type Description
int

nroots + 1, or 0 if out is too small.

Examples:

>>> import numpy as np
>>> from doppler.coding import ReedSolomon
>>> rs = ReedSolomon(nroots=32, field_poly=0x87, first_root=112,
...                  root_stride=11)          # CCSDS 131.0-B 4.3
>>> g = np.empty(rs.nroots + 1, np.uint8)
>>> rs.generator(g)                  # Annex G prints all 33
33
>>> int(g[0]), int(g[-1])
(1, 1)

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> ReedSolomon

Enter a context manager, returning this object.

Lets a ReedSolomon be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
ReedSolomon

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the ReedSolomon.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Interleaver — spreading a burst across codewords

A block interleaver writes its input by rows into a rows × cols matrix and reads it back by columns. That is the whole transform: no redundancy, no detection, and the same number of bits out as in.

What it buys is that a burst of errors arrives at the decoder spread out. Write one codeword per row and reading by columns transmits one symbol from each codeword in turn, so a burst of up to rows consecutive symbols costs each codeword at most one. An outer code correcting t symbols per codeword then survives a burst of t * rows.

Two things are worth knowing before reaching for it.

Interleaving a single codeword buys nothing. Reed-Solomon corrects any E symbol errors wherever they fall, so permuting them inside one codeword changes nothing a decoder can see. The gain exists only when there are codewords to spread a burst across — which is also why ReedSolomon's own interleaving depth already has this property for the codewords it covers. Interleaver is the general form: it works over whatever span you give it, including many codeblocks and codes with no interleaving of their own.

Match unit_bits to the code's symbol. An RS code over GF(256) is protected by permuting octets (unit_bits=8). Bit-interleaving it spreads a burst inside symbols that are already wrong, and the difference shows up exactly at the bound — measured in validate_interleave_burst_gain, where at the E * rows limit octet units correct every frame and bit units lose four in five.

There is no interleave_soft, because a transmitter has bits, not LLRs. deinterleave_soft exists because a receiver has both: an outer decoder wants DsssBurstReceiver.llrs de-interleaved before it runs, and slicing to hard decisions first throws away the confidence the soft output carries.

Interleaver

Build an interleaver over a rows x cols block of unit_bits units.

Parameters:

Name Type Description Default
rows int

Interleaving depth; the longest burst fully spread. Must be non-zero.

...
cols int

Units per codeword. Must be non-zero.

...
unit_bits int

Bits per interleaved unit. 1 interleaves bits; 8 interleaves octets, which is what spreads a burst across the codewords of a symbol-oriented code such as Reed-Solomon over GF(256). Must be non-zero.

1

Raises:

Type Description
ValueError

If construction fails. The exception message is Interleaver: rows, cols and unit_bits must all be non-zero, and their product must fit a size_t.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=3, cols=4)
>>> il.block_bits, il.burst_len, il.separation
(12, 3, 4)

rows property

rows: int

Interleaving depth -- codewords interleaved.

cols property

cols: int

Units per codeword.

unit_bits property

unit_bits: int

Bits per interleaved unit; 1 interleaves bits, 8 octets.

block_bits property

block_bits: int

Bits in one block -- rows * cols * unit_bits. Every call takes a whole multiple of this.

burst_len property

burst_len: int

The longest burst this geometry fully spreads: a burst of up to this many consecutive units on the wire touches each codeword at most once. An outer code correcting t units per codeword survives a burst of t * burst_len.

separation property

separation: int

The other half of the link budget -- what burst_len spreads a burst ACROSS. Equal to cols, and named for what it buys rather than for the matrix it comes from.

reset

reset() -> None

No-op; an interleaver carries nothing between calls.

Present because the object surface has it, and honest about why it does nothing: a reset that pretended to clear something would suggest there was something to clear. The geometry is configuration, not state, so it survives — a reset that cleared THAT would leave every later call refusing.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=2, cols=3)
>>> il.reset()
>>> il.block_bits
6

interleave

interleave(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Interleave a whole number of blocks: write the input by rows into a rows x cols matrix and read it back by columns. Length-preserving. A length that is not a whole multiple of block_bits is REFUSED rather than padded -- padding changes the length, and a receiver de-interleaving the padded block recovers different bits.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Where to write n_in bits; must not overlap in.

None

Returns:

Type Description
NDArray[uint8]

n_in on success, 0 if the length is not a whole number of blocks or out is too small. A partial block is REFUSED rather than padded: padding changes the length, and a receiver that de-interleaved the padded block would recover different bits.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=3, cols=4)
>>> x = np.arange(12, dtype=np.uint8)
>>> np.asarray(il.interleave(x)).tolist()
[0, 4, 8, 1, 5, 9, 2, 6, 10, 3, 7, 11]

interleave_max_out

interleave_max_out(n_in: int) -> int

Output bits for n_in input bits — the same number.

A permutation moves bits and does not add or remove any, so this is the

identity. It exists because the binding asks a method how much room its

output needs, and answering "the same" is not something a caller should

have to know.

Parameters:

Name Type Description Default
n_in int

Input length in bits.

required

Returns:

Type Description
int

n_in.

Examples:

>>> from doppler.coding import Interleaver
>>> Interleaver(rows=4, cols=8).interleave_max_out(32)
32

deinterleave

deinterleave(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Undo interleave() over the same geometry. De-interleaving a rows x cols block is interleaving a cols x rows one, so this is the same kernel with two arguments exchanged -- which is also why a SQUARE block is its own inverse.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Where to write n_in bits; must not overlap in.

None

Returns:

Type Description
NDArray[uint8]

n_in, or 0 on a refusal.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=3, cols=4)
>>> x = np.arange(12, dtype=np.uint8)
>>> y = np.asarray(il.interleave(x))
>>> np.array_equal(np.asarray(il.deinterleave(y)), x)
True

deinterleave_max_out

deinterleave_max_out(n_in: int) -> int

Output bits for n_in input bits — the same number.

Identical to interleaver_interleave_max_out, and for the same reason:

the inverse of a permutation is a permutation.

Parameters:

Name Type Description Default
n_in int

Input length in bits.

required

Returns:

Type Description
int

n_in.

Examples:

>>> from doppler.coding import Interleaver
>>> Interleaver(rows=4, cols=8).deinterleave_max_out(32)
32

deinterleave_soft

deinterleave_soft(
    x: NDArray[float32], out: NDArray[float32] | None = None
) -> NDArray[np.float32]

Undo an interleave over SOFT values -- the receive path that matters. DsssBurstReceiver.llrs span the whole frame, and an outer decoder wants them de-interleaved BEFORE it runs; slicing to hard bits first throws away the confidence the soft output exists to carry. There is no interleave_soft, because a transmitter has bits, not LLRs.

dsss_burst_receiver's llrs span the whole frame, and an outer decoder wants them de-interleaved BEFORE it runs. Slicing to hard bits first and de-interleaving those throws away the confidence the soft output exists to carry, which is most of what an outer code is for.

There is no interleave_soft: a transmitter has bits, not LLRs.

Parameters:

Name Type Description Default
x NDArray[float32]

Input.

required
out NDArray[float32] | None

Where to write n_in values; must not overlap in.

None

Returns:

Type Description
NDArray[float32]

n_in, or 0 on a refusal.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=2, cols=3)
>>> llr = np.array([1., 2., 3., 4., 5., 6.], dtype=np.float32)
>>> np.asarray(il.deinterleave_soft(llr)).tolist()
[1.0, 3.0, 5.0, 2.0, 4.0, 6.0]

deinterleave_soft_max_out

deinterleave_soft_max_out(n_in: int) -> int

Output values for n_in soft input values — the same number.

Parameters:

Name Type Description Default
n_in int

Input length in values.

required

Returns:

Type Description
int

n_in.

Examples:

>>> from doppler.coding import Interleaver
>>> Interleaver(rows=4, cols=8).deinterleave_soft_max_out(32)
32

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Interleaver

Enter a context manager, returning this object.

Lets a Interleaver be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Interleaver

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Interleaver.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

Deinterleaver — the same object, under the name the receive side looks for

Identical construction, and interleave deliberately absent. It exists because the two ends of a link are written by different people: someone working the receive side reaches for a Deinterleaver, and a class findable only under the transmit name is a class they do not find.

It is a view over the same core rather than a second object, and that is the important part. rows, cols and unit_bits are exactly what the two ends must agree on, and a mismatch is not an error — it is a receiver de-interleaving into a different permutation and handing the decoder plausible garbage. One core means one definition of the geometry to get right.

Deinterleaver

The RECEIVE face of the same interleaver.

Parameters:

Name Type Description Default
rows int

Interleaving depth, as the transmitter used.

...
cols int

Units per codeword, as the transmitter used.

...
unit_bits int

Bits per interleaved unit, as the transmitter used.

1

Raises:

Type Description
ValueError

If construction fails. The exception message is Interleaver: rows, cols and unit_bits must all be non-zero, and their product must fit a size_t.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver, Deinterleaver
>>> tx = Interleaver(rows=3, cols=4)
>>> rx = Deinterleaver(rows=3, cols=4)
>>> bits = np.arange(12, dtype=np.uint8)
>>> wire = np.asarray(tx.interleave(bits))
>>> np.array_equal(np.asarray(rx.deinterleave(wire)), bits)
True

rows property

rows: int

Interleaving depth -- codewords interleaved.

cols property

cols: int

Units per codeword.

unit_bits property

unit_bits: int

Bits per interleaved unit; 1 interleaves bits, 8 octets.

block_bits property

block_bits: int

Bits in one block -- rows * cols * unit_bits. Every call takes a whole multiple of this.

burst_len property

burst_len: int

The longest burst this geometry fully spreads: a burst of up to this many consecutive units on the wire touches each codeword at most once. An outer code correcting t units per codeword survives a burst of t * burst_len.

separation property

separation: int

The other half of the link budget -- what burst_len spreads a burst ACROSS. Equal to cols, and named for what it buys rather than for the matrix it comes from.

reset

reset() -> None

No-op; an interleaver carries nothing between calls.

Present because the object surface has it, and honest about why it does nothing: a reset that pretended to clear something would suggest there was something to clear. The geometry is configuration, not state, so it survives — a reset that cleared THAT would leave every later call refusing.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=2, cols=3)
>>> il.reset()
>>> il.block_bits
6

deinterleave

deinterleave(
    x: NDArray[uint8], out: NDArray[uint8] | None = None
) -> NDArray[np.uint8]

Undo interleave() over the same geometry. De-interleaving a rows x cols block is interleaving a cols x rows one, so this is the same kernel with two arguments exchanged -- which is also why a SQUARE block is its own inverse.

Parameters:

Name Type Description Default
x NDArray[uint8]

Input.

required
out NDArray[uint8] | None

Where to write n_in bits; must not overlap in.

None

Returns:

Type Description
NDArray[uint8]

n_in, or 0 on a refusal.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=3, cols=4)
>>> x = np.arange(12, dtype=np.uint8)
>>> y = np.asarray(il.interleave(x))
>>> np.array_equal(np.asarray(il.deinterleave(y)), x)
True

deinterleave_max_out

deinterleave_max_out(n_in: int) -> int

Output bits for n_in input bits — the same number.

Identical to interleaver_interleave_max_out, and for the same reason:

the inverse of a permutation is a permutation.

Parameters:

Name Type Description Default
n_in int

Input length in bits.

required

Returns:

Type Description
int

n_in.

Examples:

>>> from doppler.coding import Interleaver
>>> Interleaver(rows=4, cols=8).deinterleave_max_out(32)
32

deinterleave_soft

deinterleave_soft(
    x: NDArray[float32], out: NDArray[float32] | None = None
) -> NDArray[np.float32]

Undo an interleave over SOFT values -- the receive path that matters. DsssBurstReceiver.llrs span the whole frame, and an outer decoder wants them de-interleaved BEFORE it runs; slicing to hard bits first throws away the confidence the soft output exists to carry. There is no interleave_soft, because a transmitter has bits, not LLRs.

dsss_burst_receiver's llrs span the whole frame, and an outer decoder wants them de-interleaved BEFORE it runs. Slicing to hard bits first and de-interleaving those throws away the confidence the soft output exists to carry, which is most of what an outer code is for.

There is no interleave_soft: a transmitter has bits, not LLRs.

Parameters:

Name Type Description Default
x NDArray[float32]

Input.

required
out NDArray[float32] | None

Where to write n_in values; must not overlap in.

None

Returns:

Type Description
NDArray[float32]

n_in, or 0 on a refusal.

Examples:

>>> import numpy as np
>>> from doppler.coding import Interleaver
>>> il = Interleaver(rows=2, cols=3)
>>> llr = np.array([1., 2., 3., 4., 5., 6.], dtype=np.float32)
>>> np.asarray(il.deinterleave_soft(llr)).tolist()
[1.0, 3.0, 5.0, 2.0, 4.0, 6.0]

deinterleave_soft_max_out

deinterleave_soft_max_out(n_in: int) -> int

Output values for n_in soft input values — the same number.

Parameters:

Name Type Description Default
n_in int

Input length in values.

required

Returns:

Type Description
int

n_in.

Examples:

>>> from doppler.coding import Interleaver
>>> Interleaver(rows=4, cols=8).deinterleave_soft_max_out(32)
32

destroy

destroy() -> None

Release the underlying C resources immediately.

Ordinarily unnecessary: the resources are freed when the object is garbage-collected. Call this to release them at a definite point instead, or use the object as a context manager, which calls it on exit.

Idempotent: calling it again on an already-released object does nothing. Every other method raises RuntimeError once it has run.

__enter__

__enter__() -> Deinterleaver

Enter a context manager, returning this object.

Lets a Deinterleaver be used in a with statement so its C resources are released deterministically on exit rather than at collection time.

Returns:

Type Description
Deinterleaver

This same object, not a copy.

__exit__

__exit__(
    exc_type: object | None = ...,
    exc: object | None = ...,
    tb: object | None = ...,
) -> None

Exit a context manager, releasing the Deinterleaver.

Equivalent to calling destroy(). Returns None, so an exception raised inside the with body propagates normally; this never suppresses one.

Parameters:

Name Type Description Default
exc_type object | None

Exception class, or None. Ignored.

...
exc object | None

Exception instance, or None. Ignored.

...
tb object | None

Traceback object, or None. Ignored.

...

GalleryA CCSDS CADU, as a Frame Description, Name Your Own Code — and What Happens Past the Radius, DsssBurstReceiver — the Composed Burst Chain DesignThe FEC Receive Half, Design, Interleaving — spreading a burst across codewords, Reed-Solomon ContributingValidation log