gr-apollo/src/apollo/pm_demod.py
Ryan Malloy 0ee7ff0ad7 Implement full Apollo USB downlink decoder chain
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
2026-02-20 13:18:42 -07:00

60 lines
2.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Apollo PM Demodulator — extracts phase modulation from complex baseband.
The spacecraft transmitter phase-modulates a 76.25 MHz carrier at 0.133 rad
peak deviation (7.6 degrees). After frequency multiplication (×30) to 2287.5 MHz
and downconversion to complex baseband at the receiver, this block recovers the
composite modulating signal containing all subcarriers.
At 0.133 rad, the small-angle approximation holds (sin(0.133) ≈ 0.1327,
<0.3% error), so the demodulated output is essentially linear with the
modulating signal.
Signal chain: complex baseband → carrier PLL → phase extraction → float output
Reference: IMPLEMENTATION_SPEC.md section 2.3
"""
from gnuradio import analog, blocks, gr
class pm_demod(gr.hier_block2):
"""Phase modulation demodulator with carrier recovery.
Inputs:
complex baseband (e.g., from SDR or usb_signal_gen)
Outputs:
float — demodulated composite signal containing all subcarriers
"""
def __init__(self, carrier_pll_bw: float = 0.02, sample_rate: float = 5_120_000):
gr.hier_block2.__init__(
self,
"apollo_pm_demod",
gr.io_signature(1, 1, gr.sizeof_gr_complex),
gr.io_signature(1, 1, gr.sizeof_float),
)
# Carrier tracking PLL — locks to the residual carrier in the PM signal.
# The PLL bandwidth needs to be narrow enough to track carrier drift
# but wide enough for acquisition. 0.02 rad/sample is a good default
# for the 5.12 MHz sample rate.
#
# PLL freq range: ±carrier_pll_bw * sample_rate / (2*pi) Hz
max_freq = carrier_pll_bw * 2.0
min_freq = -max_freq
self.pll = analog.pll_carriertracking_cc(carrier_pll_bw, max_freq, min_freq)
# Extract instantaneous phase: atan2(Im, Re)
self.phase = blocks.complex_to_arg(1)
# Connect: input → PLL → phase extraction → output
self.connect(self, self.pll, self.phase, self)
def get_carrier_pll_bw(self) -> float:
return self.pll.get_loop_bandwidth()
def set_carrier_pll_bw(self, bw: float):
self.pll.set_loop_bandwidth(bw)