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
This commit is contained in:
parent
04a17899fc
commit
0e77373ea4
250
examples/uplink_loopback_demo.py
Normal file
250
examples/uplink_loopback_demo.py
Normal 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()
|
||||
57
grc/apollo_usb_uplink_receiver.block.yml
Normal file
57
grc/apollo_usb_uplink_receiver.block.yml
Normal 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
|
||||
60
grc/apollo_usb_uplink_source.block.yml
Normal file
60
grc/apollo_usb_uplink_source.block.yml
Normal 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
|
||||
332
src/apollo/uplink_word_codec.py
Normal file
332
src/apollo/uplink_word_codec.py
Normal 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
|
||||
104
src/apollo/usb_uplink_receiver.py
Normal file
104
src/apollo/usb_uplink_receiver.py
Normal 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")
|
||||
126
src/apollo/usb_uplink_source.py
Normal file
126
src/apollo/usb_uplink_source.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user