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

131 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for Apollo USB system constants — verify timing relationships.
Every assertion here validates a relationship from IMPLEMENTATION_SPEC.md.
If any of these fail, the entire demod chain is built on wrong assumptions.
"""
from apollo.constants import (
COHERENT_RATIO,
DOWNLINK_FREQ_HZ,
MASTER_CLOCK_HZ,
PCM_HIGH_BIT_RATE,
PCM_HIGH_CLOCK_DIVIDER,
PCM_HIGH_FRAMES_PER_SEC,
PCM_HIGH_WORD_RATE,
PCM_HIGH_WORDS_PER_FRAME,
PCM_LOW_BIT_RATE,
PCM_LOW_CLOCK_DIVIDER,
PCM_LOW_FRAMES_PER_SEC,
PCM_LOW_WORD_RATE,
PCM_LOW_WORDS_PER_FRAME,
PCM_SUBCARRIER_HZ,
PCM_WORD_LENGTH,
SAMPLE_RATE_BASEBAND,
SCO_DEVIATION_PERCENT,
SCO_FREQUENCIES,
SUBFRAME_FRAMES,
UPLINK_FREQ_HZ,
VCO_REFERENCE_HZ,
)
class TestTimingHierarchy:
"""Verify the 512 kHz master clock divider chain (IMPL_SPEC section 5.5)."""
def test_high_rate_bit_clock(self):
assert MASTER_CLOCK_HZ / PCM_HIGH_CLOCK_DIVIDER == PCM_HIGH_BIT_RATE
def test_low_rate_bit_clock(self):
assert MASTER_CLOCK_HZ / PCM_LOW_CLOCK_DIVIDER == PCM_LOW_BIT_RATE
def test_high_rate_word_rate(self):
assert PCM_HIGH_BIT_RATE / PCM_WORD_LENGTH == PCM_HIGH_WORD_RATE
def test_low_rate_word_rate(self):
assert PCM_LOW_BIT_RATE / PCM_WORD_LENGTH == PCM_LOW_WORD_RATE
def test_high_rate_frame_rate(self):
assert PCM_HIGH_WORD_RATE / PCM_HIGH_WORDS_PER_FRAME == PCM_HIGH_FRAMES_PER_SEC
def test_low_rate_frame_rate(self):
assert PCM_LOW_WORD_RATE / PCM_LOW_WORDS_PER_FRAME == PCM_LOW_FRAMES_PER_SEC
def test_pcm_subcarrier_is_doubled_clock(self):
"""PCM subcarrier = 512 kHz × 2 = 1.024 MHz."""
assert PCM_SUBCARRIER_HZ == MASTER_CLOCK_HZ * 2
def test_subframe_duration(self):
"""50 frames × 19.968 ms ≈ 1 second (high rate)."""
frame_period = 1.0 / PCM_HIGH_FRAMES_PER_SEC
subframe_period = SUBFRAME_FRAMES * frame_period
assert abs(subframe_period - 1.0) < 0.01 # within 1%
def test_high_rate_bits_per_frame(self):
"""128 words × 8 bits = 1024 bits per frame."""
assert PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH == 1024
def test_low_rate_bits_per_frame(self):
"""200 words × 8 bits = 1600 bits per frame."""
assert PCM_LOW_WORDS_PER_FRAME * PCM_WORD_LENGTH == 1600
class TestFrequencyRelationships:
"""Verify RF frequency relationships (IMPL_SPEC section 2.1)."""
def test_coherent_ratio(self):
"""Downlink = Uplink × 240/221 (within rounding)."""
expected = UPLINK_FREQ_HZ * COHERENT_RATIO[0] / COHERENT_RATIO[1]
assert abs(DOWNLINK_FREQ_HZ - expected) < 1 # within 1 Hz
def test_vco_multiplier_chain(self):
"""VCO × 2 × 2 × 5 × 3 × 2 × 2 = 2287.5 MHz (section 2.3)."""
# 19.0625 × 2 × 2 × 5 × 3 × 2 × 2 = 19.0625 × 240 = not quite right
# Actually: 76.25 MHz modulated, then ×2 ×5 ×3 = ×30, giving 2287.5
# VCO × 4 = 76.25, then ×30 = 2287.5
modulated_freq = VCO_REFERENCE_HZ * 4 # 76.25 MHz
assert modulated_freq == 76_250_000
tx_freq = modulated_freq * 30 # ×2 ×5 ×3
assert tx_freq == DOWNLINK_FREQ_HZ
class TestSampleRateRelationships:
"""Verify sample rate choices produce integer relationships."""
def test_baseband_is_10x_master_clock(self):
assert SAMPLE_RATE_BASEBAND == MASTER_CLOCK_HZ * 10
def test_samples_per_pcm_subcarrier_cycle(self):
"""5.12 MHz / 1.024 MHz = exactly 5 samples/cycle."""
spc = SAMPLE_RATE_BASEBAND / PCM_SUBCARRIER_HZ
assert spc == 5.0
def test_samples_per_high_rate_bit(self):
"""5.12 MHz / 51.2 kHz = exactly 100 samples/bit."""
spb = SAMPLE_RATE_BASEBAND / PCM_HIGH_BIT_RATE
assert spb == 100.0
class TestSCOFrequencies:
"""Verify SCO channel specs (IMPL_SPEC section 4.3)."""
def test_nine_channels(self):
assert len(SCO_FREQUENCIES) == 9
def test_monotonically_increasing(self):
freqs = [SCO_FREQUENCIES[i] for i in range(1, 10)]
for i in range(len(freqs) - 1):
assert freqs[i] < freqs[i + 1]
def test_deviation_ranges(self):
"""Each SCO deviates ±7.5% from center."""
for ch, center in SCO_FREQUENCIES.items():
low = center * (1.0 - SCO_DEVIATION_PERCENT / 100.0)
high = center * (1.0 + SCO_DEVIATION_PERCENT / 100.0)
# Cross-check with IMPL_SPEC table values
if ch == 1:
assert abs(low - 13_412) < 1
assert abs(high - 15_588) < 1
elif ch == 9:
assert abs(low - 152_625) < 1
assert abs(high - 177_375) < 1