Skip to content

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.

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:

FieldBitsDescription
A5Patchboard-selectable (default: 10101)
Core15Fixed pattern, complemented on odd frames
B6Patchboard-selectable (default: 110100)
Frame ID6Frame 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
)

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 data
all_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 engine
frames = engine.process_bits(all_bits)
print(f"Sync state: {engine.state_name}")
print(f"Frames found: {len(frames)}")

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)
}

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)
)
# Using a frame from the sync engine
frame = frames[0]
result = demux.process_frame(
frame["frame_bytes"],
meta={"frame_id": frame["frame_id"], "odd_frame": frame["odd_frame"]},
)
{
"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
}

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 present

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")

The Apollo PCM system uses an 8-bit ADC with these characteristics:

CodeVoltageMeaning
00.0 VBelow range
10.0 VZero reference
128~2.49 VMid-scale
2544.98 VFull-scale
2555.0 VOverflow (>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)
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 reassembly
high_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 value
engine = DownlinkEngine(buffer_size=400)
# Feed AGC data from demux results
for 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']}")

The first word of each 400-word buffer identifies the list type:

IDList NameMission Phase
0CM Powered FlightBoost, TLI, MCC burns
1LM Orbital ManeuversDOI, PDI, APS burns
2CM Coast/AlignmentTranslunar coast, platform alignment
3LM Coast/AlignmentLunar orbit coast
7LM Descent/AscentPowered descent, ascent
8LM Lunar Surface AlignmentSurface activities
9CM Entry UpdateRe-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}")

Here is a full example that ties all the engines together:

from apollo.downlink_decoder import DownlinkEngine
from apollo.pcm_demux import DemuxEngine
from apollo.pcm_frame_sync import FrameSyncEngine
from 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 sync
sync_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 frame
demux = 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 data
final = dl_engine.force_flush()
if final:
print(f"\nFinal partial snapshot: {final['word_count']} words")

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_decoder
from apollo.pcm_demux import pcm_demux
from 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 RateLow Rate
Bit rate51,200 bps1,600 bps
Words per frame128200
Frames per second501
Frame period~20 ms1 s
FrameSyncEngine(bit_rate=...)512001600
DemuxEngine(words_per_frame=...)128200

Key word positions in the 128-word high-rate frame:

Word(s)Content
1—432-bit sync word (A + core + B + frame ID)
5—33High-level analog sensors (0—5V range)
34AGC channel 034 (DNTM1) — telemetry high byte
35AGC channel 035 (DNTM2) — telemetry low byte
36—56Mixed analog and digital inputs
57AGC channel 057 (OUTLINK) — digital downlink
58—128Remaining telemetry channels

Access specific words by position:

# Extract just the AGC channels
dntm1 = 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']}")