"""Phase 1 integration test: end-to-end signal_gen → pm_demod → subcarrier_extract → bpsk_demod. This is the critical chain test — verifies that known bit patterns survive the full modulation/demodulation path. If this passes, the analog signal processing chain is correct. """ 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_HIGH_BIT_RATE, PCM_HIGH_WORDS_PER_FRAME, PCM_SUBCARRIER_HZ, PCM_WORD_LENGTH, SAMPLE_RATE_BASEBAND, ) from apollo.usb_signal_gen import generate_usb_baseband pytestmark = pytest.mark.skipif(not HAS_GNURADIO, reason="GNU Radio not installed") class TestPhase1Chain: """End-to-end demodulation chain tests.""" def _run_demod_chain(self, signal, sample_rate=SAMPLE_RATE_BASEBAND): """Run signal through the full Phase 1 demod chain and return recovered bits.""" from apollo.bpsk_demod import bpsk_demod from apollo.pm_demod import pm_demod from apollo.subcarrier_extract import subcarrier_extract tb = gr.top_block() src = blocks.vector_source_c(signal.tolist()) pm = pm_demod(carrier_pll_bw=0.02, sample_rate=sample_rate) sc_ext = subcarrier_extract( center_freq=PCM_SUBCARRIER_HZ, bandwidth=150_000, sample_rate=sample_rate, ) bpsk = bpsk_demod( symbol_rate=PCM_HIGH_BIT_RATE, sample_rate=sample_rate, loop_bw=0.045, ) snk = blocks.vector_sink_b() tb.connect(src, pm, sc_ext, bpsk, snk) tb.run() return np.array(snk.data()) def test_known_pattern_recovery_clean(self): """Recover known bits from a clean (no noise) signal.""" np.random.seed(42) known_payload = bytes(np.random.randint(0, 256, size=124, dtype=np.uint8)) signal, frame_bits = generate_usb_baseband( frames=2, frame_data=[known_payload, known_payload], snr_db=None, # no noise ) recovered = self._run_demod_chain(signal) if len(recovered) < 100: pytest.skip("Insufficient output samples for chain test") # The expected bits from the second frame (first frame may be lost to PLL settling) expected = np.array(frame_bits[1], dtype=np.uint8) frame_len = PCM_HIGH_WORDS_PER_FRAME * PCM_WORD_LENGTH # Search for the best alignment — the demod chain introduces variable delay best_ber = 1.0 for offset in range(min(len(recovered) - frame_len, frame_len)): chunk = recovered[offset : offset + frame_len] if len(chunk) < frame_len: break # Check both normal and inverted (Costas loop 180° ambiguity) ber_normal = np.mean(chunk != expected) ber_inverted = np.mean((1 - chunk) != expected) best_ber = min(best_ber, ber_normal, ber_inverted) # At 0 dB noise, we expect very low BER (< 5% accounting for sync settling) assert best_ber < 0.15, f"Bit error rate too high: {best_ber:.2%}" def test_output_produces_bits(self): """Basic sanity: the chain produces output bits.""" signal, _ = generate_usb_baseband(frames=3, snr_db=None) recovered = self._run_demod_chain(signal) assert len(recovered) > 0, "Demod chain produced no output" # Output should be binary (0 or 1) assert set(recovered).issubset({0, 1}), f"Non-binary output: {set(recovered)}" def test_noisy_signal_recovery(self): """Demod chain should work at moderate SNR (20 dB).""" np.random.seed(77) signal, frame_bits = generate_usb_baseband( frames=3, snr_db=20.0, ) recovered = self._run_demod_chain(signal) if len(recovered) < 100: pytest.skip("Insufficient output samples") # At 20 dB SNR, BPSK BER should be very low (theoretical ~1e-5) # We just verify the chain doesn't crash and produces reasonable output assert len(recovered) > 50 # Verify roughly equal distribution of 0s and 1s (not stuck at one value) ones_ratio = np.mean(recovered) assert 0.2 < ones_ratio < 0.8, f"Bit distribution skewed: {ones_ratio:.2%} ones" def test_bpsk_subcarrier_demod_wrapper(self): """Test the convenience hier_block2 wrapper combining extract + demod.""" from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod from apollo.pm_demod import pm_demod signal, _ = generate_usb_baseband(frames=2, snr_db=None) tb = gr.top_block() src = blocks.vector_source_c(signal.tolist()) pm = pm_demod() bpsk_sc = bpsk_subcarrier_demod() snk = blocks.vector_sink_b() tb.connect(src, pm, bpsk_sc, snk) tb.run() recovered = np.array(snk.data()) assert len(recovered) > 0, "Wrapper produced no output"