Patrick Lidstone
Self-hosted

Meteor-M LRPT: a native CCSDS weather-satellite image downlink decoder

A self-contained reception chain for the Meteor-M LRPT signal — 72 kSym/s QPSK/OQPSK on ~137 MHz — carried by the same CCSDS transfer-frame core (Viterbi + Reed-Solomon + pseudo-randomiser) that every modern low-rate spacecraft telemetry link uses, ending in a Meteor JPEG-like MSU-MR image.

Rafe project · app/radio/sat/lrpt.py, ccsds.py, viterbi.py, rs.py, meteor_jpeg.py · native / clean-room


Abstract

LRPT (Low Rate Picture Transmission) is the digital successor to the venerable analogue APT broadcast: the Russian Meteor-M series spacecraft downlink their MSU-MR imager on ~137 MHz as a CCSDS-conformant telemetry stream rather than a line-scan AM subcarrier. This document specifies a native, dependency-light receiver built entirely in-repo under app/radio/sat/: an SDR or FM-audio front end recovers 72 kSym/s QPSK soft symbols, a rate-½ constraint-length-7 convolutional (Viterbi) decoder undoes the inner code, an Attached Sync Marker correlator finds frame boundaries, a CCSDS pseudo-noise randomiser is removed, and a dual-basis interleaved Reed-Solomon (255,223) decoder produces clean transfer frames. Those frames carry CCSDS Space Packets which are reassembled (packets span frames) and handed to a Meteor JPEG codec — an 8×8 DCT / baseline-Huffman image compressor — to reconstruct the MSU-MR channel imagery. The two lower layers (ccsds.py and its helpers viterbi.py, rs.py) are a general CCSDS 131.0-B TM sync-and-channel-coding core reusable by any CCSDS downlink (HRPT, xRIT, cubesat telemetry); the LRPT-specific pieces are the Space-Packet reassembly and MSU-MR image tiling in lrpt.py, plus the image codec in meteor_jpeg.py. Every constant, polynomial and table in the chain is transcribed below with its source file, and the whole pipeline is validated self-consistently — modulate → add noise → demodulate → decode — by the repo's test suite.


1. Background

1.1 Where LRPT sits

An amateur pointing a turnstile or QFH antenna at a 137 MHz Meteor-M pass is looking at a spacecraft telemetry link, not a bespoke picture format. The image is only the innermost payload. From the outside in:

  RF (137 MHz FM/IQ)
    └─ QPSK / OQPSK, 72 kSym/s          ── PHY            (lrpt.py: qpsk_soft)
        └─ r=1/2 K=7 convolutional      ── inner FEC      (viterbi.py)
            └─ ASM 0x1ACFFC1D           ── frame sync     (ccsds.py: find_asm)
                └─ CCSDS PN randomiser  ── energy dispersal(ccsds.py: pn_sequence)
                    └─ RS(255,223), I=4  ── outer FEC      (rs.py)
                        └─ VCDU transfer frame            (ccsds.py: decode_cadu)
                            └─ M_PDU  ── first-header ptr  (lrpt.py: extract_packets)
                                └─ CCSDS Space Packets    (lrpt.py: packet_apid)
                                    └─ MSU-MR Meteor JPEG (meteor_jpeg.py)
                                        └─ 8×8 pixel blocks → channel image

This is precisely the CCSDS "TM Synchronization and Channel Coding" (131.0-B) concatenated-code recommendation — the same stack flown by hundreds of missions. LRPT is that stack, parameterised for a slow imager, with the CCSDS Space Packet (133.0-B) as the transport and a JPEG-like image codec as the application.

1.2 The concatenated-code idea

The link uses a classic concatenated code: a strong-but-simple convolutional inner code that the Viterbi algorithm decodes from soft symbols and that mops up the dense random bit errors of a noisy FM/IQ channel, wrapped around a Reed-Solomon outer code that cleans up the residual bursts the Viterbi decoder emits when it briefly loses the trellis. Between the two sits a pseudo-random randomiser (energy dispersal) so the transmitted spectrum stays white and the symbol-timing recovery never starves on a long run of identical symbols. This layering — Viterbi → derandomise → Reed-Solomon — is the backbone of CCSDS TM and is implemented verbatim in ccsds.py.

1.3 What this repo implements, and what it defers

