""" 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")