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
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""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
|