gr-apollo/tests/test_pcm_demux.py
Ryan Malloy 0ee7ff0ad7 Implement full Apollo USB downlink decoder chain
Complete signal processing pipeline from complex baseband to decoded
PCM telemetry, verified against the 1965 NAA Study Guide (A-624):

Core demod (Phase 1):
  - PM demodulator with carrier PLL recovery
  - 1.024 MHz subcarrier extractor (bandpass + downconvert)
  - BPSK demodulator with Costas loop + symbol sync
  - Convenience hier_block2 combining subcarrier + BPSK

PCM frame processing (Phase 2):
  - 32-bit frame sync with Hamming distance correlator
  - SEARCH/VERIFY/LOCKED state machine, complement-on-odd handling
  - Frame demultiplexer (128-word, A/D voltage scaling)
  - AGC downlink decoder (15-bit word reassembly from DNTM1/DNTM2)

Voice and analog (Phase 3):
  - 1.25 MHz FM voice subcarrier demod to 8 kHz audio
  - SCO demodulator for 9 analog sensor channels (14.5-165 kHz)

Virtual AGC integration (Phase 4):
  - TCP bridge client with auto-reconnect and channel filtering
  - DSKY uplink encoder (VERB/NOUN/DATA command sequences)

Top-level receiver (Phase 5):
  - usb_downlink_receiver hier_block2: one block, complex in, PDUs out
  - 14 GRC block YAML definitions for GNU Radio Companion
  - Example scripts for signal analysis and full-chain demo

Infrastructure:
  - constants.py with all timing/frequency/frame parameters
  - protocol.py for sync word + AGC packet encode/decode
  - Synthetic USB signal generator for testing
  - 222 tests passing, ruff lint clean
2026-02-20 13:18:42 -07:00

235 lines
8.9 KiB
Python

