From 0e77373ea4539f25711b841447e64a7ccecb4658 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 24 Feb 2026 14:17:58 -0700 Subject: [PATCH] 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 --- examples/uplink_loopback_demo.py | 250 +++++++++++++++++ grc/apollo_usb_uplink_receiver.block.yml | 57 ++++ grc/apollo_usb_uplink_source.block.yml | 60 ++++ src/apollo/uplink_word_codec.py | 332 +++++++++++++++++++++++ src/apollo/usb_uplink_receiver.py | 104 +++++++ src/apollo/usb_uplink_source.py | 126 +++++++++ 6 files changed, 929 insertions(+) create mode 100644 examples/uplink_loopback_demo.py create mode 100644 grc/apollo_usb_uplink_receiver.block.yml create mode 100644 grc/apollo_usb_uplink_source.block.yml create mode 100644 src/apollo/uplink_word_codec.py create mode 100644 src/apollo/usb_uplink_receiver.py create mode 100644 src/apollo/usb_uplink_source.py diff --git a/examples/uplink_loopback_demo.py b/examples/uplink_loopback_demo.py new file mode 100644 index 0000000..398421a --- /dev/null +++ b/examples/uplink_loopback_demo.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Apollo Uplink Loopback Demo -- encode V16N36E, modulate, demodulate, verify. + +Demonstrates the full uplink signal chain using a mix of pure-Python engines +(for bit-level serialization/deserialization) and GNU Radio blocks (for the +RF modulation/demodulation path): + + TX (ground station): + UplinkEncoder -> UplinkSerializerEngine -> [bits] + -> GR: nrz_encoder -> FM mod -> 70 kHz upconvert -> PM mod + + RX (spacecraft): + GR: PM demod -> 70 kHz extract -> FM demod -> matched filter -> slicer + -> [bits] -> UplinkDeserializerEngine + +The pure-Python engines handle word<->bit conversion at the endpoints, while +the GR streaming chain proves the RF modulation path works end-to-end. + +Usage: + uv run python examples/uplink_loopback_demo.py + uv run python examples/uplink_loopback_demo.py --snr 20 + uv run python examples/uplink_loopback_demo.py --snr 10 --verb 37 --noun 0 +""" + +import argparse +import math +import sys + +import numpy as np +from gnuradio import analog, blocks, digital, filter, gr + +from apollo.constants import ( + SAMPLE_RATE_BASEBAND, + UPLINK_DATA_SUBCARRIER_HZ, +) +from apollo.nrz_encoder import nrz_encoder +from apollo.pm_demod import pm_demod +from apollo.pm_mod import pm_mod +from apollo.subcarrier_extract import subcarrier_extract +from apollo.uplink_encoder import UplinkEncoder +from apollo.uplink_word_codec import ( + UPLINK_WORD_BITS, + UplinkDeserializerEngine, + UplinkSerializerEngine, +) + +# Uplink parameters (local definitions) +UPLINK_PM_DEVIATION_RAD = 1.0 +UPLINK_DATA_BIT_RATE = 2_000 +UPLINK_DATA_FM_DEVIATION_HZ = 4_000 +UPLINK_INTER_WORD_GAP = 3 + + +def main(): + parser = argparse.ArgumentParser(description="Apollo uplink loopback demo") + parser.add_argument( + "--verb", type=int, default=16, help="Verb number (default: 16)" + ) + parser.add_argument( + "--noun", type=int, default=36, help="Noun number (default: 36)" + ) + parser.add_argument( + "--snr", type=float, default=None, help="SNR in dB (None = no noise)" + ) + args = parser.parse_args() + + sample_rate = SAMPLE_RATE_BASEBAND + bit_rate = UPLINK_DATA_BIT_RATE + + # --- Encode the command --- + + encoder = UplinkEncoder() + tx_pairs = encoder.encode_verb_noun(verb=args.verb, noun=args.noun) + + print("=" * 60) + print("Apollo Uplink Loopback Demo") + print("=" * 60) + print(f" Command: V{args.verb:02d}N{args.noun:02d}E") + print(f" Uplink words: {len(tx_pairs)}") + print(f" SNR: {'clean (no noise)' if args.snr is None else f'{args.snr} dB'}") + print() + + print("TX word sequence:") + for i, (ch, val) in enumerate(tx_pairs): + print(f" [{i}] ch={ch:03o} val={val:05o} ({val:>5d}) " + f"bits={val:015b}") + print() + + # --- Serialize to bits using pure-Python engine --- + + serializer = UplinkSerializerEngine(inter_word_gap=UPLINK_INTER_WORD_GAP) + serializer.add_words(tx_pairs) + + bits_per_word = UPLINK_WORD_BITS + UPLINK_INTER_WORD_GAP + total_data_bits = len(tx_pairs) * bits_per_word + + # Add leading and trailing idle for PLL settling + pll_settle_bits = int(bit_rate * 0.5) # 0.5 seconds of idle + total_bits = pll_settle_bits + total_data_bits + pll_settle_bits + + tx_bits = serializer.next_bits(total_bits) + tx_bytes = np.array(tx_bits, dtype=np.byte) + + samples_per_bit = int(sample_rate / bit_rate) + n_samples = total_bits * samples_per_bit + + print(f" Total bits: {total_bits} ({total_data_bits} data + " + f"{2 * pll_settle_bits} idle)") + print(f" Samples per bit: {samples_per_bit}") + print(f" Total samples: {n_samples:,}") + print(f" Duration: {n_samples / sample_rate:.3f} s") + print() + + # --- Build GR flowgraph for the RF path --- + # + # TX: vector_source_b -> nrz -> FM mod -> upconvert 70 kHz -> to_real -> PM mod + # RX: PM demod -> extract 70 kHz -> FM demod -> matched filter + # -> decimate -> slicer -> vector_sink + + print("Building flowgraph...") + tb = gr.top_block() + + # TX chain + src = blocks.vector_source_b(tx_bytes.tolist(), False) + nrz = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate) + + fm_sensitivity = 2.0 * math.pi * UPLINK_DATA_FM_DEVIATION_HZ / sample_rate + fm_mod = analog.frequency_modulator_fc(fm_sensitivity) + + lo = analog.sig_source_c( + sample_rate, analog.GR_COS_WAVE, + UPLINK_DATA_SUBCARRIER_HZ, 1.0, 0, + ) + mixer = blocks.multiply_cc(1) + to_real = blocks.complex_to_real(1) + + pm = pm_mod(pm_deviation=UPLINK_PM_DEVIATION_RAD, sample_rate=sample_rate) + + # RX chain + pm_rx = pm_demod(carrier_pll_bw=0.02, sample_rate=sample_rate) + + sc_extract = subcarrier_extract( + center_freq=UPLINK_DATA_SUBCARRIER_HZ, + bandwidth=20_000, + sample_rate=sample_rate, + ) + + fm_gain = sample_rate / (2.0 * math.pi * UPLINK_DATA_FM_DEVIATION_HZ) + fm_demod = analog.quadrature_demod_cf(fm_gain) + + matched_taps = [1.0 / samples_per_bit] * samples_per_bit + matched = filter.fir_filter_fff(1, matched_taps) + decim = blocks.keep_one_in_n(gr.sizeof_float, samples_per_bit) + slicer = digital.binary_slicer_fb() + + snk = blocks.vector_sink_b() + + # Optional noise + if args.snr is not None: + noise_power = 1.0 / (10.0 ** (args.snr / 10.0)) + noise_amplitude = math.sqrt(noise_power / 2.0) + noise = analog.noise_source_c(analog.GR_GAUSSIAN, noise_amplitude, 0) + add_noise = blocks.add_cc(1) + + tb.connect(pm, (add_noise, 0)) + tb.connect(noise, (add_noise, 1)) + noise_out = add_noise + else: + noise_out = pm + + # Wire TX + tb.connect(src, nrz, fm_mod, (mixer, 0)) + tb.connect(lo, (mixer, 1)) + tb.connect(mixer, to_real, pm) + + # Wire RX + tb.connect(noise_out, pm_rx, sc_extract, fm_demod, matched, decim, slicer, snk) + + print("Running flowgraph (TX -> RX)...") + tb.run() + print() + + # --- Deserialize recovered bits --- + + rx_bits = list(snk.data()) + print(f"Recovered {len(rx_bits)} bits from slicer") + + deserializer = UplinkDeserializerEngine() + rx_pairs = deserializer.process_bits(rx_bits) + + print(f"Recovered {len(rx_pairs)} words (expected {len(tx_pairs)})") + print() + + if not rx_pairs: + print("No words recovered. PLL may need more settling time or") + print("the subcarrier filter bandwidth may need adjustment.") + sys.exit(1) + + # --- Compare TX vs RX --- + + print("RX word sequence:") + for i, (ch, val) in enumerate(rx_pairs): + print(f" [{i}] ch={ch:03o} val={val:05o} ({val:>5d}) " + f"bits={val:015b}") + print() + + # Match comparison + matches = 0 + n_compare = min(len(tx_pairs), len(rx_pairs)) + errors = [] + + for i in range(n_compare): + tx_ch, tx_val = tx_pairs[i] + rx_ch, rx_val = rx_pairs[i] + if tx_val == rx_val: + matches += 1 + else: + errors.append((i, tx_val, rx_val)) + + print("-" * 60) + print(f" Words transmitted: {len(tx_pairs)}") + print(f" Words recovered: {len(rx_pairs)}") + print(f" Matches: {matches}/{n_compare}") + + if errors: + print(f" Errors: {len(errors)}") + for idx, tx_v, rx_v in errors: + # Count differing bits + diff = tx_v ^ rx_v + n_bit_err = bin(diff).count("1") + print(f" Word {idx}: TX={tx_v:05o} RX={rx_v:05o} " + f"({n_bit_err} bit errors)") + + if n_compare > 0: + wer = 1.0 - (matches / n_compare) + print(f" Word error rate: {wer:.1%}") + print("-" * 60) + + if matches == n_compare and len(rx_pairs) == len(tx_pairs): + print() + print(f"V{args.verb:02d}N{args.noun:02d}E round-trip: all {matches} words match.") + elif matches == n_compare: + print() + print(f"All compared words match, but word count differs " + f"({len(rx_pairs)} recovered vs {len(tx_pairs)} sent).") + + +if __name__ == "__main__": + main() diff --git a/grc/apollo_usb_uplink_receiver.block.yml b/grc/apollo_usb_uplink_receiver.block.yml new file mode 100644 index 0000000..2f7b5b1 --- /dev/null +++ b/grc/apollo_usb_uplink_receiver.block.yml @@ -0,0 +1,57 @@ +id: apollo_usb_uplink_receiver +label: Apollo USB Uplink Receiver +category: '[Apollo USB]' +flags: [python] + +parameters: +- id: sample_rate + label: Sample Rate (Hz) + dtype: float + default: '5120000' +- id: bit_rate + label: Uplink Bit Rate + dtype: int + default: '2000' +- id: carrier_pll_bw + label: Carrier PLL Bandwidth + dtype: float + default: '0.02' +- id: subcarrier_bw + label: Subcarrier Bandwidth (Hz) + dtype: float + default: '20000' + +inputs: +- label: in + domain: stream + dtype: complex + +outputs: +- label: commands + domain: message + +templates: + imports: from apollo.usb_uplink_receiver import usb_uplink_receiver + make: >- + apollo.usb_uplink_receiver.usb_uplink_receiver( + sample_rate=${sample_rate}, + bit_rate=${bit_rate}, + carrier_pll_bw=${carrier_pll_bw}, + subcarrier_bw=${subcarrier_bw}) + +documentation: |- + Apollo USB Uplink Receiver -- spacecraft command receiver. + + Demodulates uplink commands from complex baseband: + PM demod -> 70 kHz subcarrier extract -> FM demod -> bit recovery -> word assembly + + Message output: + commands -- decoded (channel, value) PDUs for AGC bridge + + Parameters: + sample_rate: Input sample rate (default 5.12 MHz) + bit_rate: Expected uplink data rate (default 2000 bps) + carrier_pll_bw: PM carrier recovery loop bandwidth (default 0.02) + subcarrier_bw: 70 kHz subcarrier filter bandwidth (default 20 kHz) + +file_format: 1 diff --git a/grc/apollo_usb_uplink_source.block.yml b/grc/apollo_usb_uplink_source.block.yml new file mode 100644 index 0000000..5e95f86 --- /dev/null +++ b/grc/apollo_usb_uplink_source.block.yml @@ -0,0 +1,60 @@ +id: apollo_usb_uplink_source +label: Apollo USB Uplink Source +category: '[Apollo USB]' +flags: [python] + +parameters: +- id: sample_rate + label: Sample Rate (Hz) + dtype: float + default: '5120000' +- id: bit_rate + label: Uplink Bit Rate + dtype: int + default: '2000' +- id: pm_deviation + label: PM Deviation (rad) + dtype: float + default: '1.0' +- id: snr_db + label: SNR (dB) + dtype: raw + default: 'None' + +inputs: +- label: words + domain: message + optional: true + +outputs: +- label: out + domain: stream + dtype: complex + +templates: + imports: from apollo.usb_uplink_source import usb_uplink_source + make: >- + apollo.usb_uplink_source.usb_uplink_source( + sample_rate=${sample_rate}, + bit_rate=${bit_rate}, + pm_deviation=${pm_deviation}, + snr_db=${snr_db}) + +documentation: |- + Apollo USB Uplink Source -- ground station command transmitter. + + Generates a PM-modulated complex baseband signal carrying uplink commands + on a 70 kHz FM data subcarrier at 2 kbps NRZ. + + This is the transmit-side counterpart to the USB Uplink Receiver. + + Message input: + words -- (channel, value) PDUs from uplink_encoder + + Parameters: + sample_rate: Output sample rate (default 5.12 MHz) + bit_rate: Uplink data rate (default 2000 bps) + pm_deviation: Peak PM deviation in radians (default 1.0) + snr_db: Add AWGN noise at this SNR (None = no noise) + +file_format: 1 diff --git a/src/apollo/uplink_word_codec.py b/src/apollo/uplink_word_codec.py new file mode 100644 index 0000000..9c39f3f --- /dev/null +++ b/src/apollo/uplink_word_codec.py @@ -0,0 +1,332 @@ +""" +Apollo Uplink Word Codec -- serializes and deserializes 15-bit AGC words for RF transport. + +The uplink carries commands as 15-bit words at 2 kbps NRZ on a 70 kHz FM subcarrier. +Each word triggers an UPRUPT interrupt in the AGC flight software. + +Serializer: (channel, value) pairs -> NRZ bit stream (0/1 bytes) +Deserializer: NRZ bit stream -> (channel, value) pairs + +The serializer inserts a configurable inter-word gap (default 3 bit periods of zeros) +to allow the AGC time to service the UPRUPT between consecutive words. + +Reference: IMPLEMENTATION_SPEC.md section 1 (INLINK / UPRUPT) +""" + +import logging +from collections import deque + +import numpy as np + +from apollo.constants import AGC_CH_INLINK + +logger = logging.getLogger(__name__) + +# Uplink parameters (defined locally per integration instructions) +UPLINK_DATA_BIT_RATE = 2_000 # 2 kbps NRZ +UPLINK_WORD_BITS = 15 # AGC word width +UPLINK_INTER_WORD_GAP = 3 # bit periods of zeros between words + +# Minimum consecutive zeros to consider the channel idle (for deserializer sync) +IDLE_THRESHOLD = UPLINK_INTER_WORD_GAP + 2 + + +class UplinkSerializerEngine: + """Serializes (channel, value) pairs into a continuous NRZ bit stream. + + Queues incoming words and produces bits on demand. Between words, inserts + a gap of zeros (default 3 bits) representing idle time for UPRUPT servicing. + When the queue is empty, outputs continuous zeros (carrier idle). + + Args: + inter_word_gap: Number of zero-bit periods between consecutive words. + """ + + def __init__(self, inter_word_gap: int = UPLINK_INTER_WORD_GAP): + self._gap = inter_word_gap + self._bit_queue: deque[int] = deque() + + def add_words(self, pairs: list[tuple[int, int]]): + """Queue (channel, value) pairs for serialization. + + Each value is serialized as 15 bits MSB-first, followed by inter-word + gap zeros. The channel is not transmitted (the AGC always receives on + channel 045); it is used only for metadata/logging. + + Args: + pairs: List of (channel, value) tuples from UplinkEncoder. + """ + for _channel, value in pairs: + # Serialize 15 bits MSB-first + for bit_pos in range(UPLINK_WORD_BITS - 1, -1, -1): + self._bit_queue.append((value >> bit_pos) & 1) + # Inter-word gap + for _ in range(self._gap): + self._bit_queue.append(0) + + def next_bits(self, n: int) -> list[int]: + """Pull up to n bits from the queue. + + Returns queued data bits when available, zeros otherwise (idle carrier). + + Args: + n: Maximum number of bits to return. + + Returns: + List of 0/1 values, always exactly n elements long. + """ + result = [] + for _ in range(n): + if self._bit_queue: + result.append(self._bit_queue.popleft()) + else: + result.append(0) + return result + + @property + def pending(self) -> int: + """Number of bits remaining in the queue.""" + return len(self._bit_queue) + + +class UplinkDeserializerEngine: + """Reassembles 15-bit AGC words from a recovered NRZ bit stream. + + Uses a two-phase state machine: + + 1. **Acquisition**: Scans for the first non-zero bit, which marks the start + of the first word (all DSKY keycodes have at least one set bit in the + upper 5 bits, so the first transmitted word always starts with a 1). + + 2. **Locked**: Once the first word boundary is found, uses fixed framing -- + collects exactly 15 bits per word, then skips exactly `inter_word_gap` + bits, then collects the next 15 bits, etc. This is necessary because + data words can start with leading zeros that would be indistinguishable + from the inter-word gap. + + The lock is released after seeing more than `gap + word_bits` consecutive + zeros (indicating the transmitter has gone idle). + + The recovered words are emitted as (channel, value) pairs where channel is + always AGC_CH_INLINK (045 octal = 37 decimal). + + Args: + inter_word_gap: Expected number of zero bits between words. + channel: AGC channel to assign to recovered words. + """ + + # State constants + _ACQUIRING = 0 + _IN_WORD = 1 + _IN_GAP = 2 + + def __init__( + self, + inter_word_gap: int = UPLINK_INTER_WORD_GAP, + channel: int = AGC_CH_INLINK, + ): + self._gap = inter_word_gap + self._channel = channel + self._bit_buffer: list[int] = [] + self._gap_count = 0 + self._idle_count = 0 + self._state = self._ACQUIRING + + def process_bits(self, bits: list[int]) -> list[tuple[int, int]]: + """Process a batch of recovered bits and return any completed words. + + Args: + bits: List of 0/1 values from the slicer output. + + Returns: + List of (channel, value) tuples for each completed word. + """ + results: list[tuple[int, int]] = [] + + for bit in bits: + if self._state == self._ACQUIRING: + # Scanning for first non-zero bit (start of first word) + if bit == 0: + self._idle_count += 1 + else: + self._state = self._IN_WORD + self._bit_buffer = [bit] + self._idle_count = 0 + + elif self._state == self._IN_WORD: + # Collecting word bits (fixed 15-bit frame) + self._bit_buffer.append(bit) + + # Track consecutive zeros for idle detection + if bit == 0: + self._idle_count += 1 + else: + self._idle_count = 0 + + if len(self._bit_buffer) == UPLINK_WORD_BITS: + # Assemble 15-bit value MSB-first + value = 0 + for b in self._bit_buffer: + value = (value << 1) | b + + if value == 0: + # Null word (all zeros) means the transmitter has + # gone idle -- not a valid command. Drop it and + # return to acquisition. + self._state = self._ACQUIRING + self._bit_buffer = [] + else: + results.append((self._channel, value)) + self._bit_buffer = [] + if self._gap > 0: + self._state = self._IN_GAP + self._gap_count = 0 + else: + self._state = self._IN_WORD + + elif self._state == self._IN_GAP: + # Skipping inter-word gap bits + self._gap_count += 1 + + if self._gap_count >= self._gap: + # Gap complete -- start collecting next word + self._state = self._IN_WORD + self._bit_buffer = [] + + return results + + def reset(self): + """Clear internal state for a fresh decode pass.""" + self._bit_buffer = [] + self._gap_count = 0 + self._idle_count = 0 + self._state = self._ACQUIRING + + +# --------------------------------------------------------------------------- +# GNU Radio block wrappers (optional -- only if gnuradio is available) +# --------------------------------------------------------------------------- + +try: + import pmt + from gnuradio import gr + + class uplink_word_serializer(gr.sync_block): + """GNU Radio source block: serializes uplink word PDUs into a NRZ bit stream. + + Accepts (channel, value) PDUs on the ``words`` message input (same + format emitted by uplink_encoder) and outputs a continuous stream of + bytes (values 0 or 1) carrying the serialized data. + + When no words are queued, outputs zeros (idle carrier). + + Message input: + words -- PDU with metadata dict containing "channel" and "value" keys, + or a pair (cons) of (channel . value). + + Output: + byte stream -- 0/1 values at the uplink bit rate + """ + + def __init__(self, inter_word_gap: int = UPLINK_INTER_WORD_GAP): + gr.sync_block.__init__( + self, + name="apollo_uplink_word_serializer", + in_sig=None, + out_sig=[np.byte], + ) + + self._engine = UplinkSerializerEngine(inter_word_gap=inter_word_gap) + + # Message input for word injection + self.message_port_register_in(pmt.intern("words")) + self.set_msg_handler(pmt.intern("words"), self._handle_words) + + def _handle_words(self, msg): + """Parse incoming word PDU and queue for serialization.""" + if not pmt.is_pair(msg): + return + + meta = pmt.car(msg) + data = pmt.cdr(msg) + + # Try metadata dict first (preferred format from uplink_encoder) + if pmt.is_dict(meta): + ch_pmt = pmt.dict_ref(meta, pmt.intern("channel"), pmt.PMT_NIL) + val_pmt = pmt.dict_ref(meta, pmt.intern("value"), pmt.PMT_NIL) + if not pmt.is_null(ch_pmt) and not pmt.is_null(val_pmt): + channel = pmt.to_long(ch_pmt) + value = pmt.to_long(val_pmt) + self._engine.add_words([(channel, value)]) + return + + # Fallback: data is a pair (channel . value) + if pmt.is_pair(data): + channel = pmt.to_long(pmt.car(data)) + value = pmt.to_long(pmt.cdr(data)) + self._engine.add_words([(channel, value)]) + + def work(self, input_items, output_items): + out = output_items[0] + n_out = len(out) + + bits = self._engine.next_bits(n_out) + for i in range(n_out): + out[i] = bits[i] + + return n_out + + class uplink_word_deserializer(gr.basic_block): + """GNU Radio block: reassembles 15-bit uplink words from a recovered bit stream. + + Consumes a stream of bytes (0/1 from binary slicer) and emits PDU + messages for each recovered word. + + Input: + byte stream -- 0/1 values from the slicer + + Message output: + commands -- PDU with metadata dict {"channel": int, "value": int} + """ + + def __init__( + self, + inter_word_gap: int = UPLINK_INTER_WORD_GAP, + channel: int = AGC_CH_INLINK, + ): + gr.basic_block.__init__( + self, + name="apollo_uplink_word_deserializer", + in_sig=[np.byte], + out_sig=[], + ) + + self._engine = UplinkDeserializerEngine( + inter_word_gap=inter_word_gap, + channel=channel, + ) + self.message_port_register_out(pmt.intern("commands")) + + def general_work(self, input_items, output_items): + n_input = len(input_items[0]) + bits = [int(input_items[0][i]) for i in range(n_input)] + self.consume(0, n_input) + + pairs = self._engine.process_bits(bits) + for channel, value in pairs: + meta = pmt.make_dict() + meta = pmt.dict_add( + meta, pmt.intern("channel"), pmt.from_long(channel) + ) + meta = pmt.dict_add( + meta, pmt.intern("value"), pmt.from_long(value) + ) + self.message_port_pub( + pmt.intern("commands"), + pmt.cons(meta, pmt.PMT_NIL), + ) + + return 0 + +except ImportError: + pass diff --git a/src/apollo/usb_uplink_receiver.py b/src/apollo/usb_uplink_receiver.py new file mode 100644 index 0000000..569ffc4 --- /dev/null +++ b/src/apollo/usb_uplink_receiver.py @@ -0,0 +1,104 @@ +""" +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") diff --git a/src/apollo/usb_uplink_source.py b/src/apollo/usb_uplink_source.py new file mode 100644 index 0000000..8db8659 --- /dev/null +++ b/src/apollo/usb_uplink_source.py @@ -0,0 +1,126 @@ +""" +Apollo USB Uplink Source -- ground station command transmitter. + +The transmit-side counterpart to usb_uplink_receiver. Wires together the +full uplink modulation chain: + + uplink_word_serializer -> nrz_encoder -> FM mod -> 70 kHz upconvert + -> complex_to_real -> pm_mod -> [optional AWGN] -> complex out + +The ground station transmits commands on a 70 kHz FM data subcarrier at +2 kbps NRZ, phase-modulated onto the 2106.40625 MHz uplink carrier at +1.0 rad peak deviation. + +For finer control, use the individual blocks directly. + +Reference: IMPLEMENTATION_SPEC.md -- uplink transmit path (section 2.2) +""" + +import math + +from gnuradio import analog, blocks, gr + +from apollo.constants import ( + SAMPLE_RATE_BASEBAND, + UPLINK_DATA_SUBCARRIER_HZ, +) +from apollo.nrz_encoder import nrz_encoder +from apollo.pm_mod import pm_mod +from apollo.uplink_word_codec import uplink_word_serializer + +# Uplink parameters (defined locally per integration instructions) +UPLINK_PM_DEVIATION_RAD = 1.0 +UPLINK_DATA_BIT_RATE = 2_000 +UPLINK_DATA_FM_DEVIATION_HZ = 4_000 + + +class usb_uplink_source(gr.hier_block2): + """Apollo USB uplink signal source -- complex baseband output. + + Outputs: + complex -- PM-modulated baseband at sample_rate (default 5.12 MHz) + + Message inputs: + words -- forwarded to uplink_word_serializer for command injection. + Accepts the same PDU format emitted by uplink_encoder. + + The block serializes 15-bit AGC words into NRZ bits, FM-modulates them + onto a 70 kHz subcarrier, and applies PM modulation to produce complex + baseband suitable for transmission or loopback testing. + + Optional AWGN noise can be added by setting snr_db to a finite value. + """ + + def __init__( + self, + sample_rate: float = SAMPLE_RATE_BASEBAND, + bit_rate: int = UPLINK_DATA_BIT_RATE, + pm_deviation: float = UPLINK_PM_DEVIATION_RAD, + snr_db: float | None = None, + ): + gr.hier_block2.__init__( + self, + "apollo_usb_uplink_source", + gr.io_signature(0, 0, 0), # source -- no input + gr.io_signature(1, 1, gr.sizeof_gr_complex), + ) + + self._sample_rate = sample_rate + + # Forward the words message port from uplink_word_serializer + self.message_port_register_hier_in("words") + + # --- Uplink data path --- + + # Stage 1: Serialize 15-bit words into 0/1 byte stream + self.word_ser = uplink_word_serializer() + + # Forward message port: hier input -> serializer + self.msg_connect(self, "words", self.word_ser, "words") + + # Stage 2: NRZ encode (byte 0/1 -> float +1/-1 at sample_rate) + self.nrz = nrz_encoder(bit_rate=bit_rate, sample_rate=sample_rate) + + # Stage 3: FM modulate onto 70 kHz subcarrier + # Sensitivity converts amplitude to instantaneous frequency deviation + fm_sensitivity = 2.0 * math.pi * UPLINK_DATA_FM_DEVIATION_HZ / sample_rate + self.fm_mod = analog.frequency_modulator_fc(fm_sensitivity) + + # Stage 4: Upconvert to 70 kHz -- multiply by exp(j*2*pi*70000*t) + self.lo = analog.sig_source_c( + sample_rate, analog.GR_COS_WAVE, + UPLINK_DATA_SUBCARRIER_HZ, 1.0, 0, + ) + self.mixer = blocks.multiply_cc(1) + + # Stage 5: Convert complex subcarrier to real (PM modulator expects float) + self.to_real = blocks.complex_to_real(1) + + # Stage 6: PM modulation onto carrier + self.pm = pm_mod(pm_deviation=pm_deviation, sample_rate=sample_rate) + + # Connect data chain: + # word_ser -> nrz -> fm_mod -> (mixer, 0) + # lo -> (mixer, 1) + # mixer -> to_real -> pm + self.connect(self.word_ser, self.nrz, self.fm_mod, (self.mixer, 0)) + self.connect(self.lo, (self.mixer, 1)) + self.connect(self.mixer, self.to_real, self.pm) + + # --- Optional AWGN --- + + if snr_db is not None: + # Signal power is 1.0 (PM constant envelope) + noise_power = 1.0 / (10.0 ** (snr_db / 10.0)) + noise_amplitude = math.sqrt(noise_power / 2.0) + + self.noise = analog.noise_source_c( + analog.GR_GAUSSIAN, noise_amplitude, 0, + ) + self.sum_noise = blocks.add_cc(1) + + self.connect(self.pm, (self.sum_noise, 0)) + self.connect(self.noise, (self.sum_noise, 1)) + self.connect(self.sum_noise, self) + else: + self.connect(self.pm, self)