app/radio/sat/ is described in __init__.py as "a focused native pipeline reusing this repo's DSP/FEC … LRPT/HRPT/xRIT build on the CCSDS core." The scope is deliberate and worth stating up front so the reader knows exactly what is reproducible from the code:

  • Fully implemented, bit-exact, tested: the entire digital chain from soft QPSK symbols down to pixels — Viterbi, ASM sync, derandomiser, RS(255,223) dual-basis decode, VCDU→M_PDU→Space-Packet reassembly, and the Meteor JPEG image codec. A self-consistent modulator (qpsk_mod, encode_cadu, encode_block) lets the whole chain be exercised end-to-end without a live capture.
  • Real-IQ receiver, now implemented (qpsk_rx.py): AGC → root-raised-cosine matched filter → Gardner symbol-timing recovery → QPSK Costas carrier recovery → soft symbols, wired end-to-end by decode_lrpt_iq/qpsk_rx.decode_iq and exercised by a make_test_iq harness that injects carrier-frequency, timing and noise offsets. Section 4.1 documents the soft-symbol demapper; Section 4.2 now describes the recovery loop as actually built.
  • Still deferred (one real-signal detail): true OQPSK de-offsetting (the half-symbol Q delay of Meteor-M2-2/-3). qpsk_rx treats the signal as plain QPSK; full OQPSK realignment needs a real capture to validate and is left as a refinement (§8).

This document specifies the real implementation. Where the repo simplifies or diverges from canonical Meteor-M2 (the per-frame VCDU model of §5.5, the QPSK-vs- OQPSK question of §4), that is called out plainly rather than papered over.


2. Signal and frame structure — the exact numbers

2.1 Physical layer

Parameter Value Source
Downlink band ~137 MHz (VHF), RHCP Meteor-M LRPT (external)
Modulation QPSK (Meteor-M2) / OQPSK (M2-2/-3) lrpt.py docstring "QPSK 72k"
Symbol rate 72 000 Sym/s lrpt.py docstring "72k"
Bits/symbol 2 (Gray-mapped I,Q) lrpt.py
Coded channel rate 144 kbit/s (2 × 72 k) derived
Inner code r = ½, K = 7 convolutional viterbi.py
Post-Viterbi rate 72 kbit/s derived
Outer code RS(255,223), interleave I = 4 rs.py, ccsds.py

The soft-symbol constellation the demapper expects is unit-amplitude QPSK, (±1 ± j) (lrpt.py): I carries the first coded bit of a pair, Q the second.

2.2 The CADU (Channel Access Data Unit)

The unit that repeats on the wire is the CADU: a 4-byte Attached Sync Marker followed by one randomised, RS-coded, interleaved transfer frame. With the repo's default interleave depth I = 4 (ccsds.py):

 CADU  = ASM(4) + randomised_RS_block(1020)               = 1024 bytes
                                    │
        randomised_RS_block = 1020  = 255 × I  = 255 × 4   (ccsds.py)
                                    │
        deinterleave → I=4 codewords of RS(255,223)
                                    │
        RS decode  → I=4 × 223      = 892 data bytes  + 4 × 32 = 128 parity

Byte-level layout of one CADU (default I = 4):

Offset (bytes) Length Field Source
0 4 ASM = 0x1A 0xCF 0xFC 0x1D (not randomised) ccsds.py
4 1020 Randomised block = interleaved RS codewords ccsds.py

After deinterleave + RS decode you hold I = 4 transfer frames of 223 bytes each (ccsds.py). Numeric cross-check of the encoder (ccsds.py):

frame bytes    = 4 (ASM) + 1020            = 1024 bytes = 8192 bits
+ 6 tail bits (conv_encode)                = 8198 info bits          (viterbi.py)
× r=1/2 conv encode                        = 16396 coded bits
÷ 2 bits/QPSK symbol                       = 8198 symbols per CADU
@ 72 kSym/s                                ≈ 113.9 ms/CADU  (≈ 8.78 CADU/s)

