Add FM downlink mode: carrier blocks, convenience wrappers, and loopback demo

FM mode now has the same three-layer architecture as PM mode:
- fm_mod/fm_demod for carrier-level FM modulation
- fm_signal_source/fm_downlink_receiver convenience wrappers
- fm_loopback_demo.py verifying round-trip SCO voltage recovery

Includes GRC YAML for all 4 blocks and doc updates across
blocks reference, SCO guide, and signal architecture pages.
This commit is contained in:
Ryan Malloy 2026-02-24 10:18:42 -07:00
parent 9f10a8fb69
commit 7d48398551
14 changed files with 1029 additions and 0 deletions

View File

@ -226,3 +226,59 @@ The `usb_signal_source` hierarchical block wires the entire TX chain together as
<Aside type="note">
The TX and RX blocks are designed to be exact inverses. Connecting `usb_signal_source` directly to `usb_downlink_receiver` creates a perfect digital loopback -- the `loopback_demo.py` script demonstrates this, recovering transmitted frames with 100% fidelity in the clean (no noise) case.
</Aside>
## FM downlink mode
During pre-launch checkout and certain test configurations, the Apollo USB system switches from PM mode to wideband FM. In this mode, the PCM and voice subcarriers are replaced by 9 Subcarrier Oscillators (SCOs) that encode analog sensor voltages as FM tones:
```mermaid
graph TD
A["Sensor 1\n0-5V DC"] -->|"SCO 1\n14.5 kHz"| G["Composite\nSCO Signal"]
B["Sensor 5\n0-5V DC"] -->|"SCO 5\n52.5 kHz"| G
C["Sensor 9\n0-5V DC"] -->|"SCO 9\n165 kHz"| G
G -->|"FM\n500 kHz dev"| H["2287.5 MHz\nRF Carrier"]
H --> I["Transmitted\nFM Downlink"]
style A fill:#2d5016,stroke:#4a8c2a
style B fill:#2d5016,stroke:#4a8c2a
style C fill:#2d5016,stroke:#4a8c2a
style H fill:#1a3a5c,stroke:#3a7abd
style I fill:#1a3a5c,stroke:#3a7abd
```
The key differences from PM mode:
| Property | PM Mode | FM Mode |
|----------|---------|---------|
| Carrier modulation | Phase (0.133 rad peak) | Frequency (wideband) |
| Data format | Digital (PCM frames) | Analog (voltage-to-frequency) |
| Subcarriers | 1.024 MHz BPSK + 1.25 MHz FM | 9 SCO tones (14.5-165 kHz) |
| Demodulation | Phase extraction | Frequency extraction |
### gr-apollo FM block decomposition
The FM mode blocks mirror the PM mode three-layer architecture:
```mermaid
graph LR
subgraph "Layer 1: Carrier"
A["fm_mod"] --> B["fm_demod"]
end
subgraph "Layer 2: Subcarrier"
C["sco_mod\n(per channel)"] --> D["sco_demod\n(per channel)"]
end
subgraph "Layer 3: Convenience"
E["fm_signal_source"] --> F["fm_downlink_receiver"]
end
style A fill:#1a3a5c,stroke:#3a7abd,color:#fff
style B fill:#1a3a5c,stroke:#3a7abd,color:#fff
style C fill:#5c3a1a,stroke:#bd7a3a,color:#fff
style D fill:#5c3a1a,stroke:#bd7a3a,color:#fff
style E fill:#2d5016,stroke:#4a8c2a,color:#fff
style F fill:#2d5016,stroke:#4a8c2a,color:#fff
```
The `fm_signal_source` and `fm_downlink_receiver` convenience blocks wire the full chain together, just as `usb_signal_source` and `usb_downlink_receiver` do for PM mode. The FM receiver uses streaming float outputs (one per SCO channel) rather than PDU messages, since SCO telemetry is continuous analog data.

View File

