gr-apollo/src/apollo/usb_uplink_receiver.py
Ryan Malloy 0e77373ea4 Add uplink chain: DSKY command encoder to RF and back
Uplink word codec (uplink_word_codec.py):
- UplinkSerializerEngine: (channel, value) pairs to 15-bit NRZ bit stream
  with configurable inter-word gap for UPRUPT timing
- UplinkDeserializerEngine: two-phase state machine (acquisition + fixed
  framing) recovers words from NRZ bits, handles leading-zero data words
- GR wrappers: uplink_word_serializer (sync_block source) and
  uplink_word_deserializer (basic_block sink with message output)

TX source (usb_uplink_source.py):
- hier_block2 wiring: word_serializer -> nrz_encoder -> FM mod (4 kHz dev)
  -> 70 kHz upconvert -> complex_to_real -> PM mod (1.0 rad) -> [AWGN]
- Message input "words" forwards PDUs from uplink_encoder

RX receiver (usb_uplink_receiver.py):
- hier_block2 wiring: PM demod -> subcarrier_extract (70 kHz, 20 kHz BW)
  -> quadrature_demod -> matched filter -> decimate -> slicer -> deserializer
- Message output "commands" emits recovered (channel, value) PDUs

GRC block definitions for both source and receiver.

Loopback demo (uplink_loopback_demo.py):
- Encodes V16N36E, serializes with pure-Python engine, runs through GR RF
  chain (FM + PM + noise + demod), deserializes, compares TX vs RX words
2026-02-24 14:17:58 -07:00

105 lines
3.8 KiB
Python

"""
Apollo USB Uplink Receiver -- spacecraft command receiver.
The receive-side counterpart to usb_uplink_source. Demodulates uplink
commands from complex baseband:
complex in -> pm_demod -> subcarrier_extract (70 kHz)
-> quadrature_demod (FM) -> matched filter -> decimate -> slicer
-> uplink_word_deserializer -> message output
Recovers 15-bit AGC words originally serialized at 2 kbps NRZ on a 70 kHz
FM data subcarrier, phase-modulated onto the uplink carrier.
For finer control, use the individual blocks directly.
Reference: IMPLEMENTATION_SPEC.md -- uplink receive path (section 2.2)
"""
from gnuradio import analog, blocks, digital, filter, gr
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
UPLINK_DATA_SUBCARRIER_HZ,
)
from apollo.pm_demod import pm_demod
from apollo.subcarrier_extract import subcarrier_extract
from apollo.uplink_word_codec import uplink_word_deserializer
# Uplink parameters (defined locally per integration instructions)
UPLINK_DATA_BIT_RATE = 2_000
UPLINK_DATA_FM_DEVIATION_HZ = 4_000
class usb_uplink_receiver(gr.hier_block2):
"""Apollo USB uplink receiver -- complex baseband to command PDUs.
Inputs:
complex -- baseband IQ samples at sample_rate (default 5.12 MHz)
Message outputs (no streaming output):
commands -- decoded (channel, value) PDUs for AGC bridge
The block chains: PM demod -> 70 kHz subcarrier extract -> FM demod ->
matched filter -> decimate -> binary slicer -> word deserializer.
"""
def __init__(
self,
sample_rate: float = SAMPLE_RATE_BASEBAND,
bit_rate: int = UPLINK_DATA_BIT_RATE,
carrier_pll_bw: float = 0.02,
subcarrier_bw: float = 20_000,
):
gr.hier_block2.__init__(
self,
"apollo_usb_uplink_receiver",
gr.io_signature(1, 1, gr.sizeof_gr_complex),
gr.io_signature(0, 0, 0), # message-only output
)
# Register message output port
self.message_port_register_hier_out("commands")
# Stage 1: PM demodulator -- carrier PLL + phase extraction
self.pm = pm_demod(
carrier_pll_bw=carrier_pll_bw,
sample_rate=sample_rate,
)
# Stage 2: Subcarrier extractor -- bandpass + downconvert 70 kHz
self.sc_extract = subcarrier_extract(
center_freq=UPLINK_DATA_SUBCARRIER_HZ,
bandwidth=subcarrier_bw,
sample_rate=sample_rate,
)
# Stage 3: FM discriminator
# Gain normalizes the FM deviation to unity amplitude
fm_gain = sample_rate / (2.0 * 3.141592653589793 * UPLINK_DATA_FM_DEVIATION_HZ)
self.fm_demod = analog.quadrature_demod_cf(fm_gain)
# Stage 4: Matched filter + decimation for bit recovery
# Average over one bit period, then keep one sample per bit
samples_per_bit = int(sample_rate / bit_rate)
matched_taps = [1.0 / samples_per_bit] * samples_per_bit
self.matched_filter = filter.fir_filter_fff(1, matched_taps)
self.decimator = blocks.keep_one_in_n(gr.sizeof_float, samples_per_bit)
# Stage 5: Binary slicer -- hard decision (> 0 -> 1, <= 0 -> 0)
self.slicer = digital.binary_slicer_fb()
# Stage 6: Word deserializer -- reassemble 15-bit words from bits
self.deser = uplink_word_deserializer()
# Connect streaming chain:
# complex in -> PM demod -> subcarrier extract -> FM demod
# -> matched filter -> decimate -> slicer -> deserializer
self.connect(
self, self.pm, self.sc_extract, self.fm_demod,
self.matched_filter, self.decimator, self.slicer, self.deser,
)
# Connect message port: deserializer -> hier output
self.msg_connect(self.deser, "commands", self, "commands")