The decoder mirrors this but does not assume a fixed length: decode_cadu Viterbi-decodes all available info bits (total = symbols.size // 2), then find_asm locates the marker at some offset a and slices out the need = 255 × interleave × 8 = 8160-bit randomised block as bits[a+32 : a+32+8160]. Decoding everything first (rather than a fixed 8192) is deliberate — it means a real capture's lead-in before the ASM cannot truncate the last RS codeword. The Viterbi traceback discards the 6 tail bits.

2.3 The transfer frame → M_PDU → Space Packet

Each RS-corrected frame is interpreted (in this repo — see §5.5 for the divergence from canonical Meteor) as a CCSDS VCDU carrying an M_PDU (Multiplexing Protocol Data Unit). extract_packets (lrpt.py) reads:

Frame offset Field Source
0–5 VCDU primary header (version/SCID/VCID/counter — skipped) lrpt.py (len(f) < 8 guard)
6–7 M_PDU first-header pointer (FHP), 11 bits: `((f[6]&0x07)<<8) f[7]`
8… M_PDU packet zone lrpt.py

The FHP is the byte offset (within the packet zone) of the first Space-Packet header that starts in this frame. The reserved value 0x7FF means "no packet starts in this frame" — the whole zone continues a packet spanning from an earlier frame (lrpt.py).

The CCSDS Space Packet (6-byte primary header) is parsed at lrpt.py:

Byte(s) Field Notes
0 version(3) · type(1) · sec-hdr-flag(1) · APID10:8 packet_apid: ((pkt[0]&0x07)<<8)|pkt[1] (lrpt.py)
1 APID[7:0] 11-bit Application Process ID
2 seq-flags(2) · seq-count13:8 0xC0 = standalone packet
3 seq-count[7:0] 14-bit sequence counter
4–5 packet data length − 1 plen = ((z[i+4]<<8)|z[i+5]) + 1 (lrpt.py)
6… user data (plen bytes) total = 6 + plen (lrpt.py)

The APID selects the source instrument/channel; the MSU-MR imager APIDs carry the Meteor-JPEG-compressed 8×8 blocks decoded in §6.


3. The two layers of the receiver

              ┌──────────────────────── ccsds.decode_cadu ─────────────────────────┐
 soft symbols │ viterbi_decode → find_asm → _bits_to_bytes → randomize(derandomise) │ 223-B
   ──────────▶│                 → deinterleave [i::I] → rs.decode_dual  ×I           │─frames─▶
              └────────────────────────────────────────────────────────────────────┘
 (lrpt.qpsk_soft turns IQ → soft bits;  ccsds/viterbi/rs are the reusable CCSDS core)

              ┌──────────────────── lrpt.extract_packets ──────────┐   ┌ meteor_jpeg ┐
 223-B frames │ FHP-driven Space-Packet reassembly (packets span   │   │ Huffman →   │  MSU-MR
   ──────────▶│ frames) → CCSDS Space Packets by APID              │──▶│ dequant →   │─▶image
              └───────────────────────────────────────────────────┘   │ IDCT → +128 │
                                                                       └─────────────┘

The CCSDS core (ccsds.py, viterbi.py, rs.py) is sat-agnostic and is the same code the repo intends to reuse for HRPT/xRIT. The LRPT layer (lrpt.py, meteor_jpeg.py) adds only the packet reassembly and the image codec.


4. Demodulation — soft symbols from the air

4.1 What the module implements: the soft-QPSK demapper

lrpt.py provides a matched modulator/demapper pair. The modulator maps coded bit-pairs to the QPSK constellation (lrpt.py):

def qpsk_mod(coded_bits):
    b = np.asarray(coded_bits, int).reshape(-1, 2)
    return ((1 - 2*b[:,0]) + 1j*(1 - 2*b[:,1])).astype(np.complex64)   # bit0→I, bit1→Q

so 0 → +1, 1 → −1 on each axis. The demapper turns recovered IQ symbols into soft bits in [0,1] — the "probability of a 1" the Viterbi metric wants (lrpt.py):

def qpsk_soft(iq):
    scale = 1.0 / (np.mean(np.abs(iq.real)) + 1e-9)      # AGC to unit average |I|
    soft[0::2] = np.clip((1 - iq.real*scale)/2, 0, 1)    # I → even soft bits
    soft[1::2] = np.clip((1 - iq.imag*scale)/2, 0, 1)    # Q → odd  soft bits

Notes for a reimplementer:

  • Bit interleave: per symbol the I soft bit precedes the Q soft bit (soft[0::2]/soft[1::2]), which is exactly the pairing viterbi_decode reshapes with .reshape(-1,2) (viterbi.py).
  • Soft scale / AGC: amplitude is normalised by the mean magnitude of the I rail (scale = 1/mean|Re|), so the useful soft range spans roughly the unit constellation regardless of receiver gain. The mapping (1 − x·scale)/2 sends a clean +1 → 0.0 and −1 → 1.0, clipped into [0,1].
  • The soft value is a linear slice, not a true LLR; the downstream Viterbi uses a squared-Euclidean metric on 1 − 2·soft (§5.1), so a linear soft value is the natural companion.

4.2 The real-IQ receiver (qpsk_rx.py)

qpsk_rx.demod_symbols(iq, fs, sym_rate=72000.0, alpha=0.6) turns a raw oversampled 137 MHz capture — carrier frequency/phase offset, symbol-timing offset and noise — into one complex sample per QPSK symbol. The input is assumed to be centred complex baseband (any residual carrier offset is cleaned up by the Costas loop, not by a separate down-converter). Four stages, in order, with the exact constants the module uses:

  1. AGC — RMS normalise. x ← x / (√⟨|x|²⟩ + 1e-9), one global power normalisation so the loop gains below see a unit-power signal regardless of receiver gain.
  2. RRC matched filter. A root-raised-cosine pulse, roll-off α = 0.6, span ±8 symbols, normalised to unit energy (_rrc(sps, alpha=0.6, span=8)), convolved "same" at sps = fs / sym_rate samples/symbol.
  3. Gardner symbol-timing recovery (_gardner, loop gain 0.02). A fractional sample position pos advances by ~sps per symbol; each step interpolates the current symbol cur, the half-symbol-back mid and one-symbol-back prv, forms the Gardner error e = Re{ conj(mid)·(cur − prv) }, and nudges the step pos += sps − 0.02·clip(e, −2, 2). This pulls the sampling instant to the symbol centre, emitting one complex sample per symbol; the randomiser (§5.3) guarantees enough transitions for the detector.
  4. QPSK Costas carrier recovery (_costas, phase gain a1 = 0.06, frequency gain a2 = 0.002). A decision-directed 2nd-order loop derotates each symbol d = s·e^{−jφ}, with error e = sign(dᵣ)·d_i − sign(d_i)·dᵣ, then freq += a2·e; φ += freq + a1·e.

QPSK has a 4-fold (90°) phase ambiguity a Costas loop cannot resolve on its own, so decode_iq brute-forces it: it tries all four rotations (1, j, −1, −j) crossed with the two I/Q-swap options — 8 combinations — and returns the first whose soft symbols make the ASM sync and the RS codewords decode. The CCSDS layer is itself the ambiguity resolver; _soft (identical to qpsk_soft, §4.1) maps each trial to soft bits.

make_test_iq(coded_bits, fs, sym_rate=72000.0, alpha=0.6, sps_int=4, f_off=0.0, t_off=0.3, phase=0.7, noise=0.0) is the validation harness: it QPSK-modulates, RRC-shapes at 4 samples/symbol (sps_int=4, so fs2 = sym_rate·sps_int = 288 kHz), applies a carrier offset f_off (Hz) with start phase, a fractional timing offset t_off (samples) and complex AWGN of level noise, and returns (iq, fs2). The tests drive it hard — f_off up to 200 Hz, t_off to 0.7 samples, arbitrary start phase, noise to 0.3 — and still recover the four frames bit-exactly (§8.2).

OQPSK (deferred). Meteor-M2-2/-3 transmit offset QPSK — the Q rail delayed half a symbol so the constellation never crosses the origin (kinder to the transmit amplifier). qpsk_rx treats the signal as plain QPSK; true OQPSK needs the Q arm re-aligned by +½ symbol before slicing, after which the same qpsk_soft mapping applies. Meteor-M2 (the original) is plain QPSK and needs no offset. This is the one real-signal detail left for a live capture to validate (§8). Once soft symbols are in hand the rest of the chain is bit-exact and needs no tuning.


5. Decode, step by step — ccsds.decode_cadu

decode_cadu(symbols, interleave=4) (ccsds.py) is the whole CCSDS receive core. Walk it in order.

5.1 Inner code: r=½, K=7 Viterbi (viterbi.py)

The convolutional code is the CCSDS/NASA-standard rate-½, constraint-length-7 code with generator polynomials G1 = 171₈, G2 = 133₈ and G2 inverted on transmit (viterbi.py):

_G = [0o171, 0o133]        # G1, G2 (octal)
_NS = 64                   # 2^(K-1) trellis states
# per state s, input bit b:  r = (b<<6)|s
_OUT[s,b] = [ parity(r & G1),  parity(r & G2) ^ 1 ]   # G2 inverted
_NXT[s,b] =   r >> 1

The 6-bit shift register gives 64 states; each step emits two coded bits, the second complemented (the CCSDS "invert G2" convention that keeps the demod slicer from locking to an all-zero ambiguity). The encoder appends 6 zero tail bits to flush the register (viterbi.py).

viterbi_decode(soft, nbits) (viterbi.py) is a full soft-decision Viterbi:

  • Map the ideal code bits to antipodal code = 1 − 2·_OUT (0→+1, 1→−1) and the received soft bits to rx = 1 − 2·soft (viterbi.py).
  • Branch metric = squared Euclidean distance Σ (rx − code)², accumulated into path metrics pm, add-compare-select over all 64 states (viterbi.py). Lower is better; the survivor predecessor is stored in surv.
  • Traceback from state 0 (the tail bits force the terminal state to 0), reconstructing each info bit from whether the survivor transition used input 1 (viterbi.py), and return the first nbits (viterbi.py).

Validated to correct 5 % random bit flips on 300-bit blocks (test_sat_ccsds.py).

5.2 Frame sync: the Attached Sync Marker

The ASM is the 32-bit CCSDS marker 0x1ACFFC1D (ccsds.py), pre-expanded to a bit list MSB-first (ccsds.py). find_asm(bits, max_err=4) slides the marker across the decoded bitstream and returns the first offset where the Hamming distance is ≤ 4 bits (ccsds.py); the early-out abandons a position as soon as the 5th mismatch appears. The 4-bit tolerance lets sync survive the occasional Viterbi slip inside the marker itself.

The ASM bit pattern (ccsds._ASM_BITS, verified):

0x1ACFFC1D = 0001 1010 1100 1111 1111 1100 0001 1101
           = [0,0,0,1,1,0,1,0, 1,1,0,0,1,1,1,1, 1,1,1,1,1,1,0,0, 0,0,0,1,1,1,0,1]

The 32 ASM bits are not randomised (they precede the randomised block on the wire, ccsds.py), so the correlator runs directly on the Viterbi output.

5.3 Energy dispersal: the CCSDS pseudo-randomiser

Everything after the ASM is XORed with the CCSDS TM pseudo-noise sequence. The generator is an 8-bit LFSR with polynomial x⁸ + x⁷ + x⁵ + x³ + 1, seed 0xFF (ccsds.py):

reg = 0xFF
for each output byte:
    for 8 bits:
        b  = (b<<1) | ((reg>>7) & 1)                        # MSB is the PN output
        fb = (reg ^ (reg>>2) ^ (reg>>4) ^ (reg>>7)) & 1     # feedback taps
        reg = ((reg<<1) | fb) & 0xFF

randomize(data) XORs the data with pn_sequence(len(data)) and is involutive — the same call derandomises on receive (ccsds.py, applied at ccsds.py). The sequence is the canonical CCSDS one; its first eight bytes are

FF 48 0E C0 9A 0D 70 BC …               (test vector, test_sat_ccsds.py)

(the repo's first sixteen: ff480ec09a0d70bc8e2c93ada7b746ce). Only the 1020-byte block is derandomised, not the ASM.

5.4 Deinterleave + outer code: RS(255,223), dual basis, I = 4

After derandomising, the 1020-byte block is byte-deinterleaved to depth I into 4 Reed-Solomon codewords (ccsds.py):

for i in range(interleave):
    cw = bytes(block[i::interleave])[:255]     # codeword i = bytes i, i+4, i+8, …
    out.append(rs.decode_dual(cw))

so codeword i gathers every I-th byte — interleaving to depth 4 means a burst of up to 4 × 16 = 64 corrupted bytes is spread one-per-codeword and each RS(255,223) still corrects its share.

The Reed-Solomon code (rs.py) is CCSDS RS(255,223) over GF(2⁸):

  • Field polynomial 0x187 = x⁸ + x⁷ + x² + x + 1, primitive element α = 2 (rs.py). Exp/log tables are built by the standard shift-and-reduce; the first few powers are α⁰…α⁷ = 1,2,4,8,16,32,64,128, then α⁸ = 135 (0x87).

  • 32 parity symbols (NROOTS = 32, rs.py) → corrects up to t = 16 byte errors.

  • First consecutive root fcr = 112 (FCR = 112, rs.py); the generator has roots α¹¹²…α¹⁴³ (rs.py).

  • The generator polynomial produced by _generator() is self-reciprocal (palindromic) — a property of the fcr = 112 choice — with coefficients (high→low):

    1, 69, 251, 239, 38, 105, 20, 238, 218, 114, 188, 229, 112, 139, 85, 64,
    213, 64, 85, 139, 112, 229, 188, 114, 218, 238, 20, 105, 38, 239, 251, 69, 1

The dual basis. CCSDS transmits RS symbols in a dual (trace-orthogonal) basis, not the conventional power basis. rs.py builds the two 256-entry lookup tables CONV2DUAL/DUAL2CONV from the field trace Tr(x) = Σ_{k=0..7} x^{2ᵏ} (rs.py):

  • conv2dual[v]: bit j (MSB-first) = Tr(v · αʲ) (rs.py).
  • dual2conv[v]: expand the dual coordinates over the pre-solved dual basis vectors beta[j] (Gauss-Jordan inverse of the trace matrix M[i][j]=Tr(α^{i+j}), rs.py).

The two are exact inverses for all 256 symbols (asserted in test_sat_ccsds.py). decode_dual(cw_dual) simply maps the received codeword back to the conventional basis (bytes(DUAL2CONV[b] …)) and runs the ordinary decoder (rs.py). First few entries (verified): CONV2DUAL[0..3] = 0x00, 0x7F, 0xFF, 0x80; DUAL2CONV[0..3] = 0x00, 0x06, 0x0A, 0x0C.

The decoder (rs.py) is a textbook syndrome → Berlekamp-Massey → Chien search → Forney chain in the conventional basis:

  1. Syndromes S_i = cw(α^{fcr+i}), i = 0…31 (rs.py); all-zero → no errors, return the 223 data bytes (rs.py).
  2. Berlekamp-Massey builds the error-locator polynomial Λ(x) (rs.py).
  3. Chien search finds the error positions as the reciprocal roots of Λ; if the root count ≠ deg Λ the codeword is declared uncorrectable → None (rs.py).
  4. Forney computes each error magnitude from the error-evaluator Ω and the formal derivative, applying the fcr correction X^{1-fcr} (rs.py).
  5. A final syndrome re-check gates the result — non-zero syndromes after correction → None (rs.py).

Validated (test_sat_ccsds.py): corrects any 16 byte errors, rejects 20 (> t) rather than mis-decoding; and the dual-basis path corrects 16 errors too (test_sat_ccsds.py).

5.5 The result: transfer frames (and a note on the VCDU model)

decode_cadu returns a list of I = 4 frames of 223 bytes (or None per frame that failed RS), in interleave order (ccsds.py).

Divergence from canonical Meteor-M2 — stated honestly. In the on-air CCSDS/ Meteor format the four RS codewords are four sections of one 892-byte VCDU transfer frame (6-byte VCDU primary header + M_PDU, then 128 bytes of RS parity distributed by the interleaver). This repo instead treats each 223-byte RS data block as an independent frame and runs extract_packets over each one, reading an M_PDU header at its own bytes 6–7 (lrpt.py, and the test builder _vcdu at test_sat_lrpt.py prepends a 6-byte VCDU header + 2-byte FHP to each frame). The wire bytes are identical to canonical Meteor (byte k of the RS material goes to codeword k mod 4); the difference is purely how the deinterleaved output is grouped before M_PDU parsing. A reimplementer targeting real Meteor-M2 should instead concatenate the four 223-byte blocks into the single 892-byte VCDU and parse one M_PDU header at VCDU offset 6. The framing constants (ASM, RS, randomiser, I = 4) are unchanged either way.

5.6 Space-Packet reassembly — lrpt.extract_packets

CCSDS Space Packets do not respect frame boundaries: a long packet starts in one frame and finishes in the next, and a frame can begin with the tail of the previous packet followed by several whole packets. extract_packets (lrpt.py) implements the CCSDS M_PDU reassembly state machine (carrying cur, the partial packet, and want, its expected total length):

  1. Guard: a None/short frame (len(f) < 8) resets the reassembly state (lrpt.py).
  2. Read the first-header pointer fhp and packet zone zone = f[8:] (lrpt.py).
  3. FHP = 0x7FF → no packet starts here; if a packet is in progress, append the whole zone and, once len(cur) ≥ want, emit it (lrpt.py).
  4. Otherwise, first finish any in-progress packet using zone[:fhp] (the bytes before the first new header), emitting it if complete (lrpt.py).
  5. From offset i = fhp, parse consecutive Space Packets: read the 6-byte header, plen = ((z[i+4]<<8)|z[i+5]) + 1, total = 6 + plen; if the packet fits in the zone, emit it and advance; otherwise stash the fragment into cur, set want = total, and break to continue in the next frame (lrpt.py).

packet_apid(pkt) extracts the 11-bit APID (lrpt.py) used to route packets to instruments/channels. The spanning-packet logic is validated by test_sat_lrpt.py (a 120-byte packet deliberately straddling two frames is recovered whole).


6. The image: MSU-MR Meteor JPEG — meteor_jpeg.py

MSU-MR pixels are compressed with a JPEG-like codec: per 8×8 block, forward DCT → quantise (a standard luminance base table scaled by a per-packet quality byte) → zig-zag → baseline-JPEG Huffman. Decoding reverses it. This is the only piece that uses SciPy (for the separable DCT/IDCT, meteor_jpeg.py).

6.1 Forward path (encode_block, meteor_jpeg.py)

block(8×8, 0..255)
  − 128                              level shift
  DCT-II, norm="ortho"              (scipy.fft.dctn)
  ÷ quant  , round → int            per-coefficient quantisation
  [_ZZ]                             zig-zag reorder
  → DC: diff vs previous block's DC, Huffman category + sign-extended value
  → AC: run-length of zeros + (run<<4 | size) Huffman symbol + value; ZRL 0xF0;
        EOB 0x00

The DCT is orthonormal (norm="ortho"), so the same normalisation must be used on both ends (it is — meteor_jpeg.py). DC coefficients are coded differentially across blocks (dc_pred), exactly as baseline JPEG.

6.2 Inverse path (decode_block, meteor_jpeg.py)

Huffman DC symbol → size s → receive_extend → dc = dc_pred + diff
Huffman AC symbols: (run,size); size==0 → run==15 ⇒ skip 16 zeros (ZRL) else EOB
coef[k] = receive_extend(size)
inverse zig-zag:  coef[argsort(_ZZ)].reshape(8,8)
× quant  →  IDCT-II norm="ortho"  →  + 128  →  clip 0..255 → uint8

_receive_extend (meteor_jpeg.py) is the JPEG sign-extension: an s-bit magnitude whose top bit is 0 denotes a negative value v − 2ˢ + 1. _cat (meteor_jpeg.py) is the JPEG "category" = bit-length of the magnitude.

6.3 Image tiling (lrpt.py)

MSU-MR blocks arrive in row-major MCU order. assemble_image(blocks, width_blocks) lays each 8×8 block into a grayscale raster at (k // width_blocks, k % width_blocks) (lrpt.py); image_to_blocks is its inverse used by the self-consistency test (lrpt.py). compress_blocks/decompress_blocks (lrpt.py) wrap the codec with a shared quality byte and a running DC predictor across the whole frame.


7. Constants & tables (transcribed, with sources)

7.1 Sync, codes, randomiser

Constant Value Source
ASM 0x1ACFFC1D ccsds.py
ASM sync tolerance ≤ 4 bit errors ccsds.py
Conv generators G1 = 0o171, G2 = 0o133, G2 inverted viterbi.py
Conv states / tail 64 (2⁶) / 6 tail bits viterbi.py
Branch metric squared Euclidean, soft viterbi.py
PN polynomial / seed x⁸+x⁷+x⁵+x³+1 / 0xFF ccsds.py
PN first bytes FF 48 0E C0 9A 0D 70 BC test_sat_ccsds.py
RS field poly 0x187 (x⁸+x⁷+x²+x+1), α = 2 rs.py
RS fcr / nroots / t 112 / 32 / 16 rs.py
RS interleave I 4 ccsds.py
Basis dual (trace-orthogonal) rs.py

7.2 RS(255,223) generator polynomial

Produced by _generator() (rs.py), palindromic, 33 coefficients high→low:

1  69 251 239  38 105  20 238 218 114 188 229 112 139  85  64 213
64  85 139 112 229 188 114 218 238  20 105  38 239 251  69   1

7.3 Meteor JPEG tables

Base luminance quantisation (STD_LUM, meteor_jpeg.py) — the standard ITU-T T.81 luminance table, scaled per packet by qtable(quality) (meteor_jpeg.py):

16 11 10 16  24  40  51  61        scale q = (quality<50) ? 5000/quality
12 12 14 19  26  58  60  55                 : 200 − 2·quality
14 13 16 24  40  57  69  56        t = floor((STD_LUM·q + 50)/100), clipped [1,255]
14 17 22 29  51  87  80  62        quality=50 ⇒ q=100 ⇒ t == STD_LUM  (verified)
18 22 37 56  68 109 103  77
24 35 55 64  81 104 113  92        e.g. qtable(60) top row = 13 9 8 13 19 32 41 49
49 64 78 87 103 121 120 101
72 92 95 98 112 100 103  99

Zig-zag order (_ZZ, meteor_jpeg.py) — the standard JPEG scan:

0  1  8 16  9  2  3 10 17 24 32 25 18 11  4  5 12 19 26 33 40 48 41 34 27 20 13
6  7 14 21 28 35 42 49 56 57 50 43 36 29 22 15 23 30 37 44 51 58 59 52 45 38 31
39 46 53 60 61 54 47 55 62 63

Huffman tables — baseline-JPEG luminance DC and AC (ITU-T T.81 Annex K.3), transcribed at meteor_jpeg.py and expanded to code tables by _huff_tables (meteor_jpeg.py):

  • DC: _DC_BITS = [0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0] (12 symbols), _DC_VALS = 0..11 (meteor_jpeg.py).
  • AC: _AC_BITS = [0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,0x7d] (162 symbols), values _AC_VALS (meteor_jpeg.py). 0xF0 = ZRL (16 zeros), 0x00 = EOB.

_huff_tables builds canonical Huffman codes from the "number of codes of each length 1…16" (bits) and the symbol list (vals) by the standard length-order code assignment (meteor_jpeg.py).


8. Interoperability & validation

8.1 Relationship to meteor_demod / SatDump

The CCSDS core here is bit-compatible with the same layers in meteor_demod, SatDump, medet and the CCSDS 131.0-B recommendation, because it transcribes the identical constants: ASM 0x1ACFFC1D, r=½ K=7 with G1=171₈/G2=133₈ and G2 inverted, the x⁸+x⁷+x⁵+x³+1 randomiser seeded 0xFF, and dual-basis RS(255,223) fcr=112 interleaved to 4. A CADU produced by SatDump would derandomise, sync and RS-decode here (subject to the VCDU-grouping note of §5.5, which affects only how the decoded 892 bytes are re-parsed into M_PDUs, not the wire bits). This is not a SatDump port — __init__.py calls it "clean-room, from CCSDS + per-sat specs … Not a SatDump clone" — but it targets the same on-air format.

The OQPSK/QPSK front end (§4.2) is where a real deployment leans on an external SDR demodulator to produce the soft symbols this chain consumes; that boundary is the qpsk_soft interface (lrpt.py).

8.2 The test suite

test_sat_ccsds.py — the CCSDS core, self-consistent:

Test What it proves
test_viterbi_r12 conv-encode → Viterbi round-trips clean and at 5 % bit-flips
test_rs_255_223 RS corrects 16, rejects 20 errors
test_dual_basis_and_decode_dual CONV2DUAL∘DUAL2CONV = id ∀256; dual decode corrects 16
test_ccsds_pn_sequence PN matches spec vector ff480ec09a0d70bc; randomise involutive
test_cadu_full_chain encode_cadu → decode_cadu clean and at 4 % symbol flips

test_sat_lrpt.py — the LRPT layer + image:

Test What it proves
test_qpsk_ccsds_chain qpsk_mod → CCSDS → qpsk_soft recovers frames clean and under σ=0.35 AWGN
test_space_packet_reassembly a packet spanning two frames is reassembled whole; APID parse
test_msumr_image_chain image → blocks → Meteor-JPEG → image, mean abs error < 4 (lossy)
test_meteor_jpeg_block single-block DCT/Huffman round-trip, mean abs error < 6

test_sat_qpsk_rx.py — the real-IQ receiver (raw IQ → frames/image):

Test What it proves
test_decode_iq_clean make_test_iq with no impairment → decode_iq recovers the four frames
test_decode_iq_impaired recovers through timing+phase, a 200 Hz carrier offset, and heavy noise (σ=0.3)
test_qpsk_phase_ambiguity_resolved any start phase (0…6 rad) is resolved by the rotation/ASM search
test_demod_symbols_ber post-recovery symbol BER < 2 % at f_off = 150 Hz, noise 0.1
test_full_lrpt_pipeline_iq_to_image raw impaired IQ → MSU-MR image end-to-end, mean abs error < 6

The two end-to-end AWGN tests are the key validations: test_qpsk_ccsds_chain adds real Gaussian noise to the QPSK constellation and the full soft-Viterbi + ASM + derandomise + RS chain recovers the four transfer frames exactly; test_full_lrpt_pipeline_iq_to_image goes one layer further out, feeding a raw impaired IQ capture (carrier offset, timing offset, phase, noise) through qpsk_rx and the whole chain to a reconstructed image.


9. Limitations

Honest scope boundaries of the current implementation:

  • RF front end now in-repo (qpsk_rx.py). AGC → RRC → Gardner timing → Costas carrier recovery (§4.2), wired by decode_lrpt_iq and validated by make_test_iq against injected carrier/timing/noise offsets — so a raw 137 MHz capture decodes without an external SDR demodulator. The remaining RF gap is OQPSK de-offsetting (next bullet).
  • Plain QPSK mapping. qpsk_mod/qpsk_soft are the non-offset QPSK case; OQPSK (Meteor-M2-2/-3) requires the half-symbol Q re-alignment of §4.2 upstream. No differential decode or explicit 90°-ambiguity resolver is built in — the ASM search's error tolerance plus trying the constellation rotations is the intended mechanism.
  • Per-frame VCDU grouping (§5.5) diverges from canonical Meteor's single 892-byte VCDU; wire bytes match but M_PDU parsing is grouped differently.
  • Performance. The Viterbi is a pure-Python/NumPy 64-state ACS over the full block and the RS decoder is pure Python; correct and test-covered, but sized for validation, not real-time 72 kSym/s throughput without optimisation.
  • find_asm is a linear scan with a ≤4-bit threshold and returns the first match; no soft ASM correlation or lock/flywheel across CADUs.
  • Image codec assumes the baseline-JPEG luminance Huffman/quant tables and a single quality byte; it does not implement the full MSU-MR APID demux, per-channel colour assembly, or the Meteor JPEG's own restart/segment quirks beyond the 8×8 DCT block round-trip that the tests exercise.
  • SciPy dependency for the DCT/IDCT in meteor_jpeg.py (the rest of the CCSDS core is NumPy-only).

None of these are architectural; each is a bounded addition on a working, test-covered base.


10. Implementation map & references

File Role
app/radio/sat/lrpt.py QPSK soft demap, Space-Packet reassembly, MSU-MR tiling
app/radio/sat/ccsds.py CADU/VCDU framing: ASM, PN randomiser, encode/decode_cadu
app/radio/sat/viterbi.py r=½ K=7 convolutional code + soft Viterbi
app/radio/sat/rs.py RS(255,223) GF(2⁸), dual-basis transform, decode
app/radio/sat/meteor_jpeg.py 8×8 DCT / baseline-Huffman image codec
app/radio/sat/__init__.py package overview (clean-room CCSDS pipeline)
test_sat_ccsds.py, test_sat_lrpt.py self-consistent validation (§8.2)

Minimal reproduction (self-consistent, no capture needed):

from app.radio.sat import ccsds, lrpt
frames = [bytes(...223...) for _ in range(4)]        # four VCDU data frames
sym  = ccsds.encode_cadu(frames, interleave=4)       # → coded QPSK bit stream
iq   = lrpt.qpsk_mod(sym)                             # → QPSK constellation
soft = lrpt.qpsk_soft(iq)                             # ← (front end supplies this on-air)
rec  = ccsds.decode_cadu(soft, interleave=4)          # Viterbi+ASM+derand+RS → frames
pkts = lrpt.extract_packets(rec)                     # → CCSDS Space Packets

References

  1. CCSDS 131.0-B, TM Synchronization and Channel Coding — the ASM, r=½ K=7 convolutional code, RS(255,223) dual-basis, pseudo-randomiser, and interleaving this core implements.
  2. CCSDS 133.0-B, Space Packet Protocol — the Space Packet primary header and APID parsed in extract_packets/packet_apid.
  3. CCSDS 132.0-B, TM Space Data Link Protocol — the VCDU/M_PDU transfer-frame and first-header-pointer semantics.
  4. ITU-T T.81 (JPEG), Annex K — the baseline luminance quantisation table, zig-zag scan and DC/AC Huffman tables reused by meteor_jpeg.py.
  5. Meteor-M LRPT on-air format (Roscosmos/Planet weather satellites) — 137 MHz, 72 kSym/s QPSK/OQPSK, MSU-MR imager; as decoded by the community tools meteor_demod, medet and SatDump against which this chain is bit-compatible.