@ -318,3 +318,62 @@ The first few milliseconds of demodulator output may show transient behavior as
href="/reference/constants/#subcarrier-oscillators-fm-mode"
/>
</CardGrid>
## Convenience Wrappers
For quick FM downlink testing, the `fm_signal_source` and `fm_downlink_receiver` blocks wrap the entire SCO modulation/demodulation chain into single blocks:
```python
from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.fm_downlink_receiver import fm_downlink_receiver
from apollo.fm_signal_source import fm_signal_source
tb = gr.top_block()
# TX: 3 SCO channels at known voltages
tx = fm_signal_source(
channels=[1, 5, 9],
test_voltages={1: 1.0, 5: 2.5, 9: 4.0},
)
head = blocks.head(gr.sizeof_gr_complex, int(SAMPLE_RATE_BASEBAND * 0.2)) # 200 ms
rx = fm_downlink_receiver(channels=[1, 5, 9])
tb.connect(tx, head, rx)
# One sink per channel
import numpy as np
sinks = []
for idx in range(3):
snk = blocks.vector_sink_f()
tb.connect((rx, idx), snk)
sinks.append(snk)
tb.run()
for idx, ch in enumerate([1, 5, 9]):
data = np.array(sinks[idx].data())
settled = data[len(data) // 5:] # skip first 20% for settling
print(f"SCO {ch}: recovered {np.mean(settled):.3f} V")
```
For a complete round-trip demo with error analysis, run:
```bash
uv run python examples/fm_loopback_demo.py --channels 1 5 9 --snr 30
```
<CardGrid>
<LinkCard
title="fm_signal_source Reference"
description="Full constructor parameters and properties"
href="/reference/blocks/#fm_signal_source"
/>
<LinkCard
title="fm_downlink_receiver Reference"
description="Full constructor parameters and output mapping"
href="/reference/blocks/#fm_downlink_receiver"
/>
</CardGrid>

View File

@ -657,6 +657,51 @@ The voltage mapping is linear: demod output of -1.0 maps to 0V, 0.0 maps to 2.5V
---
### FM Downlink
### `fm_demod`
**Module:** `apollo.fm_demod`
**Type:** `gr.hier_block2`
**Purpose:** Extract frequency modulation from complex baseband using a carrier-tracking PLL and quadrature demodulation.
```python
from apollo.fm_demod import fm_demod
blk = fm_demod(carrier_pll_bw=0.02, fm_deviation_hz=500_000)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `complex` | Complex baseband IQ samples |
| `out0` | Output | `float` | Demodulated composite signal (normalized: +/-1.0 at full deviation) |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `carrier_pll_bw` | `float` | `0.02` | PLL loop bandwidth in rad/sample |
| `fm_deviation_hz` | `float` | `500000` | Expected max FM deviation in Hz (sets demod gain) |
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
#### Runtime Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `get_carrier_pll_bw` | `() -> float` | Read current PLL loop bandwidth |
| `set_carrier_pll_bw` | `(bw: float) -> None` | Update PLL loop bandwidth at runtime |
| `get_fm_deviation` | `() -> float` | Read configured FM deviation |
#### Internal Chain
`input -> pll_carriertracking_cc -> quadrature_demod_cf(gain) -> output`
where `gain = sample_rate / (2*pi*fm_deviation_hz)`.
---
## AGC Integration
### `AGCBridgeClient` / `agc_bridge`
@ -1226,6 +1271,47 @@ blk = sco_mod(sco_number=5, sample_rate=5_120_000)
---
### `fm_mod`
**Module:** `apollo.fm_mod`
**Type:** `gr.hier_block2`
**Purpose:** Apply frequency modulation to produce complex baseband. Used in FM downlink mode.
```python
from apollo.fm_mod import fm_mod
blk = fm_mod(fm_deviation_hz=500_000, sample_rate=5_120_000)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `float` | Composite modulating signal (sum of SCO subcarriers) |
| `out0` | Output | `complex` | FM complex baseband |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `fm_deviation_hz` | `float` | `500000` | Max frequency deviation in Hz (`FM_CARRIER_DEVIATION_HZ`) |
| `sample_rate` | `float` | `5120000` | Sample rate in Hz |
#### Runtime Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `get_fm_deviation` | `() -> float` | Read current FM deviation |
| `set_fm_deviation` | `(hz: float) -> None` | Update FM deviation at runtime |
#### Internal Chain
`input -> multiply_const_ff(1.0) -> frequency_modulator_fc(sensitivity) -> output`
where `sensitivity = 2*pi*fm_deviation_hz/sample_rate`.
---
### `usb_signal_source`
**Module:** `apollo.usb_signal_source`
@ -1282,3 +1368,102 @@ The internal blocks are exposed as instance attributes for runtime inspection or
| `self.adder` | `add_ff` | Subcarrier summer (when `voice_enabled`) |
| `self.pm` | `pm_mod` | Phase modulator |
| `self.noise` | `noise_source_c` | AWGN source (when `snr_db` is set) |
---
### `fm_signal_source`
**Module:** `apollo.fm_signal_source`
**Type:** `gr.hier_block2`
**Purpose:** Complete Apollo FM downlink transmit chain -- generates SCO channels at test voltages, sums them, and applies FM carrier modulation.
```python
from apollo.fm_signal_source import fm_signal_source
blk = fm_signal_source(channels=[1, 5, 9], test_voltages={1: 1.0, 5: 2.5, 9: 4.0}, snr_db=30)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| (none) | Input | (none) | Source block -- no streaming input |
| `out0` | Output | `complex` | FM-modulated complex baseband at `sample_rate` |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `channels` | `list[int]` | `[1, 5, 9]` | SCO channel numbers to generate (1-9) |
| `test_voltages` | `dict[int, float]` | `{ch: 2.5 for each}` | DC voltage per channel (0.0-5.0 V) |
| `sample_rate` | `float` | `5120000` | Baseband sample rate in Hz |
| `fm_deviation_hz` | `float` | `500000` | Carrier FM deviation in Hz |
| `snr_db` | `float \| None` | `None` | Add AWGN at this SNR. `None` = no noise |
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `channels` | `list[int]` | SCO channel numbers being generated |
| `test_voltages` | `dict[int, float]` | Current voltage per channel |
| `fm_deviation_hz` | `float` | Carrier FM deviation in Hz |
#### Internal Signal Chain
```
dc_source(v1) -> sco_mod(ch1) -+-> add_ff -> fm_mod -> [+AWGN] -> output
dc_source(v2) -> sco_mod(ch2) -+
dc_source(vN) -> sco_mod(chN) -+
```
---
### `fm_downlink_receiver`
**Module:** `apollo.fm_downlink_receiver`
**Type:** `gr.hier_block2`
**Purpose:** Complete Apollo FM downlink receiver -- complex baseband input to recovered SCO voltages on streaming float outputs.
```python
from apollo.fm_downlink_receiver import fm_downlink_receiver
blk = fm_downlink_receiver(channels=[1, 5, 9])
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `complex` (streaming) | Baseband IQ samples at `sample_rate` |
| `out0..N-1` | Output | `float` (streaming) | Recovered 0-5V sensor voltage per SCO channel |
Output port ordering matches the `channels` list: output 0 = `channels[0]`, etc.
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `channels` | `list[int]` | `[1, 5, 9]` | SCO channel numbers to decode (1-9) |
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz |
| `carrier_pll_bw` | `float` | `0.02` | FM carrier recovery loop bandwidth (rad/sample) |
| `fm_deviation_hz` | `float` | `500000` | Expected carrier FM deviation in Hz |
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `channels` | `list[int]` | SCO channel numbers being decoded |
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `get_sco_demod` | `(channel: int) -> sco_demod` | Access a specific SCO demodulator for runtime inspection |
#### Internal Signal Chain
```
complex in -> fm_demod -> sco_demod(ch1) -> output[0]
-> sco_demod(ch2) -> output[1]
-> sco_demod(chN) -> output[N-1]
```

