gr-apollo/tests/test_sco_mod.py
Ryan Malloy cd3a8cc6be Add SCO modulator, external audio input, and demo scripts
- sco_mod: 9-channel FM subcarrier oscillator modulator (inverse of sco_demod),
  with round-trip tests proving voltage recovery across all channels
- fm_voice_subcarrier_mod: add audio_input parameter to accept external float
  streams (e.g., Apollo mission voice recordings) instead of internal test tone
- loopback_demo.py: streaming TX->RX round-trip with CLI for noise/voice/frames
- agc_loopback_demo.py: full Virtual AGC integration via TCP bridge
2026-02-22 13:01:48 -07:00

179 lines
6.3 KiB
Python

"""Tests for the SCO (Subcarrier Oscillator) modulator 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 (
SAMPLE_RATE_BASEBAND,
SCO_FREQUENCIES,
)
pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed")
class TestSCOModInstantiation:
"""Test block creation and parameter validation."""
def test_all_channels(self):
"""Should instantiate for each valid SCO channel (1-9)."""
from apollo.sco_mod import sco_mod
for ch in range(1, 10):
mod = sco_mod(sco_number=ch)
assert mod is not None
assert mod.center_freq == SCO_FREQUENCIES[ch]
def test_invalid_channel_zero(self):
"""Channel 0 should raise ValueError."""
from apollo.sco_mod import sco_mod
with pytest.raises(ValueError, match="SCO number must be 1-9"):
sco_mod(sco_number=0)
def test_invalid_channel_ten(self):
"""Channel 10 should raise ValueError."""
from apollo.sco_mod import sco_mod
with pytest.raises(ValueError, match="SCO number must be 1-9"):
sco_mod(sco_number=10)
def test_deviation_property(self):
"""Deviation should be 7.5% of center frequency."""
from apollo.sco_mod import sco_mod
for ch in range(1, 10):
mod = sco_mod(sco_number=ch)
expected = SCO_FREQUENCIES[ch] * 0.075
assert abs(mod.deviation_hz - expected) < 0.01
def test_custom_sample_rate(self):
"""Should accept a custom sample rate."""
from apollo.sco_mod import sco_mod
mod = sco_mod(sco_number=1, sample_rate=10_240_000)
assert mod is not None
class TestSCOModFunctional:
"""Functional tests with constant-voltage inputs."""
def _get_output(self, sco_number, voltage, n_samples=None,
sample_rate=SAMPLE_RATE_BASEBAND):
"""Feed constant voltage through sco_mod and return output samples."""
from apollo.sco_mod import sco_mod
if n_samples is None:
n_samples = int(sample_rate * 0.1) # 100ms
tb = gr.top_block()
src = blocks.vector_source_f([voltage] * n_samples)
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
snk = blocks.vector_sink_f()
tb.connect(src, mod, snk)
tb.run()
return np.array(snk.data())
def test_midscale_produces_center_freq(self):
"""Feed 2.5V DC, verify spectral peak near center frequency."""
sco_ch = 5 # 52,500 Hz
sample_rate = SAMPLE_RATE_BASEBAND
output = self._get_output(sco_ch, voltage=2.5, sample_rate=sample_rate)
assert len(output) > 0, "Modulator produced no output"
# Find dominant frequency via FFT
spectrum = np.abs(np.fft.rfft(output))
freqs = np.fft.rfftfreq(len(output), d=1.0 / sample_rate)
peak_idx = np.argmax(spectrum[1:]) + 1 # skip DC
peak_freq = freqs[peak_idx]
expected = SCO_FREQUENCIES[sco_ch]
tolerance = expected * 0.02 # 2% tolerance
assert abs(peak_freq - expected) < tolerance, (
f"SCO ch{sco_ch} at 2.5V: peak at {peak_freq:.0f} Hz, "
f"expected {expected} Hz +/- {tolerance:.0f} Hz"
)
def test_produces_output(self):
"""Feed 2.5V, verify non-zero output."""
output = self._get_output(sco_number=5, voltage=2.5)
assert len(output) > 0, "Modulator produced no output"
assert np.any(output != 0.0), "Output is all zeros"
def test_output_bounded(self):
"""Peak amplitude should be reasonable (< 2.0, > 0.1)."""
output = self._get_output(sco_number=5, voltage=2.5)
peak = np.max(np.abs(output))
assert peak > 0.1, f"Output too small: peak amplitude {peak:.4f}"
assert peak < 2.0, f"Output too large: peak amplitude {peak:.4f}"
def test_all_channels_produce_output(self):
"""All 9 channels should produce non-zero output with 2.5V input."""
for ch in range(1, 10):
output = self._get_output(sco_number=ch, voltage=2.5)
assert len(output) > 0, f"SCO ch{ch} produced no output"
assert np.any(output != 0.0), f"SCO ch{ch} output is all zeros"
class TestSCOModDemodRoundtrip:
"""Round-trip tests: sco_mod -> sco_demod should recover the input voltage."""
def _roundtrip(self, sco_number, voltage, n_samples=None,
sample_rate=SAMPLE_RATE_BASEBAND):
"""Feed voltage through sco_mod -> sco_demod, return demod output."""
from apollo.sco_demod import sco_demod
from apollo.sco_mod import sco_mod
if n_samples is None:
n_samples = int(sample_rate * 0.2) # 200ms for settling
tb = gr.top_block()
src = blocks.vector_source_f([voltage] * n_samples)
mod = sco_mod(sco_number=sco_number, sample_rate=sample_rate)
demod = sco_demod(sco_number=sco_number, sample_rate=sample_rate)
snk = blocks.vector_sink_f()
tb.connect(src, mod, demod, snk)
tb.run()
return np.array(snk.data())
def test_roundtrip_midscale(self):
"""sco_mod(2.5V) -> sco_demod should recover ~2.5V."""
sco_ch = 5 # 52,500 Hz
output = self._roundtrip(sco_ch, voltage=2.5)
assert len(output) > 0, "Round-trip produced no output"
# Skip first 50% for filter settling
settled = output[len(output) // 2:]
if len(settled) > 10:
mean_v = np.mean(settled)
assert 1.5 < mean_v < 3.5, (
f"SCO ch{sco_ch} round-trip at 2.5V: mean output {mean_v:.2f}V, "
f"expected near 2.5V"
)
def test_roundtrip_monotonic(self):
"""Feed 0V, 2.5V, 5V through mod->demod; output should be monotonic."""
sco_ch = 6 # 70,000 Hz
voltages = [0.0, 2.5, 5.0]
means = []
for v_in in voltages:
output = self._roundtrip(sco_ch, voltage=v_in)
settled = output[len(output) // 2:]
mean_v = np.mean(settled) if len(settled) > 10 else float("nan")
means.append(mean_v)
assert means[0] < means[1] < means[2], (
f"Non-monotonic round-trip: "
f"V_in={voltages}, V_out={[f'{v:.2f}' for v in means]}"
)