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 bydecode_lrpt_iq/qpsk_rx.decode_iqand exercised by amake_test_iqharness 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_rxtreats 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 pairingviterbi_decodereshapes 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)/2sends 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:
- 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. - 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"atsps = fs / sym_ratesamples/symbol. - Gardner symbol-timing recovery (
_gardner, loop gain 0.02). A fractional sample positionposadvances by ~spsper symbol; each step interpolates the current symbolcur, the half-symbol-backmidand one-symbol-backprv, forms the Gardner errore = Re{ conj(mid)·(cur − prv) }, and nudges the steppos += 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. - QPSK Costas carrier recovery (
_costas, phase gain a1 = 0.06, frequency gain a2 = 0.002). A decision-directed 2nd-order loop derotates each symbold = s·e^{−jφ}, with errore = sign(dᵣ)·d_i − sign(d_i)·dᵣ, thenfreq += 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 torx = 1 − 2·soft(viterbi.py). - Branch metric = squared Euclidean distance
Σ (rx − code)², accumulated into path metricspm, add-compare-select over all 64 states (viterbi.py). Lower is better; the survivor predecessor is stored insurv. - 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 firstnbits(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 vectorsbeta[j](Gauss-Jordan inverse of the trace matrixM[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:
- Syndromes
S_i = cw(α^{fcr+i}), i = 0…31 (rs.py); all-zero → no errors, return the 223 data bytes (rs.py). - Berlekamp-Massey builds the error-locator polynomial
Λ(x)(rs.py). - Chien search finds the error positions as the reciprocal roots of
Λ; if the root count ≠ deg Λ the codeword is declared uncorrectable →None(rs.py). - Forney computes each error magnitude from the error-evaluator
Ωand the formal derivative, applying thefcrcorrectionX^{1-fcr}(rs.py). - 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_packetsover each one, reading an M_PDU header at its own bytes 6–7 (lrpt.py, and the test builder_vcduattest_sat_lrpt.pyprepends 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):
- Guard: a
None/short frame (len(f) < 8) resets the reassembly state (lrpt.py). - Read the first-header pointer
fhpand packet zonezone = f[8:](lrpt.py). - FHP =
0x7FF→ no packet starts here; if a packet is in progress, append the whole zone and, oncelen(cur) ≥ want, emit it (lrpt.py). - Otherwise, first finish any in-progress packet using
zone[:fhp](the bytes before the first new header), emitting it if complete (lrpt.py). - 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 intocur, setwant = 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 bydecode_lrpt_iqand validated bymake_test_iqagainst 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_softare 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_asmis 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
- 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.
- CCSDS 133.0-B, Space Packet Protocol — the Space Packet primary header and APID parsed in
extract_packets/packet_apid. - CCSDS 132.0-B, TM Space Data Link Protocol — the VCDU/M_PDU transfer-frame and first-header-pointer semantics.
- 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. - 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.