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
105 lines
3.8 KiB
Python
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")
|