View File

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
Apollo FM Downlink Loopback Demo -- SCO round-trip verification.
Demonstrates the FM downlink block chain using GNU Radio streaming blocks:
TX: dc_sources -> sco_mods -> add -> fm_mod
RX: fm_demod -> sco_demods -> recovered voltages
All wrapped in the convenience blocks:
fm_signal_source -> fm_downlink_receiver
Generates FM signal with SCO channels at known DC voltages, demodulates,
and compares recovered vs input voltages.
Usage:
uv run python examples/fm_loopback_demo.py
uv run python examples/fm_loopback_demo.py --channels 1 5 9
uv run python examples/fm_loopback_demo.py --snr 30
uv run python examples/fm_loopback_demo.py --samples 1024000
"""
import argparse
import sys
import numpy as np
from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND, SCO_FREQUENCIES
from apollo.fm_downlink_receiver import fm_downlink_receiver
from apollo.fm_signal_source import fm_signal_source
# Default test voltages: spread across the 0-5V range
DEFAULT_VOLTAGES = {1: 1.0, 5: 2.5, 9: 4.0}
def main():
parser = argparse.ArgumentParser(description="Apollo FM downlink loopback demo")
parser.add_argument(
"--channels", type=int, nargs="+", default=[1, 5, 9],
help="SCO channel numbers to test (default: 1 5 9)",
)
parser.add_argument(
"--snr", type=float, default=None,
help="SNR in dB (default: no noise)",
)
parser.add_argument(
"--samples", type=int, default=10 * 102400,
help="Number of samples to process (default: 1024000)",
)
args = parser.parse_args()
channels = args.channels
n_samples = args.samples
# Assign test voltages: spread evenly across 0-5V range
if set(channels) == {1, 5, 9}:
test_voltages = dict(DEFAULT_VOLTAGES)
else:
step = 4.0 / max(1, len(channels) - 1) if len(channels) > 1 else 0
test_voltages = {ch: 0.5 + i * step for i, ch in enumerate(channels)}
print("=" * 60)
print("Apollo FM Downlink Loopback Demo")
print("=" * 60)
print(f" Channels: {channels}")
print(f" Input voltages:")
for ch in channels:
v = test_voltages[ch]
freq = SCO_FREQUENCIES[ch]
print(f" SCO {ch} ({freq:,} Hz): {v:.2f} V")
print(f" Samples: {n_samples:,}")
print(f" Duration: {n_samples / SAMPLE_RATE_BASEBAND:.3f} s")
print(f" SNR: {'clean (no noise)' if args.snr is None else f'{args.snr} dB'}")
print()
# Build the flowgraph
print("Building flowgraph...")
tb = gr.top_block()
tx = fm_signal_source(
channels=channels,
test_voltages=test_voltages,
snr_db=args.snr,
)
head = blocks.head(gr.sizeof_gr_complex, n_samples)
rx = fm_downlink_receiver(channels=channels)
tb.connect(tx, head, rx)
# One vector sink per output channel
sinks = []
for idx in range(len(channels)):
snk = blocks.vector_sink_f()
tb.connect((rx, idx), snk)
sinks.append(snk)
print("Running flowgraph (TX -> RX)...")
print()
tb.run()
# Analyze results
print("-" * 60)
print(f" {'Channel':>10} {'Freq':>10} {'Input':>8} {'Recovered':>10} {'Error':>8}")
print("-" * 60)
max_error = 0.0
for idx, ch in enumerate(channels):
data = np.array(sinks[idx].data())
if len(data) == 0:
print(f" SCO {ch:>2} {SCO_FREQUENCIES[ch]:>8,} Hz {test_voltages[ch]:>6.2f} V {'NO DATA':>10} {'N/A':>8}")
continue
# Skip first 20% for filter settling
settle = len(data) // 5
settled = data[settle:]
if len(settled) == 0:
mean_v = np.mean(data)
else:
mean_v = np.mean(settled)
error = abs(test_voltages[ch] - mean_v)
max_error = max(max_error, error)
print(
f" SCO {ch:>2} {SCO_FREQUENCIES[ch]:>8,} Hz "
f"{test_voltages[ch]:>6.2f} V {mean_v:>8.3f} V {error:>6.3f} V"
)
print("-" * 60)
print()
if max_error > 0.5:
print(f"Max error: {max_error:.3f} V -- EXCESSIVE (> 0.5V)")
print("PLL may need more settling time. Try increasing --samples.")
sys.exit(1)
elif max_error > 0.1:
print(f"Max error: {max_error:.3f} V -- MODERATE")
print("Consider increasing --samples or --snr for better accuracy.")
else:
print(f"Max error: {max_error:.3f} V -- GOOD")
print("FM loopback complete.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,48 @@
id: apollo_fm_demod
label: Apollo FM Demod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: carrier_pll_bw
label: Carrier PLL Bandwidth
dtype: real
default: '0.02'
- id: fm_deviation_hz
label: FM Deviation (Hz)
dtype: real
default: '500000'
- id: sample_rate
label: Sample Rate
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: complex
outputs:
- label: out
domain: stream
dtype: float
templates:
imports: from apollo.fm_demod import fm_demod
make: apollo.fm_demod.fm_demod(carrier_pll_bw=${carrier_pll_bw}, fm_deviation_hz=${fm_deviation_hz}, sample_rate=${sample_rate})
documentation: |-
Apollo FM Demodulator
Extracts frequency modulation from complex baseband signal.
Uses a carrier tracking PLL followed by quadrature demodulation
to recover the instantaneous frequency (composite SCO signal).
Output is normalized so +/- full FM deviation maps to +/- 1.0.
Parameters:
carrier_pll_bw: PLL loop bandwidth in rad/sample (default 0.02)
fm_deviation_hz: Expected max FM deviation in Hz (default 500 kHz)
sample_rate: Input sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,63 @@
id: apollo_fm_downlink_receiver
label: Apollo FM Downlink Receiver
category: '[Apollo USB]'
flags: [python]
parameters:
- id: channels
label: SCO Channels
dtype: raw
default: '[1, 5, 9]'
- id: fm_deviation_hz
label: FM Deviation (Hz)
dtype: real
default: '500000'
- id: sample_rate
label: Sample Rate
dtype: float
default: '5120000'
- id: carrier_pll_bw
label: Carrier PLL BW
dtype: float
default: '0.02'
inputs:
- label: in
domain: stream
dtype: complex
outputs:
- label: ch${n}
domain: stream
dtype: float
multiplicity: ${ len(channels) }
templates:
imports: from apollo.fm_downlink_receiver import fm_downlink_receiver
make: >-
apollo.fm_downlink_receiver.fm_downlink_receiver(
channels=${channels},
sample_rate=${sample_rate},
carrier_pll_bw=${carrier_pll_bw},
fm_deviation_hz=${fm_deviation_hz})
documentation: |-
Apollo FM Downlink Receiver -- complete FM demod chain in one block.
Demodulates an FM-modulated complex baseband signal and recovers
individual SCO analog telemetry channels. Each output port provides
a recovered 0-5V sensor voltage for the corresponding SCO channel.
Output port ordering matches the channels list:
output 0 = channels[0], output 1 = channels[1], etc.
Used in FM downlink mode (pre-launch checkout), not PM mode.
This is the receive-side counterpart to the FM Signal Source.
Parameters:
channels: List of SCO channel numbers to decode (1-9)
fm_deviation_hz: Expected carrier FM deviation in Hz (default 500 kHz)
sample_rate: Baseband sample rate (default 5.12 MHz)
carrier_pll_bw: FM carrier recovery loop bandwidth
file_format: 1

View File

@ -0,0 +1,45 @@
id: apollo_fm_mod
label: Apollo FM Mod
category: '[Apollo USB]'
flags: [python]
parameters:
- id: fm_deviation_hz
label: FM Deviation (Hz)
dtype: real
default: '500000'
- id: sample_rate
label: Sample Rate
dtype: real
default: '5120000'
inputs:
- label: in
domain: stream
dtype: float
outputs:
- label: out
domain: stream
dtype: complex
templates:
imports: from apollo.fm_mod import fm_mod
make: apollo.fm_mod.fm_mod(fm_deviation_hz=${fm_deviation_hz}, sample_rate=${sample_rate})
documentation: |-
Apollo FM Modulator
Applies frequency modulation to produce complex baseband signal.
Takes a composite modulating signal (sum of SCO subcarriers) and outputs
an FM complex baseband where instantaneous frequency is proportional to
the input amplitude.
Used in FM downlink mode (pre-launch checkout), not PM mode.
This is the transmit-side counterpart to Apollo FM Demod.
Parameters:
fm_deviation_hz: Max frequency deviation in Hz (default 500 kHz)
sample_rate: Sample rate in Hz (default 5.12 MHz)
file_format: 1

View File

@ -0,0 +1,60 @@
id: apollo_fm_signal_source
label: Apollo FM Signal Source
category: '[Apollo USB]'
flags: [python]
parameters:
- id: channels
label: SCO Channels
dtype: raw
default: '[1, 5, 9]'
- id: test_voltages
label: Test Voltages (dict)
dtype: raw
default: 'None'
- id: fm_deviation_hz
label: FM Deviation (Hz)
dtype: real
default: '500000'
- id: sample_rate
label: Sample Rate (Hz)
dtype: float
default: '5120000'
- id: snr_db
label: SNR (dB)
dtype: raw
default: 'None'
outputs:
- label: out
domain: stream
dtype: complex
templates:
imports: from apollo.fm_signal_source import fm_signal_source
make: >-
apollo.fm_signal_source.fm_signal_source(
channels=${channels},
test_voltages=${test_voltages},
sample_rate=${sample_rate},
fm_deviation_hz=${fm_deviation_hz},
snr_db=${snr_db})
documentation: |-
Apollo FM Signal Source -- complete FM transmit chain in one block.
Generates an FM-modulated complex baseband signal containing
SCO analog telemetry channels. Each channel encodes a DC test
voltage as an FM subcarrier tone (14.5 kHz to 165 kHz).
Used in FM downlink mode (pre-launch checkout), not PM mode.
This is the transmit-side counterpart to the FM Downlink Receiver.
Parameters:
channels: List of SCO channel numbers to generate (1-9)
test_voltages: Dict mapping channel -> DC voltage (default 2.5V)
fm_deviation_hz: Carrier FM deviation in Hz (default 500 kHz)
sample_rate: Output sample rate (default 5.12 MHz)
snr_db: Add AWGN noise at this SNR (None = no noise)
file_format: 1

View File

@ -26,6 +26,7 @@ from apollo.usb_signal_gen import generate_usb_baseband as generate_usb_baseband
try:
from apollo.bpsk_demod import bpsk_demod as bpsk_demod
from apollo.bpsk_subcarrier_demod import bpsk_subcarrier_demod as bpsk_subcarrier_demod
from apollo.fm_demod import fm_demod as fm_demod
from apollo.pm_demod import pm_demod as pm_demod
from apollo.sco_demod import sco_demod as sco_demod
from apollo.subcarrier_extract import subcarrier_extract as subcarrier_extract
@ -36,6 +37,7 @@ except ImportError:
# GNU Radio transmit-side blocks
try:
from apollo.bpsk_subcarrier_mod import bpsk_subcarrier_mod as bpsk_subcarrier_mod
from apollo.fm_mod import fm_mod as fm_mod
from apollo.fm_voice_subcarrier_mod import fm_voice_subcarrier_mod as fm_voice_subcarrier_mod
from apollo.nrz_encoder import nrz_encoder as nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source as pcm_frame_source
@ -48,6 +50,8 @@ except ImportError:
try:
from apollo.agc_bridge import agc_bridge as agc_bridge
from apollo.downlink_decoder import downlink_decoder as downlink_decoder
from apollo.fm_downlink_receiver import fm_downlink_receiver as fm_downlink_receiver
from apollo.fm_signal_source import fm_signal_source as fm_signal_source
from apollo.pcm_demux import pcm_demux as pcm_demux
from apollo.pcm_frame_sync import pcm_frame_sync as pcm_frame_sync
from apollo.uplink_encoder import uplink_encoder as uplink_encoder

View File

@ -20,6 +20,7 @@ PM_PEAK_DEVIATION_RAD = 0.133 # 7.6 degrees peak phase deviation
PM_SENSITIVITY_RAD_PER_V = 0.033 # at 1 kHz
FM_VCO_SENSITIVITY_HZ_PER_V = 1_500_000 # 1.5 MHz peak / V peak
FM_MODULATION_BW_HZ = 1_500_000 # 5 Hz to 1.5 MHz
FM_CARRIER_DEVIATION_HZ = 500_000 # default max FM deviation for carrier
# ---------------------------------------------------------------------------
# Subcarrier Frequencies (IMPL_SPEC section 4.2)

72
src/apollo/fm_demod.py Normal file
View File

@ -0,0 +1,72 @@
"""
Apollo FM Demodulator -- extracts frequency modulation from complex baseband.
The receive-side counterpart to fm_mod. Recovers the composite modulating
signal (sum of SCO subcarriers) from an FM complex baseband input by tracking
the carrier with a PLL and extracting instantaneous frequency.
The key difference from pm_demod: PM extracts instantaneous *phase* via
complex_to_arg, while FM extracts instantaneous *frequency* via
quadrature_demod_cf (which computes the phase derivative).
The quadrature demod gain is chosen so that +/- full deviation maps to
+/- 1.0 at the output. This normalized output feeds directly into the
sco_demod blocks for individual channel recovery.
Reference: IMPLEMENTATION_SPEC.md section 2.3
"""
import math
from gnuradio import analog, gr
from apollo.constants import FM_CARRIER_DEVIATION_HZ, SAMPLE_RATE_BASEBAND
class fm_demod(gr.hier_block2):
"""FM demodulator with carrier recovery.
Inputs:
complex baseband (FM-modulated signal)
Outputs:
float -- demodulated composite signal containing all SCO subcarriers
"""
def __init__(
self,
carrier_pll_bw: float = 0.02,
fm_deviation_hz: float = FM_CARRIER_DEVIATION_HZ,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_fm_demod",
gr.io_signature(1, 1, gr.sizeof_gr_complex),
gr.io_signature(1, 1, gr.sizeof_float),
)
self._fm_deviation_hz = fm_deviation_hz
self._sample_rate = sample_rate
# Carrier tracking PLL -- same as pm_demod
max_freq = carrier_pll_bw * 2.0
min_freq = -max_freq
self.pll = analog.pll_carriertracking_cc(carrier_pll_bw, max_freq, min_freq)
# Quadrature demod: extract instantaneous frequency
# Gain normalizes output so +/- fm_deviation_hz maps to +/- 1.0
fm_gain = sample_rate / (2.0 * math.pi * fm_deviation_hz)
self.fm_demod = analog.quadrature_demod_cf(fm_gain)
# Connect: input -> PLL -> quadrature demod -> output
self.connect(self, self.pll, self.fm_demod, self)
def get_carrier_pll_bw(self) -> float:
return self.pll.get_loop_bandwidth()
def set_carrier_pll_bw(self, bw: float):
self.pll.set_loop_bandwidth(bw)
def get_fm_deviation(self) -> float:
return self._fm_deviation_hz

View File

@ -0,0 +1,94 @@
"""
Apollo FM Downlink Receiver -- top-level hierarchical block for FM mode.
Combines FM carrier demodulation with per-channel SCO demodulation:
complex baseband -> fm_demod -> sco_demod(ch1) -> output[0]
-> sco_demod(ch2) -> output[1]
-> sco_demod(chN) -> output[N-1]
Input: complex baseband samples at 5.12 MHz
Output: N streaming float outputs, one per SCO channel (recovered 0-5V voltage)
Unlike usb_downlink_receiver (which outputs PDU messages), this block uses
streaming float outputs because SCO telemetry is continuous analog data,
not discrete frames.
For finer control over individual channel parameters, use fm_demod and
sco_demod blocks directly.
Reference: IMPLEMENTATION_SPEC.md sections 2.3, 4.3
"""
from gnuradio import gr
from apollo.constants import FM_CARRIER_DEVIATION_HZ, SAMPLE_RATE_BASEBAND, SCO_FREQUENCIES
from apollo.fm_demod import fm_demod
from apollo.sco_demod import sco_demod
class fm_downlink_receiver(gr.hier_block2):
"""Apollo FM downlink receiver -- complex baseband to recovered SCO voltages.
Inputs:
complex -- baseband IQ samples at sample_rate (default 5.12 MHz)
Outputs:
float[0..N-1] -- recovered sensor voltage per SCO channel (0.0 to 5.0 V)
Output ordering matches the channels list: output 0 = channels[0], etc.
"""
def __init__(
self,
channels: list[int] | None = None,
sample_rate: float = SAMPLE_RATE_BASEBAND,
carrier_pll_bw: float = 0.02,
fm_deviation_hz: float = FM_CARRIER_DEVIATION_HZ,
):
if channels is None:
channels = [1, 5, 9]
n_channels = len(channels)
gr.hier_block2.__init__(
self,
"apollo_fm_downlink_receiver",
gr.io_signature(1, 1, gr.sizeof_gr_complex),
gr.io_signature(n_channels, n_channels, gr.sizeof_float),
)
self._channels = list(channels)
self._sample_rate = sample_rate
# Validate channels
for ch in self._channels:
if ch not in SCO_FREQUENCIES:
raise ValueError(
f"SCO channel {ch} invalid. Valid: {sorted(SCO_FREQUENCIES.keys())}"
)
# Stage 1: FM carrier demodulator
self.fm = fm_demod(
carrier_pll_bw=carrier_pll_bw,
fm_deviation_hz=fm_deviation_hz,
sample_rate=sample_rate,
)
self.connect(self, self.fm)
# Stage 2: Per-channel SCO demodulators
self._sco_demods = {}
for idx, ch in enumerate(self._channels):
demod = sco_demod(sco_number=ch, sample_rate=sample_rate)
self._sco_demods[ch] = demod
# fm_demod output -> sco_demod -> hier output[idx]
self.connect(self.fm, demod, (self, idx))
@property
def channels(self) -> list[int]:
"""SCO channel numbers being decoded."""
return list(self._channels)
def get_sco_demod(self, channel: int) -> sco_demod:
"""Access a specific SCO demodulator for runtime inspection."""
return self._sco_demods[channel]

58
src/apollo/fm_mod.py Normal file
View File

@ -0,0 +1,58 @@
"""
Apollo FM Modulator -- applies frequency modulation to produce complex baseband.
The transmit-side counterpart to fm_demod. Takes a composite modulating signal
(sum of SCO subcarriers) and produces FM complex baseband where the
instantaneous frequency is proportional to the input amplitude.
In FM downlink mode, the spacecraft uses wideband FM instead of the narrow PM
used for normal PCM/voice operations. The SCO composite signal frequency-modulates
the carrier with much higher deviation than PM mode.
Reference: IMPLEMENTATION_SPEC.md section 2.3
"""
import math
from gnuradio import analog, blocks, gr
from apollo.constants import FM_CARRIER_DEVIATION_HZ, SAMPLE_RATE_BASEBAND
class fm_mod(gr.hier_block2):
"""FM modulator: float input -> FM complex baseband output."""
def __init__(
self,
fm_deviation_hz: float = FM_CARRIER_DEVIATION_HZ,
sample_rate: float = SAMPLE_RATE_BASEBAND,
):
gr.hier_block2.__init__(
self,
"apollo_fm_mod",
gr.io_signature(1, 1, gr.sizeof_float),
gr.io_signature(1, 1, gr.sizeof_gr_complex),
)
self._fm_deviation_hz = fm_deviation_hz
self._sample_rate = sample_rate
# Runtime-adjustable gain (unity default; mirrors pm_mod pattern)
self.gain = blocks.multiply_const_ff(1.0)
# FM modulate: sensitivity encodes full deviation
sensitivity = 2.0 * math.pi * fm_deviation_hz / sample_rate
self.modulator = analog.frequency_modulator_fc(sensitivity)
# Connect: input -> gain -> fm_mod -> output
self.connect(self, self.gain, self.modulator, self)
def get_fm_deviation(self) -> float:
"""Return current FM deviation in Hz."""
return self._fm_deviation_hz
def set_fm_deviation(self, hz: float):
"""Update FM deviation at runtime by rescaling modulator sensitivity."""
self._fm_deviation_hz = hz
sensitivity = 2.0 * math.pi * hz / self._sample_rate
self.modulator.set_sensitivity(sensitivity)

View File

@ -0,0 +1,135 @@
"""
Apollo FM Downlink Signal Source -- complete FM transmit chain in one block.
The transmit-side counterpart to fm_downlink_receiver. Wires together the
full FM modulation chain for SCO analog telemetry:
dc_source(v1) -> sco_mod(ch1) -+-> add_ff -> fm_mod -> [+AWGN] -> complex out
dc_source(v2) -> sco_mod(ch2) -+
dc_source(vN) -> sco_mod(chN) -+
In FM downlink mode (used for pre-launch checkout), the spacecraft replaces
the PCM/voice subcarrier system with 9 Subcarrier Oscillators (SCOs) that
encode analog sensor voltages as FM tones. These SCO tones are summed and
frequency-modulate the RF carrier.
For finer control, use the individual sco_mod and fm_mod blocks directly.
Reference: IMPLEMENTATION_SPEC.md sections 2.3, 4.3
"""
import math
from gnuradio import analog, blocks, gr
from apollo.constants import FM_CARRIER_DEVIATION_HZ, SAMPLE_RATE_BASEBAND, SCO_FREQUENCIES
from apollo.fm_mod import fm_mod
from apollo.sco_mod import sco_mod
class fm_signal_source(gr.hier_block2):
"""Apollo FM downlink signal source -- complex baseband output.
Outputs:
complex -- FM-modulated baseband at sample_rate (default 5.12 MHz)
Generates DC test voltages for each configured SCO channel, modulates
them onto their respective subcarrier tones, sums the composite, and
applies wideband FM to produce complex baseband.
Optional AWGN noise can be added by setting snr_db to a finite value.
"""
def __init__(
self,
channels: list[int] | None = None,
test_voltages: dict[int, float] | None = None,
sample_rate: float = SAMPLE_RATE_BASEBAND,
fm_deviation_hz: float = FM_CARRIER_DEVIATION_HZ,
snr_db: float | None = None,
):
if channels is None:
channels = [1, 5, 9]
if test_voltages is None:
test_voltages = {ch: 2.5 for ch in channels}
gr.hier_block2.__init__(
self,
"apollo_fm_signal_source",
gr.io_signature(0, 0, 0), # source -- no input
gr.io_signature(1, 1, gr.sizeof_gr_complex),
)
self._channels = list(channels)
self._test_voltages = dict(test_voltages)
self._fm_deviation_hz = fm_deviation_hz
self._sample_rate = sample_rate
# Validate channels
for ch in self._channels:
if ch not in SCO_FREQUENCIES:
raise ValueError(
f"SCO channel {ch} invalid. Valid: {sorted(SCO_FREQUENCIES.keys())}"
)
# --- Build SCO modulation chains ---
self._dc_sources = {}
self._sco_mods = {}
n_channels = len(self._channels)
self.adder = blocks.add_ff(1)
for idx, ch in enumerate(self._channels):
voltage = self._test_voltages.get(ch, 2.5)
# DC source at the test voltage
dc = analog.sig_source_f(
sample_rate, analog.GR_CONST_WAVE, 0, voltage, 0,
)
self._dc_sources[ch] = dc
# SCO modulator for this channel
mod = sco_mod(sco_number=ch, sample_rate=sample_rate)
self._sco_mods[ch] = mod
# Connect: dc -> sco_mod -> adder port idx
self.connect(dc, mod, (self.adder, idx))
# --- FM carrier modulation ---
self.fm = fm_mod(fm_deviation_hz=fm_deviation_hz, sample_rate=sample_rate)
self.connect(self.adder, self.fm)
# --- Optional AWGN ---
if snr_db is not None:
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.fm, (self.sum_noise, 0))
self.connect(self.noise, (self.sum_noise, 1))
self.connect(self.sum_noise, self)
else:
self.connect(self.fm, self)
@property
def channels(self) -> list[int]:
"""SCO channel numbers being generated."""
return list(self._channels)
@property
def test_voltages(self) -> dict[int, float]:
"""Current test voltage per channel."""
return dict(self._test_voltages)
@property
def fm_deviation_hz(self) -> float:
"""Carrier FM deviation in Hz."""
return self._fm_deviation_hz