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
90 lines
2.9 KiB
Python
90 lines
2.9 KiB
Python
"""Tests for the subcarrier extractor block."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
try:
|
|
from gnuradio import blocks, gr
|
|
|
|
HAS_GNURADIO = True
|
|
except ImportError:
|
|
HAS_GNURADIO = False
|
|
|
|
from apollo.constants import PCM_SUBCARRIER_HZ, SAMPLE_RATE_BASEBAND
|
|
|
|
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
|
|
|
|
|
|
class TestSubcarrierExtract:
|
|
"""Test subcarrier extraction and frequency translation."""
|
|
|
|
def test_passes_target_frequency(self):
|
|
"""A tone at the center frequency should pass through."""
|
|
from apollo.subcarrier_extract import subcarrier_extract
|
|
|
|
tb = gr.top_block()
|
|
sample_rate = SAMPLE_RATE_BASEBAND
|
|
n_samples = 50000
|
|
|
|
# Generate a pure tone at 1.024 MHz
|
|
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
|
tone = np.cos(2 * np.pi * PCM_SUBCARRIER_HZ * t).astype(np.float32)
|
|
|
|
src = blocks.vector_source_f(tone.tolist())
|
|
extract = subcarrier_extract(
|
|
center_freq=PCM_SUBCARRIER_HZ,
|
|
bandwidth=150_000,
|
|
sample_rate=sample_rate,
|
|
)
|
|
snk = blocks.vector_sink_c()
|
|
|
|
tb.connect(src, extract, snk)
|
|
tb.run()
|
|
|
|
output = np.array(snk.data())
|
|
# Output should have significant energy (tone passed through)
|
|
assert len(output) > 0
|
|
power = np.mean(np.abs(output[1000:]) ** 2)
|
|
assert power > 0.01, f"Target frequency power too low: {power}"
|
|
|
|
def test_rejects_distant_frequency(self):
|
|
"""A tone far from the passband should be strongly attenuated."""
|
|
from apollo.subcarrier_extract import subcarrier_extract
|
|
|
|
tb = gr.top_block()
|
|
sample_rate = SAMPLE_RATE_BASEBAND
|
|
n_samples = 50000
|
|
|
|
# Generate a tone at 500 kHz (far from 1.024 MHz passband)
|
|
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
|
tone = np.cos(2 * np.pi * 500_000 * t).astype(np.float32)
|
|
|
|
src = blocks.vector_source_f(tone.tolist())
|
|
extract = subcarrier_extract(
|
|
center_freq=PCM_SUBCARRIER_HZ,
|
|
bandwidth=150_000,
|
|
sample_rate=sample_rate,
|
|
)
|
|
snk = blocks.vector_sink_c()
|
|
|
|
tb.connect(src, extract, snk)
|
|
tb.run()
|
|
|
|
output = np.array(snk.data())
|
|
if len(output) > 1000:
|
|
power = np.mean(np.abs(output[1000:]) ** 2)
|
|
assert power < 0.001, f"Out-of-band frequency not rejected: {power}"
|
|
|
|
def test_output_sample_rate_property(self):
|
|
"""Output sample rate should account for decimation."""
|
|
from apollo.subcarrier_extract import subcarrier_extract
|
|
|
|
ext = subcarrier_extract(sample_rate=5_120_000, decimation=4)
|
|
assert ext.output_sample_rate == 1_280_000
|
|
|
|
def test_block_instantiation(self):
|
|
from apollo.subcarrier_extract import subcarrier_extract
|
|
|
|
ext = subcarrier_extract()
|
|
assert ext is not None
|