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
145 lines
4.6 KiB
Python
145 lines
4.6 KiB
Python
"""
|
|
Apollo Subcarrier Oscillator (SCO) Demodulator — FM analog telemetry.
|
|
|
|
In FM downlink mode, the Pre-Modulation Processor generates 9 subcarrier
|
|
oscillators (SCOs) that encode analog sensor voltages (0-5V DC) as frequency
|
|
deviations of +/-7.5% around each channel's center frequency.
|
|
|
|
The SCOs are present in the composite FM modulating signal alongside PCM and
|
|
voice subcarriers. This block extracts one SCO channel and recovers the
|
|
original 0-5V sensor value.
|
|
|
|
Receiver side (this block):
|
|
PM demod output -> subcarrier_extract(sco_freq, BW=15% of center)
|
|
-> quadrature_demod (FM discriminator)
|
|
-> DC offset + scale to 0-5V
|
|
|
|
The mapping is linear:
|
|
0V input -> center_freq - 7.5% = low frequency
|
|
2.5V input -> center_freq (nominal)
|
|
5V input -> center_freq + 7.5% = high frequency
|
|
|
|
Reference: IMPLEMENTATION_SPEC.md section 4.3
|
|
"""
|
|
|
|
import math
|
|
|
|
from gnuradio import analog, blocks, gr
|
|
|
|
from apollo.constants import (
|
|
SAMPLE_RATE_BASEBAND,
|
|
SCO_DEVIATION_PERCENT,
|
|
SCO_FREQUENCIES,
|
|
SCO_INPUT_RANGE_V,
|
|
)
|
|
from apollo.subcarrier_extract import subcarrier_extract
|
|
|
|
|
|
class sco_demod(gr.hier_block2):
|
|
"""Extract and demodulate one SCO channel to a 0-5V sensor reading.
|
|
|
|
Only valid in FM downlink mode (not PM mode).
|
|
|
|
Inputs:
|
|
float -- PM demodulator output (composite subcarrier signal)
|
|
|
|
Outputs:
|
|
float -- recovered sensor voltage (0.0 to 5.0 V)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
sco_number: int = 1,
|
|
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
|
):
|
|
gr.hier_block2.__init__(
|
|
self,
|
|
"apollo_sco_demod",
|
|
gr.io_signature(1, 1, gr.sizeof_float),
|
|
gr.io_signature(1, 1, gr.sizeof_float),
|
|
)
|
|
|
|
if sco_number not in SCO_FREQUENCIES:
|
|
raise ValueError(
|
|
f"SCO number must be 1-9, got {sco_number}. "
|
|
f"Valid channels: {sorted(SCO_FREQUENCIES.keys())}"
|
|
)
|
|
|
|
self._sco_number = sco_number
|
|
self._sample_rate = sample_rate
|
|
|
|
center_freq = SCO_FREQUENCIES[sco_number]
|
|
self._center_freq = center_freq
|
|
|
|
# BPF bandwidth = 15% of center frequency (per IMPL_SPEC 4.3:
|
|
# the deviation is +/-7.5%, so 15% total bandwidth captures the
|
|
# full FM swing)
|
|
bw = 0.15 * center_freq
|
|
self._bandwidth = bw
|
|
|
|
# Frequency deviation in Hz: +/-7.5% of center
|
|
deviation_hz = center_freq * (SCO_DEVIATION_PERCENT / 100.0)
|
|
self._deviation_hz = deviation_hz
|
|
|
|
# Decimation: SCOs range from 14.5 kHz to 165 kHz. We need at
|
|
# least 2x the BW after decimation. Be conservative.
|
|
min_rate = bw * 3.0 # 3x bandwidth for margin
|
|
decimation = max(1, int(sample_rate / min_rate))
|
|
self._decimation = decimation
|
|
extracted_rate = sample_rate / decimation
|
|
|
|
# Stage 1: Extract the SCO to complex baseband
|
|
self.extract = subcarrier_extract(
|
|
center_freq=center_freq,
|
|
bandwidth=bw,
|
|
sample_rate=sample_rate,
|
|
decimation=decimation,
|
|
)
|
|
|
|
# Stage 2: FM discriminator
|
|
# Gain: sample_rate / (2 * pi * max_deviation)
|
|
# This gives output in units of (deviation_hz / deviation_hz) = 1.0
|
|
# at full deviation. We then scale to voltage.
|
|
fm_gain = extracted_rate / (2.0 * math.pi * deviation_hz)
|
|
self.fm_demod = analog.quadrature_demod_cf(fm_gain)
|
|
|
|
# Stage 3: Scale and offset to 0-5V range
|
|
# The FM demod output is proportional to instantaneous frequency offset:
|
|
# -deviation -> demod output ≈ -1.0 -> 0V
|
|
# 0 -> demod output ≈ 0.0 -> 2.5V
|
|
# +deviation -> demod output ≈ +1.0 -> 5V
|
|
#
|
|
# voltage = (demod_output + 1.0) * 2.5
|
|
# Implemented as: multiply by 2.5, then add 2.5
|
|
v_min, v_max = SCO_INPUT_RANGE_V
|
|
v_range = v_max - v_min # 5.0
|
|
v_mid = (v_max + v_min) / 2.0 # 2.5
|
|
|
|
self.scale = blocks.multiply_const_ff(v_range / 2.0)
|
|
self.offset = blocks.add_const_ff(v_mid)
|
|
|
|
# Connect the chain
|
|
self.connect(
|
|
self,
|
|
self.extract,
|
|
self.fm_demod,
|
|
self.scale,
|
|
self.offset,
|
|
self,
|
|
)
|
|
|
|
@property
|
|
def center_freq(self) -> float:
|
|
"""Center frequency of this SCO channel in Hz."""
|
|
return self._center_freq
|
|
|
|
@property
|
|
def deviation_hz(self) -> float:
|
|
"""FM deviation in Hz (+/- from center)."""
|
|
return self._deviation_hz
|
|
|
|
@property
|
|
def output_sample_rate(self) -> float:
|
|
"""Sample rate of the output stream."""
|
|
return self._sample_rate / self._decimation
|