Complete signal processing pipeline from complex baseband to decoded PCM telemetry, verified against the 1965 NAA Study Guide (A-624): Core demod (Phase 1): - PM demodulator with carrier PLL recovery - 1.024 MHz subcarrier extractor (bandpass + downconvert) - BPSK demodulator with Costas loop + symbol sync - Convenience hier_block2 combining subcarrier + BPSK PCM frame processing (Phase 2): - 32-bit frame sync with Hamming distance correlator - SEARCH/VERIFY/LOCKED state machine, complement-on-odd handling - Frame demultiplexer (128-word, A/D voltage scaling) - AGC downlink decoder (15-bit word reassembly from DNTM1/DNTM2) Voice and analog (Phase 3): - 1.25 MHz FM voice subcarrier demod to 8 kHz audio - SCO demodulator for 9 analog sensor channels (14.5-165 kHz) Virtual AGC integration (Phase 4): - TCP bridge client with auto-reconnect and channel filtering - DSKY uplink encoder (VERB/NOUN/DATA command sequences) Top-level receiver (Phase 5): - usb_downlink_receiver hier_block2: one block, complex in, PDUs out - 14 GRC block YAML definitions for GNU Radio Companion - Example scripts for signal analysis and full-chain demo Infrastructure: - constants.py with all timing/frequency/frame parameters - protocol.py for sync word + AGC packet encode/decode - Synthetic USB signal generator for testing - 222 tests passing, ruff lint clean
225 lines
7.2 KiB
Python
225 lines
7.2 KiB
Python
"""
|
|
Synthetic Apollo Unified S-Band downlink signal generator.
|
|
|
|
Generates complex baseband representing a PM-modulated carrier with:
|
|
- 1.024 MHz BPSK subcarrier (PCM telemetry NRZ data)
|
|
- Optional 1.25 MHz FM voice subcarrier (test tone)
|
|
- Configurable SNR
|
|
|
|
Used for testing the entire demodulation chain against known data.
|
|
All parameters from IMPLEMENTATION_SPEC.md sections 2.3, 4.2, 5.1.
|
|
"""
|
|
|
|
import numpy as np
|
|
|
|
from apollo.constants import (
|
|
PCM_HIGH_BIT_RATE,
|
|
PCM_HIGH_WORDS_PER_FRAME,
|
|
PCM_SUBCARRIER_HZ,
|
|
PCM_SYNC_WORD_LENGTH,
|
|
PCM_WORD_LENGTH,
|
|
PM_PEAK_DEVIATION_RAD,
|
|
SAMPLE_RATE_BASEBAND,
|
|
VOICE_FM_DEVIATION_HZ,
|
|
VOICE_SUBCARRIER_HZ,
|
|
)
|
|
from apollo.protocol import generate_sync_word, sync_word_to_bits
|
|
|
|
|
|
def generate_pcm_frame(
|
|
frame_id: int = 1,
|
|
odd: bool = False,
|
|
data: bytes | None = None,
|
|
words_per_frame: int = PCM_HIGH_WORDS_PER_FRAME,
|
|
) -> list[int]:
|
|
"""Generate a complete PCM frame as a list of bits (MSB first, NRZ).
|
|
|
|
Args:
|
|
frame_id: Frame number (1-50).
|
|
odd: Whether this is an odd-numbered frame (complement sync core).
|
|
data: Optional payload bytes (words 5-128/200). Random if None.
|
|
words_per_frame: 128 (high rate) or 200 (low rate).
|
|
|
|
Returns:
|
|
List of bit values (0 or 1), length = words_per_frame * 8.
|
|
"""
|
|
# Generate 32-bit sync word (words 1-4)
|
|
sync = generate_sync_word(frame_id=frame_id, odd=odd)
|
|
bits = sync_word_to_bits(sync)
|
|
|
|
# Data words (words 5 through end)
|
|
data_words = words_per_frame - (PCM_SYNC_WORD_LENGTH // PCM_WORD_LENGTH)
|
|
if data is not None:
|
|
payload = list(data[:data_words])
|
|
# Pad if needed
|
|
while len(payload) < data_words:
|
|
payload.append(0x00)
|
|
else:
|
|
payload = [np.random.randint(0, 256) for _ in range(data_words)]
|
|
|
|
for byte_val in payload:
|
|
for bit_pos in range(7, -1, -1): # MSB first
|
|
bits.append((byte_val >> bit_pos) & 1)
|
|
|
|
return bits
|
|
|
|
|
|
def generate_nrz_waveform(
|
|
bits: list[int],
|
|
bit_rate: float,
|
|
sample_rate: float,
|
|
) -> np.ndarray:
|
|
"""Convert a bit sequence to an NRZ baseband waveform.
|
|
|
|
NRZ: bit 1 → +1.0, bit 0 → -1.0.
|
|
|
|
Args:
|
|
bits: List of bit values (0 or 1).
|
|
bit_rate: Bit rate in Hz.
|
|
sample_rate: Output sample rate in Hz.
|
|
|
|
Returns:
|
|
Float array of NRZ samples.
|
|
"""
|
|
samples_per_bit = sample_rate / bit_rate
|
|
n_samples = int(len(bits) * samples_per_bit)
|
|
waveform = np.empty(n_samples, dtype=np.float32)
|
|
|
|
for i, bit in enumerate(bits):
|
|
start = int(i * samples_per_bit)
|
|
end = int((i + 1) * samples_per_bit)
|
|
waveform[start:end] = 1.0 if bit == 1 else -1.0
|
|
|
|
return waveform
|
|
|
|
|
|
def generate_bpsk_subcarrier(
|
|
nrz_data: np.ndarray,
|
|
subcarrier_freq: float,
|
|
sample_rate: float,
|
|
) -> np.ndarray:
|
|
"""Generate a BPSK-modulated subcarrier.
|
|
|
|
The 1.024 MHz subcarrier is bi-phase modulated by NRZ data:
|
|
output(t) = data(t) * cos(2*pi*f_sc*t)
|
|
|
|
Args:
|
|
nrz_data: NRZ waveform (+1/-1 values).
|
|
subcarrier_freq: Subcarrier frequency in Hz.
|
|
sample_rate: Sample rate in Hz.
|
|
|
|
Returns:
|
|
Float array of BPSK subcarrier samples.
|
|
"""
|
|
t = np.arange(len(nrz_data), dtype=np.float64) / sample_rate
|
|
carrier = np.cos(2.0 * np.pi * subcarrier_freq * t)
|
|
return (nrz_data * carrier).astype(np.float32)
|
|
|
|
|
|
def generate_fm_voice_subcarrier(
|
|
n_samples: int,
|
|
sample_rate: float,
|
|
tone_freq: float = 1000.0,
|
|
subcarrier_freq: float = VOICE_SUBCARRIER_HZ,
|
|
fm_deviation: float = VOICE_FM_DEVIATION_HZ,
|
|
) -> np.ndarray:
|
|
"""Generate an FM voice subcarrier with a test tone.
|
|
|
|
Voice path: audio → FM VCO → upconvert to 1.25 MHz.
|
|
|
|
Args:
|
|
n_samples: Number of output samples.
|
|
sample_rate: Sample rate in Hz.
|
|
tone_freq: Audio test tone frequency in Hz.
|
|
subcarrier_freq: Voice subcarrier center frequency.
|
|
fm_deviation: FM deviation in Hz.
|
|
|
|
Returns:
|
|
Float array of FM voice subcarrier samples.
|
|
"""
|
|
t = np.arange(n_samples, dtype=np.float64) / sample_rate
|
|
# FM modulation: instantaneous phase = 2*pi*fc*t + (dev/f_tone)*sin(2*pi*f_tone*t)
|
|
modulation_index = fm_deviation / tone_freq
|
|
phase = 2.0 * np.pi * subcarrier_freq * t + modulation_index * np.sin(
|
|
2.0 * np.pi * tone_freq * t
|
|
)
|
|
return np.cos(phase).astype(np.float32)
|
|
|
|
|
|
def generate_usb_baseband(
|
|
frames: int = 1,
|
|
bit_rate: float = PCM_HIGH_BIT_RATE,
|
|
sample_rate: float = SAMPLE_RATE_BASEBAND,
|
|
pm_deviation: float = PM_PEAK_DEVIATION_RAD,
|
|
voice_enabled: bool = False,
|
|
voice_tone_hz: float = 1000.0,
|
|
snr_db: float | None = None,
|
|
frame_data: list[bytes] | None = None,
|
|
) -> tuple[np.ndarray, list[list[int]]]:
|
|
"""Generate a complete Apollo USB downlink baseband signal.
|
|
|
|
Produces complex baseband representing a PM-modulated carrier with
|
|
BPSK PCM subcarrier and optional FM voice subcarrier.
|
|
|
|
Args:
|
|
frames: Number of PCM frames to generate.
|
|
bit_rate: PCM bit rate (51200 or 1600).
|
|
sample_rate: Output sample rate in Hz.
|
|
pm_deviation: Peak PM deviation in radians.
|
|
voice_enabled: Include 1.25 MHz FM voice subcarrier.
|
|
voice_tone_hz: Voice test tone frequency.
|
|
snr_db: If not None, add AWGN at this SNR (dB).
|
|
frame_data: Optional list of payload bytes per frame.
|
|
|
|
Returns:
|
|
Tuple of (complex baseband signal, list of bit sequences per frame).
|
|
"""
|
|
words_per_frame = 128 if bit_rate == PCM_HIGH_BIT_RATE else 200
|
|
|
|
all_bits = []
|
|
all_frame_bits = []
|
|
|
|
for i in range(frames):
|
|
frame_id = (i % 50) + 1
|
|
odd = (frame_id % 2) == 1
|
|
data = frame_data[i] if frame_data and i < len(frame_data) else None
|
|
frame_bits = generate_pcm_frame(
|
|
frame_id=frame_id, odd=odd, data=data, words_per_frame=words_per_frame
|
|
)
|
|
all_frame_bits.append(frame_bits)
|
|
all_bits.extend(frame_bits)
|
|
|
|
# NRZ waveform at the output sample rate
|
|
nrz = generate_nrz_waveform(all_bits, bit_rate, sample_rate)
|
|
|
|
# BPSK subcarrier
|
|
pcm_subcarrier = generate_bpsk_subcarrier(nrz, PCM_SUBCARRIER_HZ, sample_rate)
|
|
|
|
# Composite modulating signal (scaled for PM deviation)
|
|
# The PCM subcarrier level sets the PM deviation
|
|
modulating = pcm_subcarrier * pm_deviation
|
|
|
|
if voice_enabled:
|
|
voice = generate_fm_voice_subcarrier(
|
|
len(nrz), sample_rate, tone_freq=voice_tone_hz
|
|
)
|
|
# Voice subcarrier at reduced level relative to PCM
|
|
# Per IMPL_SPEC: PCM=2.2Vpp, Voice=1.68Vpp → ratio 1.68/2.2 ≈ 0.76
|
|
voice_level = pm_deviation * (1.68 / 2.2)
|
|
modulating = modulating + voice * voice_level
|
|
|
|
# PM modulation: s(t) = exp(j * modulating(t))
|
|
# At baseband, the carrier is at DC, so this is just phase modulation
|
|
signal = np.exp(1j * modulating).astype(np.complex64)
|
|
|
|
# Add noise if requested
|
|
if snr_db is not None:
|
|
signal_power = np.mean(np.abs(signal) ** 2)
|
|
noise_power = signal_power / (10.0 ** (snr_db / 10.0))
|
|
noise = np.sqrt(noise_power / 2) * (
|
|
np.random.randn(len(signal)) + 1j * np.random.randn(len(signal))
|
|
)
|
|
signal = (signal + noise).astype(np.complex64)
|
|
|
|
return signal, all_frame_bits
|