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
235 lines
8.9 KiB
Python
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)
|