"""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]}" )