- 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
179 lines
6.3 KiB
Python
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]}"
|
|
)
|