"""Tests for Apollo PCM frame demultiplexer."""
import pytest
from apollo.constants import (
AGC_CH_DNTM1,
AGC_CH_DNTM2,
AGC_CH_OUTLINK,
PCM_HIGH_WORDS_PER_FRAME,
PCM_SYNC_WORD_LENGTH,
PCM_WORD_LENGTH,
)
from apollo.pcm_demux import AGC_WORD_POSITIONS, DemuxEngine
from apollo.protocol import (
adc_to_voltage,
generate_sync_word,
)
def _make_test_frame(
frame_id: int = 1,
odd: bool = False,
fill_value: int = 0x55,
words_per_frame: int = PCM_HIGH_WORDS_PER_FRAME,
) -> bytes:
"""Build a raw frame byte array with known contents.
Words 1-4 are the sync word; words 5+ are filled with fill_value
(or custom data placed at specific positions).
"""
sync_word = generate_sync_word(frame_id=frame_id, odd=odd)
sync_bytes = sync_word.to_bytes(4, byteorder="big")
data_bytes = bytes([fill_value] * (words_per_frame - 4))
return sync_bytes + data_bytes
class TestWordExtraction:
"""Test individual word extraction from known frames."""
def test_extract_data_words(self):
"""Words 5-128 should have the expected fill value."""
frame = _make_test_frame(fill_value=0xAB)
engine = DemuxEngine(output_format="raw")
result = engine.process_frame(frame)
for word in result["words"]:
assert word["raw_value"] == 0xAB
def test_extract_specific_word(self):
"""extract_word should return the correct value at a given position."""
frame = bytearray(_make_test_frame(fill_value=0x00))
# Place a known value at word position 50 (0-indexed: 49)
frame[49] = 0xDE
frame = bytes(frame)
engine = DemuxEngine(output_format="raw")
word = engine.extract_word(frame, word_position=50)
assert word["raw_value"] == 0xDE
assert word["position"] == 50
def test_sync_word_parsing(self):
"""The parsed sync word fields should match the generated values."""
frame = _make_test_frame(frame_id=25, odd=False)
engine = DemuxEngine()
result = engine.process_frame(frame)
sync = result["sync"]
assert sync["frame_id"] == 25
# Even frame: core should match the default
from apollo.constants import DEFAULT_SYNC_CORE
assert sync["core"] == DEFAULT_SYNC_CORE
def test_word_count(self):
"""Number of data words should be (words_per_frame - 4)."""
frame = _make_test_frame()
engine = DemuxEngine()
result = engine.process_frame(frame)
expected_data_words = PCM_HIGH_WORDS_PER_FRAME - (PCM_SYNC_WORD_LENGTH // PCM_WORD_LENGTH)
assert len(result["words"]) == expected_data_words
def test_word_positions_are_one_indexed(self):
"""All word positions should be 1-indexed, starting at 5."""
frame = _make_test_frame()
engine = DemuxEngine()
result = engine.process_frame(frame)
positions = [w["position"] for w in result["words"]]
assert positions[0] == 5
assert positions[-1] == PCM_HIGH_WORDS_PER_FRAME
def test_raw_frame_passthrough(self):
"""The raw_frame field should contain the original frame bytes."""
frame = _make_test_frame(fill_value=0x42)
engine = DemuxEngine()
result = engine.process_frame(frame)
assert result["raw_frame"] == frame
def test_metadata_passthrough(self):
"""Metadata from the frame sync should be passed through."""
frame = _make_test_frame()
engine = DemuxEngine()
meta = {"frame_id": 7, "odd_frame": True}
result = engine.process_frame(frame, meta=meta)
assert result["meta"]["frame_id"] == 7
assert result["meta"]["odd_frame"] is True
class TestADVoltageScaling:
"""Test A/D converter voltage scaling (section 5.3)."""
def test_scaled_format_includes_voltage(self):
"""With output_format='scaled', words should include voltage field."""
frame = _make_test_frame(fill_value=128)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
assert "voltage" in result["words"][0]
def test_raw_format_no_voltage(self):
"""With output_format='raw', words should NOT have voltage field."""
frame = _make_test_frame(fill_value=128)
engine = DemuxEngine(output_format="raw")
result = engine.process_frame(frame)
assert "voltage" not in result["words"][0]
def test_voltage_zero_code(self):
"""ADC code 1 should map to 0V."""
frame = _make_test_frame(fill_value=1)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
assert result["words"][0]["voltage"] == 0.0
def test_voltage_fullscale(self):
"""ADC code 254 should map to 4.98V."""
frame = _make_test_frame(fill_value=254)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
assert abs(result["words"][0]["voltage"] - 4.98) < 0.001
def test_voltage_midscale(self):
"""ADC code ~128 should be roughly 2.5V."""
frame = _make_test_frame(fill_value=128)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
v = result["words"][0]["voltage"]
assert abs(v - 2.5) < 0.1
def test_voltage_consistency_with_protocol(self):
"""Voltage values should match protocol.adc_to_voltage."""
frame = _make_test_frame(fill_value=200)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
expected = adc_to_voltage(200)
assert result["words"][0]["voltage"] == expected
def test_low_level_voltage_included(self):
"""Scaled format should also include low-level voltage."""
frame = _make_test_frame(fill_value=128)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
assert "voltage_low_level" in result["words"][0]
expected = adc_to_voltage(128, low_level=True)
assert result["words"][0]["voltage_low_level"] == expected
class TestAGCChannelExtraction:
"""Test extraction of AGC downlink channels (34, 35, 57)."""
def test_agc_channels_extracted(self):
"""AGC channel words should appear in agc_data output."""
frame = bytearray(_make_test_frame(fill_value=0x00))
# Place known values at AGC word positions
frame[33] = 0xAA # word 34 (ch 34, DNTM1)
frame[34] = 0xBB # word 35 (ch 35, DNTM2)
frame[56] = 0xCC # word 57 (ch 57, OUTLINK)
frame = bytes(frame)
engine = DemuxEngine()
result = engine.process_frame(frame)
agc = result["agc_data"]
assert len(agc) == 3
# Sort by channel for predictable order
agc_by_ch = {a["channel"]: a for a in agc}
assert agc_by_ch[AGC_CH_DNTM1]["raw_value"] == 0xAA
assert agc_by_ch[AGC_CH_DNTM2]["raw_value"] == 0xBB
assert agc_by_ch[AGC_CH_OUTLINK]["raw_value"] == 0xCC
def test_agc_word_positions_correct(self):
"""AGC entries should have correct 1-indexed word positions."""
frame = _make_test_frame()
engine = DemuxEngine()
result = engine.process_frame(frame)
for agc in result["agc_data"]:
ch = agc["channel"]
# Check word position matches expected 0-indexed + 1
expected_positions = [p + 1 for p in AGC_WORD_POSITIONS[ch]]
assert agc["word_position"] in expected_positions
def test_agc_voltage_scaling_when_enabled(self):
"""AGC data should include voltage when format is 'scaled'."""
frame = bytearray(_make_test_frame(fill_value=0x00))
frame[33] = 200 # DNTM1
frame = bytes(frame)
engine = DemuxEngine(output_format="scaled")
result = engine.process_frame(frame)
dntm1_entries = [a for a in result["agc_data"] if a["channel"] == AGC_CH_DNTM1]
assert len(dntm1_entries) == 1
assert "voltage" in dntm1_entries[0]
assert dntm1_entries[0]["voltage"] == adc_to_voltage(200)
class TestInvalidInput:
"""Test error handling for bad input."""
def test_short_frame_rejected(self):
"""Frame shorter than words_per_frame should raise ValueError."""
engine = DemuxEngine()
with pytest.raises(ValueError, match="Frame too short"):
engine.process_frame(b"\x00" * 10)
def test_invalid_output_format(self):
"""Invalid output_format should raise ValueError."""
with pytest.raises(ValueError, match="Invalid output_format"):
DemuxEngine(output_format="bogus")
def test_extract_word_out_of_range(self):
"""Word position outside 1-128 should raise ValueError."""
engine = DemuxEngine()
frame = _make_test_frame()
with pytest.raises(ValueError):
engine.extract_word(frame, word_position=0)
with pytest.raises(ValueError):
engine.extract_word(frame, word_position=129)