gr-apollo/tests/test_pcm_frame_sync.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

310 lines
12 KiB
Python

"""Tests for Apollo PCM frame synchronizer."""
import numpy as np
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_LOW_BIT_RATE,
PCM_LOW_WORDS_PER_FRAME,
PCM_SYNC_WORD_LENGTH,
PCM_WORD_LENGTH,
)
from apollo.pcm_frame_sync import (
STATE_LOCKED,
STATE_SEARCH,
STATE_VERIFY,
FrameSyncEngine,
_bits_to_bytes,
_hamming_distance,
)
from apollo.usb_signal_gen import generate_pcm_frame
def _make_frame_bits(frame_id: int = 1, odd: bool = False, data: bytes | None = None):
"""Helper: generate a complete frame as a bit list."""
return generate_pcm_frame(frame_id=frame_id, odd=odd, data=data)
def _make_multi_frame_bits(n_frames: int = 5, data: bytes | None = None) -> list[int]:
"""Helper: generate N consecutive frames concatenated as a bit stream."""
all_bits = []
for i in range(n_frames):
fid = (i % 50) + 1
odd = (fid % 2) == 1
all_bits.extend(_make_frame_bits(frame_id=fid, odd=odd, data=data))
return all_bits
class TestHammingDistance:
"""Unit tests for the Hamming distance helper."""
def test_identical(self):
assert _hamming_distance([1, 0, 1, 0], [1, 0, 1, 0]) == 0
def test_all_different(self):
assert _hamming_distance([1, 1, 1, 1], [0, 0, 0, 0]) == 4
def test_one_error(self):
assert _hamming_distance([1, 0, 1, 0], [1, 0, 0, 0]) == 1
class TestBitsToBytes:
"""Unit tests for bit-to-byte packing."""
def test_single_byte(self):
assert _bits_to_bytes([1, 0, 1, 0, 1, 0, 1, 0]) == bytes([0xAA])
def test_two_bytes(self):
bits = [1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
assert _bits_to_bytes(bits) == bytes([0xF0, 0x0F])
def test_zero_byte(self):
assert _bits_to_bytes([0, 0, 0, 0, 0, 0, 0, 0]) == bytes([0x00])
def test_ff_byte(self):
assert _bits_to_bytes([1, 1, 1, 1, 1, 1, 1, 1]) == bytes([0xFF])
class TestSyncAcquisitionFromRandomOffset:
"""Test that the engine can find sync from an arbitrary bit offset."""
def test_acquire_with_no_offset(self):
"""Frame starting at bit 0 should be acquired."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
bits = _make_multi_frame_bits(n_frames=4)
frames = engine.process_bits(bits)
assert len(frames) >= 1, "Should acquire at least one frame"
def test_acquire_with_random_prefix(self):
"""Random bits before first sync should be skipped."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
np.random.seed(77)
garbage = list(np.random.randint(0, 2, size=200))
frame_bits = _make_multi_frame_bits(n_frames=4)
bits = garbage + frame_bits
frames = engine.process_bits(bits)
assert len(frames) >= 1, "Should find sync after random prefix"
def test_acquire_with_large_offset(self):
"""Even with a large garbage prefix, sync should be found."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
np.random.seed(88)
garbage = list(np.random.randint(0, 2, size=2000))
frame_bits = _make_multi_frame_bits(n_frames=5)
bits = garbage + frame_bits
frames = engine.process_bits(bits)
assert len(frames) >= 1
class TestComplementOnOdd:
"""Verify that the engine handles odd-frame core complementing."""
def test_even_frame_detected(self):
"""Even frame (normal core) should be detected."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=0)
bits = _make_frame_bits(frame_id=2, odd=False)
# Need enough frames to get through VERIFY
bits2 = _make_frame_bits(frame_id=3, odd=True)
bits3 = _make_frame_bits(frame_id=4, odd=False)
bits4 = _make_frame_bits(frame_id=5, odd=True)
frames = engine.process_bits(bits + bits2 + bits3 + bits4)
assert len(frames) >= 1
# First frame should be even
assert frames[0]["odd_frame"] is False
def test_odd_frame_detected(self):
"""Odd frame (complemented core) should be detected."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=0)
bits = _make_frame_bits(frame_id=1, odd=True)
bits2 = _make_frame_bits(frame_id=2, odd=False)
bits3 = _make_frame_bits(frame_id=3, odd=True)
bits4 = _make_frame_bits(frame_id=4, odd=False)
frames = engine.process_bits(bits + bits2 + bits3 + bits4)
assert len(frames) >= 1
assert frames[0]["odd_frame"] is True
def test_alternating_odd_even(self):
"""Multiple consecutive frames should alternate odd/even detection."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=0)
all_bits = []
for i in range(6):
fid = i + 1
odd = (fid % 2) == 1
all_bits.extend(_make_frame_bits(frame_id=fid, odd=odd))
frames = engine.process_bits(all_bits)
assert len(frames) >= 3
for frame in frames:
fid = frame["frame_id"]
expected_odd = (fid % 2) == 1
assert frame["odd_frame"] == expected_odd, (
f"Frame {fid}: expected odd={expected_odd}, got {frame['odd_frame']}"
)
class TestStateMachineTransitions:
"""Test SEARCH -> VERIFY -> LOCKED transitions."""
def test_starts_in_search(self):
engine = FrameSyncEngine()
assert engine.state == STATE_SEARCH
def test_moves_to_verify_on_first_match(self):
"""First sync match should transition to VERIFY."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
bits = _make_frame_bits(frame_id=1, odd=True)
# Process just the sync word to trigger SEARCH -> VERIFY
engine.process_bits(bits[:PCM_SYNC_WORD_LENGTH])
assert engine.state == STATE_VERIFY
def test_reaches_locked_after_verify(self):
"""After verify_count consecutive hits, should reach LOCKED."""
engine = FrameSyncEngine(
bit_rate=PCM_HIGH_BIT_RATE,
max_bit_errors=3,
verify_count=2,
)
all_bits = _make_multi_frame_bits(n_frames=5)
engine.process_bits(all_bits)
assert engine.state == STATE_LOCKED
def test_drops_to_search_on_consecutive_misses(self):
"""Corrupting sync words should eventually drop back to SEARCH."""
engine = FrameSyncEngine(
bit_rate=PCM_HIGH_BIT_RATE,
max_bit_errors=0, # strict matching
miss_limit=2,
verify_count=2,
)
# First, establish lock with clean frames
clean = _make_multi_frame_bits(n_frames=5)
engine.process_bits(clean)
assert engine.state == STATE_LOCKED
# Now feed frames with completely corrupted sync words
frame_len = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH
for _ in range(3):
np.random.seed(42)
bad_frame = list(np.random.randint(0, 2, size=frame_len))
engine.process_bits(bad_frame)
assert engine.state == STATE_SEARCH
class TestMaxBitErrors:
"""Test Hamming distance threshold for sync detection."""
def test_exact_match_required(self):
"""With max_bit_errors=0, only exact sync matches should work."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=0)
bits = _make_multi_frame_bits(n_frames=4)
frames = engine.process_bits(bits)
assert len(frames) >= 1
# All frames should have full confidence
for f in frames:
assert f["sync_confidence"] == PCM_SYNC_WORD_LENGTH
def test_tolerates_bit_errors(self):
"""With max_bit_errors=3, frames with up to 3 flipped sync bits should work."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
# Generate a clean frame and flip 2 bits in the sync word
bits = _make_frame_bits(frame_id=2, odd=False)
bits[5] ^= 1 # flip bit 5
bits[10] ^= 1 # flip bit 10
# Append more clean frames so the engine can VERIFY/LOCK
bits2 = _make_frame_bits(frame_id=3, odd=True)
bits3 = _make_frame_bits(frame_id=4, odd=False)
bits4 = _make_frame_bits(frame_id=5, odd=True)
frames = engine.process_bits(bits + bits2 + bits3 + bits4)
assert len(frames) >= 1
def test_rejects_too_many_errors(self):
"""With max_bit_errors=0, a single flipped sync bit should prevent match."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=0)
# Generate frames with 1 corrupted sync bit each
all_bits = []
for i in range(4):
fid = i + 1
odd = (fid % 2) == 1
frame = _make_frame_bits(frame_id=fid, odd=odd)
frame[3] ^= 1 # flip one bit in sync
all_bits.extend(frame)
frames = engine.process_bits(all_bits)
# With strict matching and corrupted syncs, should get no frames
assert len(frames) == 0
class TestKnownPayloadRoundtrip:
"""Test that payload data survives the frame sync extraction."""
def test_payload_recovery(self):
"""Known payload should be recoverable from the output frame."""
np.random.seed(42)
payload = bytes(np.random.randint(0, 256, size=124, dtype=np.uint8))
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
# Generate 4 frames with the same payload to allow lock acquisition
all_bits = []
for i in range(4):
fid = i + 1
odd = (fid % 2) == 1
all_bits.extend(_make_frame_bits(frame_id=fid, odd=odd, data=payload))
frames = engine.process_bits(all_bits)
assert len(frames) >= 1
# Check that the payload portion (bytes 4 onward) of at least one frame matches
found_match = False
for f in frames:
frame_bytes = f["frame_bytes"]
# Words 5-128 are bytes 4-127 (0-indexed)
recovered_payload = frame_bytes[4:128]
if recovered_payload == payload:
found_match = True
break
assert found_match, "Payload not recovered correctly from any emitted frame"
def test_frame_id_in_output(self):
"""Output metadata should contain the correct frame ID."""
engine = FrameSyncEngine(bit_rate=PCM_HIGH_BIT_RATE, max_bit_errors=3)
all_bits = _make_multi_frame_bits(n_frames=5)
frames = engine.process_bits(all_bits)
assert len(frames) >= 1
for f in frames:
assert 1 <= f["frame_id"] <= 50
class TestLowRateFrames:
"""Test with 200-word low-rate frames."""
def test_low_rate_frame_length(self):
"""Low-rate engine should expect 200-word frames."""
engine = FrameSyncEngine(bit_rate=PCM_LOW_BIT_RATE, max_bit_errors=3)
assert engine.bits_per_frame == PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH
def test_low_rate_acquisition(self):
"""Should acquire low-rate frames (200 words each)."""
engine = FrameSyncEngine(bit_rate=PCM_LOW_BIT_RATE, max_bit_errors=3)
all_bits = []
for _i in range(4):
frame = generate_pcm_frame(
frame_id=1,
odd=True,
words_per_frame=PCM_LOW_WORDS_PER_FRAME,
)
all_bits.extend(frame)
frames = engine.process_bits(all_bits)
assert len(frames) >= 1
# Frame should be 200 bytes
for f in frames:
assert len(f["frame_bytes"]) == PCM_LOW_WORDS_PER_FRAME