Work with PCM Telemetry
The Apollo PCM telemetry system encodes spacecraft sensor data, AGC state, and digital downlink information into structured frames transmitted at 50 frames per second. This guide shows how to go from a raw bit stream to named telemetry fields using the gr-apollo engines.
PCM Frame Structure
Section titled “PCM Frame Structure”Each high-rate frame contains 128 words of 8 bits each (1024 bits total):
flowchart LR
subgraph Frame ["128-word PCM Frame"]
direction LR
S["Sync Word<br/>Words 1-4<br/>(32 bits)"]
D["Data Words<br/>Words 5-128<br/>(124 words)"]
end
The 32-bit sync word encodes four fields:
| Field | Bits | Description |
|---|---|---|
| A | 5 | Patchboard-selectable (default: 10101) |
| Core | 15 | Fixed pattern, complemented on odd frames |
| B | 6 | Patchboard-selectable (default: 110100) |
| Frame ID | 6 | Frame number within subframe (1—50) |
Fifty frames make one subframe (1 second at high rate). The frame ID counts from 1 to 50 within each subframe.
Step 1: Frame Synchronization with FrameSyncEngine
Section titled “Step 1: Frame Synchronization with FrameSyncEngine”The FrameSyncEngine is a pure-Python class that accepts individual bits (0 or 1) and outputs complete frames. No GNU Radio installation required.
from apollo.pcm_frame_sync import FrameSyncEngine
engine = FrameSyncEngine( bit_rate=51200, # High rate (or 1600 for low rate) max_bit_errors=3, # Hamming distance threshold verify_count=2, # Consecutive hits to confirm lock miss_limit=3, # Consecutive misses before re-searching)Processing a Bit Stream
Section titled “Processing a Bit Stream”Feed bits from any source — a BPSK demodulator, a file, or the test signal generator:
from apollo.usb_signal_gen import generate_pcm_frame
# Generate 5 frames of known dataall_bits = []for i in range(5): frame_id = i + 1 odd = (frame_id % 2) == 1 bits = generate_pcm_frame(frame_id=frame_id, odd=odd) all_bits.extend(bits)
# Process through the sync engineframes = engine.process_bits(all_bits)
print(f"Sync state: {engine.state_name}")print(f"Frames found: {len(frames)}")Frame Output Format
Section titled “Frame Output Format”Each frame returned by process_bits() is a dictionary:
{ "frame_id": 1, # 1-50 within subframe "odd_frame": True, # Odd frames have complemented sync core "sync_confidence": 29, # Bits correct out of 32 "timestamp": 1708444800.123, # time.time() when frame was emitted "state": "LOCKED", # Engine state when emitted "frame_bytes": b'\xab\xce...', # Complete frame as bytes (128 bytes) "frame_bits": [1, 0, 1, ...], # Complete frame as bit list (1024 bits)}Resetting the Engine
Section titled “Resetting the Engine”To reprocess from a clean state:
engine.reset()print(engine.state_name) # "SEARCH"Step 2: Frame Demultiplexing with DemuxEngine
Section titled “Step 2: Frame Demultiplexing with DemuxEngine”The DemuxEngine takes complete frame bytes and separates them into individual telemetry words with optional voltage scaling.
from apollo.pcm_demux import DemuxEngine
demux = DemuxEngine( output_format="scaled", # "raw", "scaled", or "engineering" words_per_frame=128, # 128 (high rate) or 200 (low rate))Processing a Frame
Section titled “Processing a Frame”# Using a frame from the sync engineframe = frames[0]result = demux.process_frame( frame["frame_bytes"], meta={"frame_id": frame["frame_id"], "odd_frame": frame["odd_frame"]},)Demux Output Structure
Section titled “Demux Output Structure”{ "sync": { "a_bits": 21, # 5-bit A field (decimal) "core": 29404, # 15-bit core (decimal) "b_bits": 52, # 6-bit B field (decimal) "frame_id": 1, # 6-bit frame ID "word": 0xABCE_D401, # Raw 32-bit sync word }, "words": [ { "position": 5, # 1-indexed word position "raw_value": 128, # 8-bit ADC code "voltage": 2.49, # Scaled voltage (if format is "scaled") "voltage_low_level": 0.020, # Low-level input voltage }, # ... words 5 through 128 ], "agc_data": [ { "channel": 28, # Decimal channel number "channel_octal": "034", # Octal for reference "raw_value": 42, # 8-bit value "word_position": 34, # 1-indexed position in frame "voltage": 0.80, # Scaled (if "scaled" format) }, # Entries for channels 034, 035, 057 ], "raw_frame": b'\xab\xce...', # Original frame bytes "meta": {"frame_id": 1, ...}, # Pass-through metadata}Output Formats
Section titled “Output Formats”Returns 8-bit integer values with no conversion:
demux = DemuxEngine(output_format="raw")result = demux.process_frame(frame_bytes)word = result["words"][0]print(word["raw_value"]) # 128# No "voltage" key presentApplies the ADC voltage conversion (code 1 = 0V, code 254 = 4.98V):
demux = DemuxEngine(output_format="scaled")result = demux.process_frame(frame_bytes)word = result["words"][0]print(word["raw_value"]) # 128print(f"{word['voltage']:.2f}") # 2.49Same as “scaled” (engineering-unit named fields are planned for a future release):
demux = DemuxEngine(output_format="engineering")Accessing a Specific Word
Section titled “Accessing a Specific Word”Extract a single word by its 1-indexed position without processing the entire frame:
word = demux.extract_word(frame_bytes, word_position=34)print(f"Word 34: code={word['raw_value']}, voltage={word['voltage']:.3f}V")Step 3: ADC Voltage Conversion
Section titled “Step 3: ADC Voltage Conversion”The Apollo PCM system uses an 8-bit ADC with these characteristics:
| Code | Voltage | Meaning |
|---|---|---|
| 0 | 0.0 V | Below range |
| 1 | 0.0 V | Zero reference |
| 128 | ~2.49 V | Mid-scale |
| 254 | 4.98 V | Full-scale |
| 255 | 5.0 V | Overflow (>5V) |
The step size is 19.7 mV per LSB. For low-level inputs (0—40 mV range), the hardware applies x125 gain before the ADC:
from apollo.protocol import adc_to_voltage
# Standard high-level input (0-5V)voltage = adc_to_voltage(128)print(f"{voltage:.3f}V") # 2.492V
# Low-level input (0-40 mV, with x125 gain removed)voltage_ll = adc_to_voltage(128, low_level=True)print(f"{voltage_ll:.4f}V") # 0.0199V (19.9 mV)Step 4: AGC Downlink Decoding with DownlinkEngine
Section titled “Step 4: AGC Downlink Decoding with DownlinkEngine”Channels 034 and 035 in the PCM frame carry 8-bit halves of 15-bit AGC words. The DownlinkEngine reassembles them into full words and groups them into downlink list snapshots.
from apollo.downlink_decoder import DownlinkEngine, reassemble_agc_word
# Manual word reassemblyhigh_byte = 0x2A # From channel 034 (DNTM1)low_byte = 0x55 # From channel 035 (DNTM2)agc_word = reassemble_agc_word(high_byte, low_byte)print(f"AGC word: {agc_word:05o}o ({agc_word})") # 15-bit valueUsing the Engine
Section titled “Using the Engine”engine = DownlinkEngine(buffer_size=400)
# Feed AGC data from demux resultsfor frame_result in all_demux_results: for agc in frame_result["agc_data"]: snapshot = engine.feed_agc_word(agc["channel"], agc["raw_value"]) if snapshot is not None: print(f"List type: {snapshot['list_name']}") print(f"Words collected: {snapshot['word_count']}")Downlink List Types
Section titled “Downlink List Types”The first word of each 400-word buffer identifies the list type:
| ID | List Name | Mission Phase |
|---|---|---|
| 0 | CM Powered Flight | Boost, TLI, MCC burns |
| 1 | LM Orbital Maneuvers | DOI, PDI, APS burns |
| 2 | CM Coast/Alignment | Translunar coast, platform alignment |
| 3 | LM Coast/Alignment | Lunar orbit coast |
| 7 | LM Descent/Ascent | Powered descent, ascent |
| 8 | LM Lunar Surface Alignment | Surface activities |
| 9 | CM Entry Update | Re-entry corridor |
from apollo.downlink_decoder import identify_list_type
list_id, list_name = identify_list_type(snapshot["words"][0])print(f"List {list_id}: {list_name}")Complete Pipeline: Bits to Named Fields
Section titled “Complete Pipeline: Bits to Named Fields”Here is a full example that ties all the engines together:
from apollo.downlink_decoder import DownlinkEnginefrom apollo.pcm_demux import DemuxEnginefrom apollo.pcm_frame_sync import FrameSyncEnginefrom apollo.usb_signal_gen import generate_pcm_frame
# Step 1: Generate test data (5 frames with known payloads)all_bits = []for i in range(5): frame_id = i + 1 odd = (frame_id % 2) == 1 # Payload: word positions encoded as byte values payload = bytes(range(5, 129)) # Words 5-128 = values 5-128 bits = generate_pcm_frame(frame_id=frame_id, odd=odd, data=payload) all_bits.extend(bits)
# Step 2: Frame syncsync_engine = FrameSyncEngine(bit_rate=51200, max_bit_errors=3)frames = sync_engine.process_bits(all_bits)print(f"Sync state: {sync_engine.state_name}")print(f"Frames decoded: {len(frames)}")
# Step 3: Demux each framedemux = DemuxEngine(output_format="scaled", words_per_frame=128)dl_engine = DownlinkEngine()
for frame in frames: result = demux.process_frame(frame["frame_bytes"])
# Print some telemetry words print(f"\nFrame {result['sync']['frame_id']}:") for word in result["words"][:5]: print(f" Word {word['position']:3d}: " f"code={word['raw_value']:3d} " f"voltage={word['voltage']:.3f}V")
# Step 4: Feed AGC channels into downlink decoder for agc in result["agc_data"]: print(f" AGC ch {agc['channel_octal']}: " f"word {agc['word_position']}, " f"raw={agc['raw_value']}") snapshot = dl_engine.feed_agc_word(agc["channel"], agc["raw_value"]) if snapshot: print(f" >>> Downlink snapshot: {snapshot['list_name']}")
# Flush any remaining buffered AGC datafinal = dl_engine.force_flush()if final: print(f"\nFinal partial snapshot: {final['word_count']} words")GNU Radio Blocks
Section titled “GNU Radio Blocks”When GNU Radio is available, the same processing chain runs as GR blocks connected via message ports:
flowchart LR
A["pcm_frame_sync<br/>(byte stream in)"] -->|"frames (PDU)"| B["pcm_demux"]
B -->|"telemetry (PDU)"| C["Per-word data"]
B -->|"agc_data (PDU)"| D["downlink_decoder"]
B -->|"raw_frame (PDU)"| E["Full frame"]
D -->|"downlink (PDU)"| F["Decoded lists"]
from gnuradio import gr
from apollo.downlink_decoder import downlink_decoderfrom apollo.pcm_demux import pcm_demuxfrom apollo.pcm_frame_sync import pcm_frame_sync
tb = gr.top_block()
fsync = pcm_frame_sync(bit_rate=51200, max_bit_errors=3)demux = pcm_demux(output_format="scaled")decoder = downlink_decoder(buffer_size=400)
tb.msg_connect(fsync, "frames", demux, "frames")tb.msg_connect(demux, "agc_data", decoder, "agc_data")The GR blocks use the same FrameSyncEngine, DemuxEngine, and DownlinkEngine internally — the message-port wrappers just handle PDU serialization.
High Rate vs. Low Rate
Section titled “High Rate vs. Low Rate”| High Rate | Low Rate | |
|---|---|---|
| Bit rate | 51,200 bps | 1,600 bps |
| Words per frame | 128 | 200 |
| Frames per second | 50 | 1 |
| Frame period | ~20 ms | 1 s |
FrameSyncEngine(bit_rate=...) | 51200 | 1600 |
DemuxEngine(words_per_frame=...) | 128 | 200 |
Word Position Reference
Section titled “Word Position Reference”Key word positions in the 128-word high-rate frame:
| Word(s) | Content |
|---|---|
| 1—4 | 32-bit sync word (A + core + B + frame ID) |
| 5—33 | High-level analog sensors (0—5V range) |
| 34 | AGC channel 034 (DNTM1) — telemetry high byte |
| 35 | AGC channel 035 (DNTM2) — telemetry low byte |
| 36—56 | Mixed analog and digital inputs |
| 57 | AGC channel 057 (OUTLINK) — digital downlink |
| 58—128 | Remaining telemetry channels |
Access specific words by position:
# Extract just the AGC channelsdntm1 = demux.extract_word(frame_bytes, 34)dntm2 = demux.extract_word(frame_bytes, 35)outlink = demux.extract_word(frame_bytes, 57)
print(f"DNTM1: {dntm1['raw_value']}")print(f"DNTM2: {dntm2['raw_value']}")print(f"OUTLINK: {outlink['raw_value']}")