gr-apollo/src/apollo/usb_signal_source.py
Ryan Malloy 493c21c511 Add transmit chain: 6 composable GR source blocks mirroring CuriousMarc bench
Implement the transmit/generate side as streaming GNU Radio blocks,
complementing the existing receive chain. Each block maps to a physical
instrument on CuriousMarc's Keysight bench:

  pcm_frame_source  - PCM bit stream generator (sync_block + FrameSourceEngine)
  nrz_encoder       - bits to NRZ waveform (+1/-1) with upsampling
  bpsk_subcarrier_mod - NRZ x cos(1.024 MHz) BPSK modulator
  fm_voice_subcarrier_mod - 1.25 MHz FM test tone source
  pm_mod            - phase modulator: exp(j * deviation * input)
  usb_signal_source - convenience wrapper wiring all blocks together

Includes GRC YAML definitions for all blocks under [Apollo USB] category,
49 new tests (271 total, all passing), and a loopback test that validates
the full TX->RX round trip including frame recovery with 30 dB AWGN.
2026-02-21 18:55:50 -07:00

147 lines
5.0 KiB
Python

"""
Apollo USB Signal Source -- complete transmit chain in one block.
The transmit-side counterpart to usb_downlink_receiver. Wires together the
full modulation chain:
pcm_frame_source -> nrz_encoder -> bpsk_subcarrier_mod -+-> add_ff -> pm_mod -> [complex out]
|
fm_voice_subcarrier_mod --------+
(optional, scaled by 1.68/2.2)
This mirrors CuriousMarc's physical bench topology: the individual composable
blocks map 1:1 to Keysight instruments (EXG signal generator for PM, two
33522B AWGs for subcarrier modulation).
For finer control, use the individual blocks directly.
Reference: IMPLEMENTATION_SPEC.md -- full downlink transmit path
"""
import math
from gnuradio import analog, blocks, gr
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod
from apollo.constants import (
PCM_HIGH_BIT_RATE,
PCM_SUBCARRIER_HZ,
PM_PEAK_DEVIATION_RAD,
SAMPLE_RATE_BASEBAND,
VOICE_FM_DEVIATION_HZ,
VOICE_SUBCARRIER_HZ,
)
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source
from apollo.pm_mod import pm_mod
class usb_signal_source(gr.hier_block2):
"""Apollo USB downlink signal source -- complex baseband output.
Outputs:
complex -- PM-modulated baseband at sample_rate (default 5.12 MHz)
Message inputs:
frame_data -- forwarded to pcm_frame_source for dynamic payload injection
The block generates PCM telemetry frames, NRZ-encodes them, BPSK-modulates
onto a 1.024 MHz subcarrier, optionally adds a 1.25 MHz FM voice subcarrier,
and applies PM modulation to produce complex baseband.
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 = PCM_HIGH_BIT_RATE,
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
voice_enabled: bool = False,
voice_tone_hz: float = 1000.0,
snr_db: float | None = None,
):
gr.hier_block2.__init__(
self,
"apollo_usb_signal_source",
gr.io_signature(0, 0, 0), # source -- no input
gr.io_signature(1, 1, gr.sizeof_gr_complex),
)
self._sample_rate = sample_rate
self._voice_enabled = voice_enabled
# Forward the frame_data message port from pcm_frame_source
self.message_port_register_hier_in("frame_data")
# --- PCM telemetry path ---
# Stage 1: Generate PCM frame bits (0/1 byte stream)
self.frame_src = pcm_frame_source(bit_rate=bit_rate)
# Forward message port: hier input -> pcm_frame_source
self.msg_connect(self, "frame_data", self.frame_src, "frame_data")
# 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: BPSK modulate onto 1.024 MHz subcarrier
self.bpsk = bpsk_subcarrier_mod(
subcarrier_freq=PCM_SUBCARRIER_HZ,
sample_rate=sample_rate,
)
# Connect PCM chain: frame_src -> nrz -> bpsk
self.connect(self.frame_src, self.nrz, self.bpsk)
# --- Subcarrier summing ---
if voice_enabled:
# Voice subcarrier level relative to PCM:
# Per IMPL_SPEC: PCM = 2.2 Vpp, Voice = 1.68 Vpp
# The BPSK subcarrier has unity amplitude, so voice is scaled
# by 1.68/2.2 to maintain the correct power ratio.
voice_scale = 1.68 / 2.2
self.voice = fm_voice_subcarrier_mod(
sample_rate=sample_rate,
subcarrier_freq=VOICE_SUBCARRIER_HZ,
fm_deviation=VOICE_FM_DEVIATION_HZ,
tone_freq=voice_tone_hz,
)
self.voice_gain = blocks.multiply_const_ff(voice_scale)
self.adder = blocks.add_ff(1)
# PCM subcarrier -> adder port 0
self.connect(self.bpsk, (self.adder, 0))
# Voice subcarrier (scaled) -> adder port 1
self.connect(self.voice, self.voice_gain, (self.adder, 1))
composite = self.adder
else:
composite = self.bpsk
# --- PM modulation ---
self.pm = pm_mod(pm_deviation=pm_deviation, sample_rate=sample_rate)
self.connect(composite, 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)