Merge feature/uplink-chain: full DSKY command encode/decode over RF

This commit is contained in:
Ryan Malloy 2026-02-24 14:23:29 -07:00
commit 78e73ca38c
6 changed files with 929 additions and 0 deletions

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

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