Integrate uplink chain and PRN ranging into shared files and docs

Consolidate locally-defined constants into constants.py (single source
of truth for all uplink modulation and ranging parameters). Update
__init__.py with new imports across all three tiers. Add signal
architecture, block reference, and demo guide documentation for the
uplink and ranging subsystems.
This commit is contained in:
Ryan Malloy 2026-02-24 14:32:45 -07:00
parent 3dc8afdc08
commit cfc9ca03eb
11 changed files with 1253 additions and 47 deletions

View File

@ -1,6 +1,6 @@
---
title: "Signal Architecture"
description: "How the Apollo Unified S-Band downlink signal is structured, and how gr-apollo decomposes it into telemetry"
description: "How the Apollo Unified S-Band signal is structured -- downlink, uplink, and ranging -- and how gr-apollo decomposes it"
---
import { Aside, Tabs, TabItem } from '@astrojs/starlight/components';
@ -282,3 +282,158 @@ graph LR
```
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.
## The uplink signal
The downlink carries telemetry from spacecraft to ground. The uplink does the reverse -- delivering commands from Mission Control to the spacecraft on 2106.40625 MHz.
The modulation scheme is the same family as the downlink (PM carrier with FM subcarriers), but the parameters are dramatically different. Where the downlink tiptoes along at 0.133 rad peak deviation, the uplink runs at 1.0 rad. This asymmetry is not a design inconsistency -- it reflects a fundamental physical reality. The ground station has megawatt-class transmitters and 26-meter dish antennas. The spacecraft has a 20-watt traveling-wave tube and a dish measured in inches. The ground can afford the power overhead of higher modulation index; the spacecraft cannot waste a single fraction of a watt.
The uplink carries two information streams:
- **Command data** -- DSKY commands encoded as 15-bit AGC words, transmitted at 2 kbps NRZ on a 70 kHz FM subcarrier with +/-4 kHz deviation
- **Voice** (optional) -- Crew-directed audio on a 30 kHz FM subcarrier with +/-7.5 kHz deviation
```mermaid
graph LR
subgraph "TX — Ground Station"
A["DSKY Commands<br/>15-bit AGC words"] --> B["Word Serializer<br/>15 bits → NRZ<br/>2 kbps"]
B --> C["NRZ Encoder<br/>0/1 → +1/-1"]
C --> D["FM Mod<br/>70 kHz<br/>±4 kHz dev"]
D --> E["Σ"]
V1["Voice Audio<br/>300-3000 Hz"] -->|"FM<br/>30 kHz<br/>±7.5 kHz dev"| E
E --> F["PM Mod<br/>1.0 rad peak"]
F --> G["2106.4 MHz<br/>RF Carrier"]
end
G -->|"240/221<br/>turnaround"| H
subgraph "RX — Spacecraft"
H["RF Input<br/>2106.4 MHz"] --> I["PM Demod<br/>Carrier PLL"]
I --> J["Extract<br/>70 kHz"]
J --> K["FM Demod"]
K --> L["Slicer<br/>2 kbps"]
L --> M["Word<br/>Deserializer"]
M --> N["AGC<br/>Commands"]
end
style A fill:#2d5016,stroke:#4a8c2a
style V1 fill:#2d5016,stroke:#4a8c2a
style G fill:#1a3a5c,stroke:#3a7abd
style H fill:#1a3a5c,stroke:#3a7abd
style F fill:#3a1a5c,stroke:#7a3abd
style I fill:#3a1a5c,stroke:#7a3abd
style N fill:#5c3a1a,stroke:#bd7a3a
```
<Tabs>
<TabItem label="TX Blocks">
The uplink transmit chain assembles commands into the modulated carrier:
1. **uplink_encoder** -- Accepts DSKY command PDUs and formats them as 15-bit AGC uplink words. The word format matches the Apollo AGC's INLINK channel (channel 45) protocol.
2. **uplink_word_serializer** -- Serializes 15-bit words into a continuous NRZ bit stream at 2 kbps. Each bit is held for the full bit period.
3. **nrz_encoder** -- Converts the 0/1 byte stream to +1.0/-1.0 floats, identical to the downlink NRZ encoder.
4. **FM modulator** -- Frequency-modulates the NRZ data onto a 70 kHz subcarrier with +/-4 kHz deviation.
5. **PM modulator** -- Phase-modulates the composite subcarrier signal onto the carrier at 1.0 rad peak deviation.
The `usb_uplink_source` hierarchical block wires this chain together.
</TabItem>
<TabItem label="RX Blocks">
The uplink receiver disassembles the signal in reverse order:
1. **PM demodulator** -- Same carrier PLL approach as the downlink, but configured for the wider 1.0 rad deviation. The larger modulation index means the small-angle approximation no longer holds (15.9% error at 1.0 rad), but FM demodulation of the subcarrier is tolerant of this distortion.
2. **70 kHz extraction** -- Bandpass filter centered on the command subcarrier.
3. **FM demodulator** -- Recovers the NRZ data stream from the 70 kHz subcarrier.
4. **Slicer and deserializer** -- Threshold detection at 2 kbps, then reassembly of 15-bit AGC words from the serial bit stream.
The `usb_uplink_receiver` hierarchical block wires this chain together.
</TabItem>
</Tabs>
<Aside type="note">
The 1.0 rad uplink deviation pushes well beyond the linear small-angle regime. At 1.0 rad, `sin(theta)` deviates from `theta` by nearly 16%. The designers accepted this because the uplink data rate is only 2 kbps (versus 51.2 kbps on the downlink) and FM demodulation is inherently robust against amplitude and phase nonlinearity. The uplink could afford to trade linearity for link margin.
</Aside>
## PRN ranging
Voice and telemetry tell the ground station what the spacecraft is doing. Ranging tells them where it is. The Apollo USB system measures Earth-to-spacecraft distance by timing how long a known signal takes to make the round trip.
The ranging signal is a composite pseudo-random noise (PRN) code, transmitted by the ground station and transponded (retransmitted) by the spacecraft. The ground station correlates the received code against its own reference copy, and the time offset between them is the two-way light-time delay.
```mermaid
graph LR
A["PRN Generator<br/>~994 kchip/s"] --> B["NRZ Encode<br/>0/1 → +1/-1"]
B --> C["PM Mod onto<br/>Uplink Carrier"]
C --> D["Spacecraft<br/>Transponder"]
D --> E["PM Mod onto<br/>Downlink Carrier"]
E --> F["Ground RX<br/>PRN Correlator"]
F --> G["Range<br/>Measurement"]
A --> H["Reference Copy<br/>(delayed)"]
H --> F
style A fill:#5c3a1a,stroke:#bd7a3a
style D fill:#1a3a5c,stroke:#3a7abd
style F fill:#3a1a5c,stroke:#7a3abd
style G fill:#2d5016,stroke:#4a8c2a
```
### The composite code
The PRN code is not a single sequence -- it is five component codes combined with Boolean logic:
| Code | Length (chips) | Type |
|------|----------------|------|
| CL | 2 | Clock (alternating 0,1) |
| X | 11 | Fixed pattern |
| A | 31 | Maximal-length LFSR |
| B | 63 | Maximal-length LFSR |
| C | 127 | Maximal-length LFSR |
The combination logic produces one output chip per clock:
```
output = (NOT(X) AND majority(A, B, C)) XOR CL
```
where `majority(A, B, C)` outputs 1 when two or more inputs are 1. The combined code length is the product of the component lengths: 2 x 11 x 31 x 63 x 127 = **5,456,682 chips**. At the chip rate of approximately 994 kchip/s, one full code period takes about 5.49 seconds.
The maximal-length LFSR sequences (A, B, C) give the composite code excellent autocorrelation properties -- a sharp peak when aligned, low sidelobes everywhere else. This is what makes unambiguous range measurement possible even at the signal-to-noise ratios encountered at lunar distance.
### Sequential acquisition
Correlating against the full 5.4-million-chip code in one pass would be computationally ruinous, even by modern standards. The Apollo system uses a sequential strategy that exploits the composite structure:
1. **Correlate CL first** (length 2) -- resolves range to within half the CL period
2. **Correlate X** (length 11) -- refines within the CL ambiguity window
3. **Correlate A** (length 31) -- further refinement
4. **Correlate B** (length 63) -- further refinement
5. **Correlate C** (length 127) -- final resolution
Each stage resolves the ambiguity left by the previous one. The total number of trial positions searched is 2 + 11 + 31 + 63 + 127 = 234, rather than 5,456,682. This makes acquisition practical in seconds rather than hours.
### Range resolution
The two-way range resolution is determined by the chip rate:
```
resolution = c / (2 x chip_rate) = 299,792,458 / (2 x 994,000) ~ 150.8 meters
```
This is the minimum distinguishable range difference. The actual measurement precision is much better than this, because the correlator interpolates between chips. NASA routinely achieved ranging precision on the order of 15 meters to the Moon -- about one-tenth of a chip.
<Aside type="tip">
Ken Shirriff's [detailed analysis of the Apollo ranging system](https://www.righto.com/2022/09/the-ranging-system-that-measured.html) traces the code generation logic down to the gate level. His work on reverse-engineering the actual hardware provides an invaluable cross-reference for validating the gr-apollo implementation.
</Aside>
### gr-apollo ranging blocks
<Tabs>
<TabItem label="Transmit">
- **ranging_source** -- Generates the composite PRN chip stream by clocking the five component code generators and combining their outputs. Configurable chip rate (default ~994 kchip/s). Output is one byte per chip (0 or 1).
- **ranging_mod** -- NRZ-encodes the chip stream (+1/-1) and scales it for injection into the PM modulator's composite baseband signal.
</TabItem>
<TabItem label="Receive">
- **ranging_demod** -- Performs FFT-based cross-correlation between the received ranging signal and a locally generated reference. Sequential acquisition is implemented as a state machine that steps through the CL, X, A, B, C stages. Outputs range measurement PDUs containing the measured delay in chips, the equivalent range in meters, and a confidence metric.
</TabItem>
</Tabs>

View File

@ -1,11 +1,11 @@
---
title: "Run the Demos"
description: "How to run gr-apollo's example scripts for loopback testing, voice modulation, full downlink simulation, and AGC integration."
description: "How to run gr-apollo's example scripts for loopback testing, voice modulation, full downlink simulation, AGC integration, uplink commands, and PRN ranging."
---
import { Steps, Aside, Tabs, TabItem, Code, LinkCard, CardGrid } from '@astrojs/starlight/components';
gr-apollo ships with four demo scripts that exercise different parts of the TX/RX chain. They progress from a self-contained streaming loopback to a full mission simulation with crew voice, and finally to live integration with the Virtual AGC emulator.
gr-apollo ships with eight demo scripts that exercise different parts of the TX/RX chain. They progress from a self-contained streaming loopback to a full mission simulation with crew voice, live integration with the Virtual AGC emulator, uplink command encoding, and PRN ranging.
| Demo | Requires | What It Does |
|------|----------|-------------|
@ -13,6 +13,10 @@ gr-apollo ships with four demo scripts that exercise different parts of the TX/R
| `voice_subcarrier_demo.py` | GNU Radio, scipy | Real audio through 1.25 MHz FM |
| `full_downlink_demo.py` | GNU Radio, scipy | PCM telemetry + crew voice on one carrier |
| `agc_loopback_demo.py` | yaAGC (no GR) | Live AGC telemetry over TCP |
| `fetch_apollo_audio.py` | ffmpeg | Download real Apollo recordings from Archive.org |
| `real_signal_demo.py` | GNU Radio, scipy | Process real Apollo audio through full USB chain |
| `uplink_loopback_demo.py` | GNU Radio | Encode DSKY commands, modulate, demodulate, verify |
| `ranging_demo.py` | None (pure Python) | PRN code generation, delay simulation, correlation |
## Prerequisites
@ -367,6 +371,401 @@ The `AGCBridgeClient` auto-reconnects with exponential backoff. You can start th
---
## Audio Downloads
**Script:** `examples/fetch_apollo_audio.py`
**Requires:** ffmpeg
This utility downloads Apollo 11 audio highlights from the Internet Archive as a FLAC file, then extracts individual clips using ffmpeg. The clips are saved as 48 kHz mono WAV files in `examples/audio/` for use with the other signal processing demos.
```mermaid
graph LR
A["Archive.org\nApollo11Highlights.flac"]:::data --> B["urllib\n(download)"]:::timing --> C["FLAC file\n(local)"]:::data
C --> D["ffmpeg\n(seek + extract)"]:::rf --> E["apollo11_liftoff.wav"]:::data
C --> F["ffmpeg\n(seek + extract)"]:::rf --> G["apollo11_eagle_has_landed.wav"]:::data
C --> H["ffmpeg\n(seek + extract)"]:::rf --> I["apollo11_one_small_step.wav"]:::data
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
```
Five clips are defined in the script, covering key mission moments from liftoff through splashdown. The FLAC source is removed after extraction by default to save disk space.
### Usage
```bash
uv run python examples/fetch_apollo_audio.py --list
uv run python examples/fetch_apollo_audio.py --all
uv run python examples/fetch_apollo_audio.py --clip eagle_has_landed
uv run python examples/fetch_apollo_audio.py --clip liftoff --force
uv run python examples/fetch_apollo_audio.py --all --keep-flac
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `--list` | off | List available clip names and timestamps |
| `--clip` | -- | Extract a specific clip by name |
| `--all` | off | Extract all five defined clips |
| `--keep-flac` | off | Keep the downloaded FLAC file after extraction |
| `--force` | off | Re-download and re-extract even if files already exist |
| `--output-dir` | `examples/audio/` | Output directory for WAV files |
### Expected Output (--list)
```
Available clips:
liftoff 00:00:05 (00:00:30) Apollo 11 liftoff
eagle_has_landed 00:06:45 (00:00:30) The Eagle has landed
one_small_step 00:15:30 (00:00:25) One small step for man
houston_problem 00:20:00 (00:00:15) Houston, we've had a problem
splashdown 00:42:00 (00:00:20) Splashdown
5 clips defined.
Extract with: --clip NAME or --all
```
### Expected Output (--all)
```
============================================================
Apollo 11 Audio Fetch
============================================================
Step 1: Download source FLAC
Downloading: https://archive.org/download/Apollo11AudioHighlights/Apollo11Highlights.flac
Saving to: examples/audio/Apollo11Highlights.flac
[########################################] 100.0% 45.2/45.2 MB
Downloaded 45.2 MB
Step 2: Extract 5 clip(s)
[liftoff] Extracting: Apollo 11 liftoff
start=00:00:05 duration=00:00:30
-> examples/audio/apollo11_liftoff.wav (2880 KB)
[eagle_has_landed] Extracting: The Eagle has landed
start=00:06:45 duration=00:00:30
-> examples/audio/apollo11_eagle_has_landed.wav (2880 KB)
[one_small_step] Extracting: One small step for man
start=00:15:30 duration=00:00:25
-> examples/audio/apollo11_one_small_step.wav (2400 KB)
[houston_problem] Extracting: Houston, we've had a problem
start=00:20:00 duration=00:00:15
-> examples/audio/apollo11_houston_problem.wav (1440 KB)
[splashdown] Extracting: Splashdown
start=00:42:00 duration=00:00:20
-> examples/audio/apollo11_splashdown.wav (1920 KB)
Removed source FLAC (45.2 MB). Use --keep-flac to retain.
============================================================
Extracted: 5 Failed: 0
Output: examples/audio/apollo11_*.wav
============================================================
```
<Aside type="tip">
Run this script first if you want to use `real_signal_demo.py` with actual Apollo recordings. The bundled `apollo11_crew.wav` clip works as a fallback, but the Archive.org recordings are the real thing.
</Aside>
---
## Real Signal Processing
**Script:** `examples/real_signal_demo.py`
**Requires:** GNU Radio, scipy
This demo auto-discovers WAV files in `examples/audio/` (downloaded by `fetch_apollo_audio.py`) and runs them through the full USB downlink chain: transmit (NRZ + BPSK + voice FM onto PM carrier) then receive (PCM frame recovery + voice demodulation). It proves the gr-apollo signal chain works on real-world audio, not just synthetic test tones.
If no downloaded clips are found, the demo falls back to the bundled `examples/audio/apollo11_crew.wav`.
```mermaid
graph TB
subgraph discover ["Audio Discovery"]
direction LR
A["examples/audio/\napollo11_*.wav"]:::data --> B["auto-discover\n(skip output files)"]:::timing
end
subgraph TX ["TX (spacecraft)"]
direction LR
C["pcm_frame_source\n→ nrz → bpsk_mod"]:::data --> F["add_ff"]:::rf
D["real audio clip\n→ resample → upsample"]:::data --> E["fm_voice_mod\n× 0.764"]:::rf --> F
F --> G["pm_mod"]:::rf
end
subgraph RX ["RX (ground station)"]
direction LR
H["pm_demod"]:::rf --> I["bpsk_demod\n→ frame_sync"]:::rf
H --> J["voice_demod"]:::rf
I --> K["PCM frames"]:::data
J --> L["recovered WAV"]:::data
end
B --> D
G --> H
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
```
### Usage
```bash
uv run python examples/real_signal_demo.py
uv run python examples/real_signal_demo.py --clip eagle_has_landed
uv run python examples/real_signal_demo.py --snr 25
uv run python examples/real_signal_demo.py --clip liftoff --play
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `--clip` | first discovered | Process a specific clip by name |
| `--snr` | None | Add AWGN noise at this SNR in dB |
| `--play` | off | Play recovered audio with `aplay` after processing |
### Expected Output
```
============================================================
Apollo Real Signal Demo
Full USB downlink: PCM telemetry + crew voice
============================================================
Found 5 clip(s): liftoff, eagle_has_landed, one_small_step, houston_problem, splashdown
Processing: eagle_has_landed
------------------------------------------------------------
Loading: examples/audio/apollo11_eagle_has_landed.wav
Duration: 30.00s, 153,600,000 baseband samples
TX: 153,600,000 samples, ~1502 PCM frames, SNR=clean
TX complete: 153,600,000 complex samples (28.5s)
RX PCM: 1498 frames recovered (41.2s)
RX voice: 240,000 samples, 30.00s (25.1s)
Saved: examples/audio/apollo11_eagle_has_landed_recovered.wav
============================================================
Summary
============================================================
Clip: eagle_has_landed
Input duration: 30.00s
Recovered audio: 30.00s
PCM frames: 1498 recovered (expected ~1502)
SNR: clean
Processing time: TX=28.5s PCM-RX=41.2s Voice-RX=25.1s
Output: examples/audio/apollo11_eagle_has_landed_recovered.wav
============================================================
Play recovered: aplay examples/audio/apollo11_eagle_has_landed_recovered.wav
```
<Aside type="note">
The signal path is identical to `full_downlink_demo.py` -- the difference is that this script auto-discovers audio clips and works with whatever `fetch_apollo_audio.py` has downloaded. If the `examples/audio/` directory only contains the bundled `apollo11_crew.wav`, the demo uses that instead.
</Aside>
---
## Uplink Loopback
**Script:** `examples/uplink_loopback_demo.py`
**Requires:** GNU Radio
This demo exercises the full uplink signal chain. It encodes a DSKY command (V16N36E by default), serializes the words to a bit stream, modulates through the RF path (NRZ, FM onto a 70 kHz data subcarrier, then PM), demodulates on the other end, and deserializes the recovered bits back to uplink words. The TX and RX word sequences are compared for a word-for-word match.
```mermaid
graph LR
subgraph TX ["TX (ground station)"]
direction LR
A["UplinkEncoder\n(V16N36E)"]:::data --> B["UplinkSerializer\n(word→bits)"]:::data --> C["nrz_encoder"]:::timing
C --> D["FM mod\n(±4 kHz)"]:::rf --> E["upconvert\n70 kHz"]:::rf --> F["pm_mod\n(1.0 rad)"]:::rf
end
subgraph RX ["RX (spacecraft)"]
direction LR
G["pm_demod"]:::rf --> H["subcarrier_extract\n70 kHz"]:::rf --> I["FM demod"]:::rf
I --> J["matched filter\n+ slicer"]:::timing --> K["UplinkDeserializer\n(bits→words)"]:::data
end
F --> G
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
```
The pure-Python engines handle word-to-bit conversion at the endpoints, while the GNU Radio streaming chain proves the RF modulation path works end-to-end.
### Usage
```bash
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
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `--verb` | 16 | Verb number for the DSKY command |
| `--noun` | 36 | Noun number for the DSKY command |
| `--snr` | None | SNR in dB (None = clean, no noise) |
### Expected Output
```
============================================================
Apollo Uplink Loopback Demo
============================================================
Command: V16N36E
Uplink words: 7
SNR: clean (no noise)
TX word sequence:
[0] ch=032 val=10000 (4096) bits=001000000000000
[1] ch=032 val=10001 (4097) bits=001000000000001
[2] ch=032 val=10110 (4168) bits=001000001110000
[3] ch=032 val=00011 ( 3) bits=000000000000011
[4] ch=032 val=00110 ( 6) bits=000000000000110
[5] ch=032 val=11000 (6144) bits=001100000000000
[6] ch=032 val=10010 (4106) bits=001000000001010
Total bits: 8036 (504 data + 7532 idle)
Samples per bit: 2560
Total samples: 20,572,160
Duration: 4.018 s
Building flowgraph...
Running flowgraph (TX -> RX)...
Recovered 8036 bits from slicer
Recovered 7 words (expected 7)
RX word sequence:
[0] ch=032 val=10000 (4096) bits=001000000000000
[1] ch=032 val=10001 (4097) bits=001000000000001
[2] ch=032 val=10110 (4168) bits=001000001110000
[3] ch=032 val=00011 ( 3) bits=000000000000011
[4] ch=032 val=00110 ( 6) bits=000000000000110
[5] ch=032 val=11000 (6144) bits=001100000000000
[6] ch=032 val=10010 (4106) bits=001000000001010
------------------------------------------------------------
Words transmitted: 7
Words recovered: 7
Matches: 7/7
Word error rate: 0.0%
------------------------------------------------------------
V16N36E round-trip: all 7 words match.
```
<Aside type="tip">
The bit rate is 2 kbps and the data subcarrier is 70 kHz -- both per the Apollo USB uplink specification. The 0.5-second idle periods before and after the data give the PLL time to settle. If you lower the SNR below about 8 dB, you may start seeing word errors in the recovered sequence.
</Aside>
---
## PRN Ranging
**Script:** `examples/ranging_demo.py`
**Requires:** None (pure Python, no GNU Radio needed)
This demo exercises the Apollo ranging subsystem. It generates the composite PRN ranging code from its five component codes (CL, X, A, B, C), verifies their algebraic properties, NRZ-encodes the chip stream, applies a known propagation delay to simulate spacecraft distance, optionally adds noise, and cross-correlates to recover the delay. The measured range is compared against the true range.
```mermaid
graph LR
A["RangingCodeGenerator\n(CL×X×A×B×C)"]:::data --> B["NRZ encode\n(bipolar ±1)"]:::timing --> C["np.roll\n(apply delay)"]:::rf
C --> D["+ AWGN\n(optional)"]:::rf --> E["RangingCorrelator\n(cross-correlate)"]:::data
E --> F["range estimate\n(km)"]:::data
classDef data fill:#2d5016,stroke:#4a8c2a,color:#fff
classDef rf fill:#1a3a5c,stroke:#3a7abd,color:#fff
classDef timing fill:#5c3a1a,stroke:#bd7a3a,color:#fff
```
The demo works at chip rate (1 sample per chip) for simplicity and speed. The composite PRN code length is 2,037,675 chips, giving an unambiguous range that covers Earth-to-Moon distances.
### Usage
```bash
uv run python examples/ranging_demo.py
uv run python examples/ranging_demo.py --range-km 100
uv run python examples/ranging_demo.py --range-km 384400 # Moon distance
uv run python examples/ranging_demo.py --snr 20
uv run python examples/ranging_demo.py --chips 200000
```
### Arguments
| Argument | Default | Description |
|----------|---------|-------------|
| `--range-km` | 100.0 | Target range in km |
| `--snr` | None | SNR in dB (None = clean, no noise) |
| `--chips` | 50,000 | Number of chips for correlation |
### Expected Output
```
============================================================
Apollo PRN Ranging Demo
============================================================
1. Component code verification
----------------------------------------
CL: length= 2 (1s=1, 0s=1), periodic=True, balance=OK [OK]
X: length= 11 (1s=6, 0s=5), periodic=True, balance=OK [OK]
A: length= 5 (1s=3, 0s=2), periodic=True, balance=OK [OK]
B: length= 7 (1s=4, 0s=3), periodic=True, balance=OK [OK]
C: length= 5 (1s=3, 0s=2), periodic=True, balance=OK [OK]
Code length: 2,037,675 = 2*11*5*7*5*... [OK]
Composite sample (50,000 chips): balance=0.500 (ideal ~0.5)
2. Generating 50,000 PRN chips...
Generated in 2.3 ms
Ones: 25,012 / 50,000 (50.0%)
3. Simulating range: 100.0 km
Round-trip distance: 200.0 km
Round-trip time: 0.6671 ms
Delay: 6.67 chips (7 samples)
Added AWGN noise at 20 dB SNR (noise power = 0.0100)
4. Cross-correlating...
Correlation time: 4.1 ms
Peak-to-average ratio: 224.3
5. Range measurement results
----------------------------------------
True range: 100.0 km
Measured range: 104.9 km
Error: 4950.0 m
Quantization step: 14984.4 m (1 chip, two-way)
Delay (chips): 7.00
Delay (samples): 7
Correlation peak: 50000
Error is within one quantization step -- measurement is correct.
============================================================
```
<Aside type="note">
At chip rate (1 sample per chip), the range quantization step is about 15 km. This is the coarse acquisition mode. The real Apollo system achieved finer resolution by oversampling the chip stream and interpolating the correlation peak. The `--chips` argument controls how many chips are correlated -- more chips improve the peak-to-average ratio and noise resistance, but the quantization step stays the same unless you oversample.
</Aside>
<Aside type="tip">
Try `--range-km 384400` to simulate ranging to the Moon. The composite code is long enough to handle the round-trip without ambiguity. With `--snr 10`, you can see how the correlation peak rises above the noise floor even in degraded conditions.
</Aside>
---
## Which Demo to Start With
If you are new to gr-apollo:
@ -379,12 +778,25 @@ If you are new to gr-apollo:
4. **Connect to yaAGC** when you are ready to interact with a running Apollo Guidance Computer.
5. **Run the ranging demo** to see PRN code generation and correlation at work. It is pure Python with no GNU Radio dependency, so it runs anywhere.
6. **Try the uplink loopback** to see DSKY commands travel through the RF chain and come back intact on the other side.
<Aside type="tip">
If you want to work with real Apollo recordings rather than the bundled test clip, run `fetch_apollo_audio.py --all` first. The downloaded clips are automatically picked up by `real_signal_demo.py` and can also be passed directly to the voice and full-downlink demos.
</Aside>
<CardGrid>
<LinkCard
title="Build a Transmit Signal"
description="Detailed walkthrough of the TX block chain"
href="/guides/transmit-signal/"
/>
<LinkCard
title="Signal Architecture"
description="Uplink, downlink, and ranging signal paths"
href="/reference/signal-architecture/"
/>
<LinkCard
title="Block Reference"
description="Full API docs for all blocks used in the demos"

View File

@ -8,7 +8,7 @@ import { Tabs, TabItem, Aside } from '@astrojs/starlight/components';
Every component in gr-apollo falls into one of two categories: **GNU Radio blocks** that require the `gnuradio` runtime, and **pure-Python engines** that work standalone. The engines power the GR blocks internally, but you can also use them directly for testing, scripting, or integration without GNU Radio.
<Aside type="note">
Blocks that require GNU Radio are imported lazily. If `gnuradio` is not installed, the pure-Python engines (`FrameSyncEngine`, `DemuxEngine`, `DownlinkEngine`, `AGCBridgeClient`, `UplinkEncoder`, `generate_usb_baseband`) remain available.
Blocks that require GNU Radio are imported lazily. If `gnuradio` is not installed, the pure-Python engines (`FrameSyncEngine`, `DemuxEngine`, `DownlinkEngine`, `AGCBridgeClient`, `UplinkEncoder`, `UplinkSerializerEngine`, `UplinkDeserializerEngine`, `RangingCodeGenerator`, `RangingCorrelator`, `generate_usb_baseband`) remain available.
</Aside>
---
@ -1467,3 +1467,610 @@ complex in -> fm_demod -> sco_demod(ch1) -> output[0]
-> sco_demod(ch2) -> output[1]
-> sco_demod(chN) -> output[N-1]
```
---
## Uplink Chain
The uplink carries ground-station commands to the spacecraft as 15-bit AGC words at 2 kbps NRZ on a 70 kHz FM subcarrier, phase-modulated onto the 2106.40625 MHz uplink carrier at 1.0 rad peak deviation. Each word triggers an UPRUPT interrupt in the AGC flight software.
### `UplinkSerializerEngine` / `UplinkDeserializerEngine`
<Tabs>
<TabItem label="Serializer Engine">
**Module:** `apollo.uplink_word_codec`
**Type:** Pure-Python class
**Purpose:** Serialize (channel, value) pairs into a continuous NRZ bit stream with configurable inter-word gap.
```python
from apollo.uplink_word_codec import UplinkSerializerEngine
engine = UplinkSerializerEngine(inter_word_gap=3)
engine.add_words([(37, 0x4400), (37, 0x0400)])
bits = engine.next_bits(36)
```
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `inter_word_gap` | `int` | `3` | Number of zero-bit periods between consecutive words (`UPLINK_INTER_WORD_GAP`) |
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `add_words` | `(pairs: list[tuple[int, int]]) -> None` | 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 -- it is used only for metadata/logging |
| `next_bits` | `(n: int) -> list[int]` | Pull exactly `n` bits from the queue. Returns queued data bits when available, zeros otherwise (idle carrier) |
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `pending` | `int` | Number of bits remaining in the queue |
</TabItem>
<TabItem label="Deserializer Engine">
**Module:** `apollo.uplink_word_codec`
**Type:** Pure-Python class
**Purpose:** Reassemble 15-bit AGC words from a recovered NRZ bit stream using a two-phase state machine (acquisition and locked framing).
```python
from apollo.uplink_word_codec import UplinkDeserializerEngine
engine = UplinkDeserializerEngine(inter_word_gap=3)
pairs = engine.process_bits([1, 0, 1, 1, ...])
# Returns: [(37, 0x4400), ...]
```
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `inter_word_gap` | `int` | `3` | Expected number of zero bits between words (`UPLINK_INTER_WORD_GAP`) |
| `channel` | `int` | `37` | AGC channel to assign to recovered words (`AGC_CH_INLINK`, 045 octal) |
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `process_bits` | `(bits: list[int]) -> list[tuple[int, int]]` | Process a batch of recovered bits. Returns list of (channel, value) tuples for each completed word |
| `reset` | `() -> None` | Clear internal state for a fresh decode pass |
#### State Machine
The deserializer uses a two-phase approach:
1. **Acquisition** -- Scans for the first non-zero bit (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** -- Uses fixed framing: collects exactly 15 bits per word, skips exactly `inter_word_gap` bits, then collects the next 15, etc. The lock releases after seeing a null word (all zeros), returning to acquisition.
<Aside type="note">
Fixed framing after the first word boundary is necessary because data words can start with leading zeros that would be indistinguishable from the inter-word gap in a bit-synchronous scheme.
</Aside>
</TabItem>
</Tabs>
---
### `uplink_word_serializer`
**Module:** `apollo.uplink_word_codec`
**Type:** `gr.sync_block`
**Purpose:** GNU Radio source block wrapping `UplinkSerializerEngine`. Accepts word PDUs on message input, outputs continuous NRZ byte stream.
```python
from apollo.uplink_word_codec import uplink_word_serializer
blk = uplink_word_serializer(inter_word_gap=3)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| (none) | Input | (none) | Source block -- no streaming input |
| `out0` | Output | `byte` | NRZ bit stream (values 0 or 1). Zeros when idle |
| `"words"` | Input | Message (PDU) | Word injection: PDU with metadata dict containing `"channel"` and `"value"` keys, or a pair `(channel . value)` |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `inter_word_gap` | `int` | `3` | Number of zero-bit periods between words (`UPLINK_INTER_WORD_GAP`) |
#### Input PDU Format
The `"words"` message port accepts the same format emitted by `uplink_encoder`:
| Key | PMT Type | Description |
|-----|----------|-------------|
| `"channel"` | `pmt.from_long` | AGC channel number (37 for INLINK) |
| `"value"` | `pmt.from_long` | 15-bit word value |
---
### `uplink_word_deserializer`
**Module:** `apollo.uplink_word_codec`
**Type:** `gr.basic_block`
**Purpose:** GNU Radio block wrapping `UplinkDeserializerEngine`. Consumes byte stream, emits PDU messages for each recovered 15-bit word.
```python
from apollo.uplink_word_codec import uplink_word_deserializer
blk = uplink_word_deserializer(inter_word_gap=3, channel=37)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `byte` (streaming) | NRZ bit stream (values 0 or 1) from binary slicer |
| `"commands"` | Output | Message (PDU) | Recovered word PDUs |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `inter_word_gap` | `int` | `3` | Expected number of zero bits between words (`UPLINK_INTER_WORD_GAP`) |
| `channel` | `int` | `37` | AGC channel to assign to recovered words (`AGC_CH_INLINK`) |
#### Output PDU Format
Each output PDU is `pmt.cons(metadata, pmt.PMT_NIL)`:
**Metadata dict:**
| Key | PMT Type | Description |
|-----|----------|-------------|
| `"channel"` | `pmt.from_long` | AGC channel number (37) |
| `"value"` | `pmt.from_long` | Recovered 15-bit word value |
---
### `usb_uplink_source`
**Module:** `apollo.usb_uplink_source`
**Type:** `gr.hier_block2`
**Purpose:** Complete Apollo USB uplink transmit chain -- serializes 15-bit AGC words into NRZ bits, FM-modulates onto 70 kHz subcarrier, and applies PM modulation to produce complex baseband.
```python
from apollo.usb_uplink_source import usb_uplink_source
blk = usb_uplink_source(sample_rate=5_120_000, snr_db=20.0)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| (none) | Input | (none) | Source block -- no streaming input |
| `out0` | Output | `complex` | PM-modulated complex baseband at `sample_rate` |
| `"words"` | Input | Message | Forwarded to `uplink_word_serializer` for command injection. Accepts the same PDU format emitted by `uplink_encoder` |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `sample_rate` | `float` | `5120000` | Baseband sample rate in Hz (`SAMPLE_RATE_BASEBAND`) |
| `bit_rate` | `int` | `2000` | Uplink data bit rate in bps (`UPLINK_DATA_BIT_RATE`) |
| `pm_deviation` | `float` | `1.0` | Peak PM deviation in radians (`UPLINK_PM_DEVIATION_RAD`) |
| `snr_db` | `float \| None` | `None` | Add AWGN at this SNR. `None` means no noise |
#### Internal Signal Chain
```
word_ser -> nrz_encoder(2 kbps) -> fm_mod(sensitivity)
-> (mixer, 0); sig_source_c(70 kHz) -> (mixer, 1)
-> mixer -> complex_to_real -> pm_mod(1.0 rad) -> [+AWGN] -> output
```
#### Sub-Block Access
| Attribute | Type | Block |
|-----------|------|-------|
| `self.word_ser` | `uplink_word_serializer` | Word serializer |
| `self.nrz` | `nrz_encoder` | NRZ line encoder |
| `self.fm_mod` | `frequency_modulator_fc` | FM modulator |
| `self.lo` | `sig_source_c` | 70 kHz LO |
| `self.mixer` | `multiply_cc` | Subcarrier upconverter |
| `self.to_real` | `complex_to_real` | Complex-to-real conversion |
| `self.pm` | `pm_mod` | Phase modulator |
| `self.noise` | `noise_source_c` | AWGN source (when `snr_db` is set) |
---
### `usb_uplink_receiver`
**Module:** `apollo.usb_uplink_receiver`
**Type:** `gr.hier_block2`
**Purpose:** Complete Apollo USB uplink receiver chain -- complex baseband input to recovered command PDUs. The spacecraft-side counterpart to `usb_uplink_source`.
```python
from apollo.usb_uplink_receiver import usb_uplink_receiver
blk = usb_uplink_receiver(sample_rate=5_120_000)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `complex` (streaming) | Baseband IQ samples at `sample_rate` |
| `"commands"` | Output | Message (PDU) | Decoded (channel, value) PDUs for AGC bridge |
<Aside type="note">
This block has one streaming input and zero streaming outputs. All output is via the `"commands"` message port.
</Aside>
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz (`SAMPLE_RATE_BASEBAND`) |
| `bit_rate` | `int` | `2000` | Uplink data bit rate in bps (`UPLINK_DATA_BIT_RATE`) |
| `carrier_pll_bw` | `float` | `0.02` | PM demodulator PLL bandwidth (rad/sample) |
| `subcarrier_bw` | `float` | `20000` | 70 kHz subcarrier bandpass filter width (Hz) |
#### Internal Signal Chain
```
complex in -> pm_demod -> subcarrier_extract(70 kHz, BW=20 kHz)
-> quadrature_demod_cf(FM) -> matched_filter(1/samp_per_bit)
-> keep_one_in_n(samp_per_bit) -> binary_slicer_fb
-> uplink_word_deserializer -> "commands" message output
```
#### Sub-Block Access
| Attribute | Type | Block |
|-----------|------|-------|
| `self.pm` | `pm_demod` | PM demodulator |
| `self.sc_extract` | `subcarrier_extract` | 70 kHz subcarrier extractor |
| `self.fm_demod` | `quadrature_demod_cf` | FM discriminator |
| `self.matched_filter` | `fir_filter_fff` | Integrate-and-dump matched filter |
| `self.decimator` | `keep_one_in_n` | Bit-rate decimator |
| `self.slicer` | `binary_slicer_fb` | Hard-decision binary slicer |
| `self.deser` | `uplink_word_deserializer` | Word reassembler |
---
## PRN Ranging
The Apollo ranging system measures spacecraft distance by transmitting a composite pseudo-random noise (PRN) code and correlating the echo. The code combines five component sequences (CL, X, A, B, C) using majority-vote logic and XOR operations. Combined code length: 5,456,682 chips (~5.49 seconds at 993,963 chips/sec).
### `RangingCodeGenerator`
**Module:** `apollo.ranging`
**Type:** Pure-Python class
**Purpose:** Generate Apollo PRN ranging code sequences -- individual component codes or the full composite sequence.
```python
from apollo.ranging import RangingCodeGenerator
gen = RangingCodeGenerator()
composite = gen.generate_sequence() # Full 5.4M chip code
a_code = gen.generate_a() # 31-chip A component
x_code = gen.generate_component("x") # By name
```
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `generate_cl` | `(n_chips: int \| None) -> np.ndarray` | Generate CL component: alternating 0, 1 clock. Default length: 2 |
| `generate_x` | `(n_chips: int \| None) -> np.ndarray` | Generate X component: 11-chip non-LFSR sequence with custom feedback logic |
| `generate_a` | `(n_chips: int \| None) -> np.ndarray` | Generate A component: 31-chip LFSR, 5 bits, taps [2,0] (x^5+x^2+1) |
| `generate_b` | `(n_chips: int \| None) -> np.ndarray` | Generate B component: 63-chip LFSR, 6 bits, taps [1,0] (x^6+x+1) |
| `generate_c` | `(n_chips: int \| None) -> np.ndarray` | Generate C component: 127-chip LFSR, 7 bits, taps [1,0] (x^7+x+1) |
| `generate_component` | `(name: str, n_chips: int \| None) -> np.ndarray` | Generate a named component (`"cl"`, `"x"`, `"a"`, `"b"`, `"c"`). Raises `ValueError` if name is not recognized |
| `generate_sequence` | `(n_chips: int \| None) -> np.ndarray` | Generate the full composite PRN code. Default: one full period (5,456,682 chips) |
All methods return `uint8` numpy arrays of 0/1 values. When `n_chips` is `None`, generates one full period for that component.
#### Component Code Properties
| Component | Length | Register | Taps | Init | Type |
|-----------|--------|----------|------|------|------|
| CL | 2 | -- | -- | -- | Alternating clock |
| X | 11 | 5-bit | Custom feedback | `0b10110` (22) | Non-LFSR |
| A | 31 | 5-bit | [2, 0] | `0x1F` (all ones) | Maximal-length LFSR |
| B | 63 | 6-bit | [1, 0] | `0x3F` (all ones) | Maximal-length LFSR |
| C | 127 | 7-bit | [1, 0] | `0x7F` (all ones) | Maximal-length LFSR |
#### Composite Code Generation
The composite code reproduces Shirriff's `calc()` algorithm: on even output chips (ck=0), all shift registers advance and the output is computed from feedback bits via majority logic. On odd chips (ck=1), the output is flipped.
Combination logic per step: `out = (NOT(xnew) AND maj(anew, bnew, cnew)) XOR ck`
where `maj(A,B,C) = (A&B) | (A&C) | (B&C)`.
---
### `RangingCorrelator`
**Module:** `apollo.ranging`
**Type:** Pure-Python class
**Purpose:** Cross-correlate received NRZ samples with the known PRN code using FFT-based correlation for range measurement.
```python
from apollo.ranging import RangingCorrelator
import numpy as np
correlator = RangingCorrelator(sample_rate=5_120_000, two_way=True)
result = correlator.correlate(received_samples)
print(f"Range: {result['range_m']:.1f} m")
```
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `chip_rate` | `int` | `993963` | PRN chip rate in chips/sec (`RANGING_CHIP_RATE_HZ`) |
| `sample_rate` | `float` | `993963` | Input sample rate in Hz. When equal to `chip_rate`, each chip is one sample |
| `two_way` | `bool` | `True` | If `True`, range is halved (signal traveled ground to spacecraft and back) |
#### Methods
| Method | Signature | Description |
|--------|-----------|-------------|
| `correlate` | `(received: np.ndarray, code_chips: int \| None) -> dict` | Cross-correlate received float samples with PRN reference. Returns measurement dict |
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `chip_rate` | `int` | PRN chip rate in chips/sec |
| `sample_rate` | `float` | Input sample rate in Hz |
| `two_way` | `bool` | Two-way range mode |
| `samples_per_chip` | `float` | Ratio `sample_rate / chip_rate` |
#### `correlate` Return Value
| Key | Type | Description |
|-----|------|-------------|
| `delay_samples` | `int` | Peak correlation index in samples |
| `delay_chips` | `float` | Delay measured in chip periods |
| `range_m` | `float` | Computed range in meters (halved if `two_way=True`) |
| `correlation_peak` | `float` | Absolute value of the correlation peak |
| `peak_to_avg_ratio` | `float` | Peak divided by mean correlation magnitude. Higher values indicate cleaner detection |
---
### `chips_to_range_m`
**Module:** `apollo.ranging`
**Type:** Pure-Python function
**Purpose:** Convert chip delay to range in meters.
```python
from apollo.ranging import chips_to_range_m
distance = chips_to_range_m(delay_chips=100.0, two_way=True)
```
#### Signature
```python
def chips_to_range_m(delay_chips: float, two_way: bool = True) -> float
```
#### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `delay_chips` | `float` | -- | Delay measured in chip periods |
| `two_way` | `bool` | `True` | If `True`, divide distance by 2 (round-trip signal path) |
#### Return Value
Range in meters (`float`). Uses `SPEED_OF_LIGHT_M_S` (299,792,458 m/s) and `RANGING_CHIP_RATE_HZ` (993,963 chip/s).
---
### `range_m_to_chips`
**Module:** `apollo.ranging`
**Type:** Pure-Python function
**Purpose:** Convert range in meters to chip delay.
```python
from apollo.ranging import range_m_to_chips
chips = range_m_to_chips(range_m=384_400_000, two_way=True)
```
#### Signature
```python
def range_m_to_chips(range_m: float, two_way: bool = True) -> float
```
#### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `range_m` | `float` | -- | Distance in meters |
| `two_way` | `bool` | `True` | If `True`, compute round-trip delay (distance is doubled before conversion) |
#### Return Value
Delay in chip periods (`float`).
---
### `verify_code_properties`
**Module:** `apollo.ranging`
**Type:** Pure-Python function
**Purpose:** Self-test for code correctness -- verify all component codes have correct length, periodicity, and balance properties.
```python
from apollo.ranging import verify_code_properties
results = verify_code_properties()
for name, props in results.items():
print(f"{name}: {props}")
```
#### Signature
```python
def verify_code_properties() -> dict
```
#### Parameters
None.
#### Return Value
Dict with component names as keys (`"cl"`, `"x"`, `"a"`, `"b"`, `"c"`, `"length_product"`, `"composite_sample"`) and property dicts as values.
**Per-component dict:**
| Key | Type | Description |
|-----|------|-------------|
| `length` | `int` | Actual generated length |
| `length_correct` | `bool` | Whether length matches the expected constant |
| `ones_count` | `int` | Number of 1-chips in the sequence |
| `zeros_count` | `int` | Number of 0-chips in the sequence |
| `periodic` | `bool` | Whether 2x generation repeats exactly |
| `balance_correct` | `bool` | Whether ones/zeros ratio matches LFSR theory (for A, B, C, CL) |
**`length_product` dict:**
| Key | Type | Description |
|-----|------|-------------|
| `expected` | `int` | Product of all component lengths (2 x 11 x 31 x 63 x 127 = 5,456,682) |
| `matches_constant` | `bool` | Whether `RANGING_CODE_LENGTH` equals the product |
**`composite_sample` dict:**
| Key | Type | Description |
|-----|------|-------------|
| `length` | `int` | Length of the test sample (10,000 chips) |
| `ones_count` | `int` | Number of 1-chips |
| `zeros_count` | `int` | Number of 0-chips |
| `balance` | `float` | Ratio of ones to total (near 0.5 for a balanced code) |
---
### `ranging_source`
**Module:** `apollo.ranging_source`
**Type:** `gr.sync_block`
**Purpose:** GNU Radio source block producing a continuous PRN ranging chip stream. Pre-generates the full code period and cycles through it.
```python
from apollo.ranging_source import ranging_source
blk = ranging_source()
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| (none) | Input | (none) | Source block -- no streaming input |
| `out0` | Output | `byte` | PRN chip stream (values 0 or 1), repeating every 5,456,682 chips |
#### Constructor Parameters
None. The code period and chip rate are determined by the ranging constants.
<Aside type="note">
The full 5,456,682-chip code is pre-generated at construction time and stored in memory (~5.2 MB). Streaming is zero-allocation after startup.
</Aside>
---
### `ranging_mod`
**Module:** `apollo.ranging_mod`
**Type:** `gr.hier_block2`
**Purpose:** NRZ-encode PRN chips at the baseband sample rate. Converts chip stream (bytes 0/1) to float NRZ waveform (+1/-1) suitable for summing with other subcarriers before PM modulation.
```python
from apollo.ranging_mod import ranging_mod
blk = ranging_mod(chip_rate=993_963, sample_rate=5_120_000)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `byte` | Chip stream from `ranging_source` (values 0 or 1) |
| `out0` | Output | `float` | NRZ waveform (+1.0 / -1.0) at `sample_rate` |
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `chip_rate` | `int` | `993963` | PRN chip rate in chips/sec (`RANGING_CHIP_RATE_HZ`) |
| `sample_rate` | `float` | `5120000` | Output sample rate in Hz (`SAMPLE_RATE_BASEBAND`) |
#### Properties
| Property | Type | Description |
|----------|------|-------------|
| `samples_per_chip` | `int` | Samples per chip period: `int(sample_rate / chip_rate)` |
#### Internal Chain
`input -> char_to_float -> multiply_const_ff(2.0) -> add_const_ff(-1.0) -> repeat(samples_per_chip) -> output`
<Aside type="note">
At 5.12 MHz sample rate with 993,963 chip/s, `samples_per_chip` is 5 (integer truncation of 5.1509...). This is functionally identical to `nrz_encoder` but configured for the ranging chip rate instead of the PCM bit rate.
</Aside>
---
### `ranging_demod`
**Module:** `apollo.ranging_demod`
**Type:** `gr.basic_block`
**Purpose:** FFT-based ranging demodulator. Accumulates samples in batches, cross-correlates with the known PRN code, and emits range measurement PDUs.
```python
from apollo.ranging_demod import ranging_demod
blk = ranging_demod(sample_rate=5_120_000, correlation_length=100_000)
```
#### I/O Signature
| Port | Direction | Type | Description |
|------|-----------|------|-------------|
| `in0` | Input | `float` (streaming) | PM demod output or filtered ranging signal |
| `"range"` | Output | Message (PDU) | Range measurement PDUs, one per correlation batch |
<Aside type="note">
This block has one streaming input and zero streaming outputs. All output is via the `"range"` message port.
</Aside>
#### Constructor Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `chip_rate` | `int` | `993963` | PRN chip rate in chips/sec (`RANGING_CHIP_RATE_HZ`) |
| `sample_rate` | `float` | `5120000` | Input sample rate in Hz (`SAMPLE_RATE_BASEBAND`) |
| `correlation_length` | `int` | `100000` | Number of samples to accumulate per correlation batch. Longer batches improve SNR at the cost of measurement rate |
| `two_way` | `bool` | `True` | If `True`, range is halved (round-trip signal path) |
#### Output PDU Format
Each PDU is `pmt.cons(metadata, pmt.PMT_NIL)`:
**Metadata dict:**
| Key | PMT Type | Description |
|-----|----------|-------------|
| `"delay_chips"` | `pmt.from_double` | Measured delay in chip periods |
| `"range_m"` | `pmt.from_double` | Computed range in meters |
| `"correlation_peak"` | `pmt.from_double` | Absolute value of the correlation peak |
| `"peak_to_avg_ratio"` | `pmt.from_double` | Peak-to-average ratio (detection quality metric) |
| `"timestamp"` | `pmt.from_double` | `time.time()` when the measurement was emitted |

View File

@ -18,7 +18,12 @@ from apollo.downlink_decoder import DownlinkEngine as DownlinkEngine
from apollo.pcm_demux import DemuxEngine as DemuxEngine
from apollo.pcm_frame_source import FrameSourceEngine as FrameSourceEngine
from apollo.pcm_frame_sync import FrameSyncEngine as FrameSyncEngine
from apollo.ranging import RangingCodeGenerator as RangingCodeGenerator
from apollo.ranging import chips_to_range_m as chips_to_range_m
from apollo.ranging import range_m_to_chips as range_m_to_chips
from apollo.uplink_encoder import UplinkEncoder as UplinkEncoder
from apollo.uplink_word_codec import UplinkDeserializerEngine as UplinkDeserializerEngine
from apollo.uplink_word_codec import UplinkSerializerEngine as UplinkSerializerEngine
from apollo.usb_signal_gen import generate_usb_baseband as generate_usb_baseband
# GNU Radio receive-side blocks (require gnuradio runtime)
@ -30,6 +35,7 @@ try:
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
from apollo.uplink_word_codec import uplink_word_deserializer as uplink_word_deserializer
from apollo.voice_subcarrier_demod import voice_subcarrier_demod as voice_subcarrier_demod
except ImportError:
pass # GNU Radio not available — receive-side GR blocks won't be importable
@ -42,7 +48,10 @@ try:
from apollo.nrz_encoder import nrz_encoder as nrz_encoder
from apollo.pcm_frame_source import pcm_frame_source as pcm_frame_source
from apollo.pm_mod import pm_mod as pm_mod
from apollo.ranging_mod import ranging_mod as ranging_mod
from apollo.ranging_source import ranging_source as ranging_source
from apollo.sco_mod import sco_mod as sco_mod
from apollo.uplink_word_codec import uplink_word_serializer as uplink_word_serializer
except ImportError:
pass # GNU Radio not available — transmit-side GR blocks won't be importable
@ -54,8 +63,11 @@ try:
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.ranging_demod import ranging_demod as ranging_demod
from apollo.uplink_encoder import uplink_encoder as uplink_encoder
from apollo.usb_downlink_receiver import usb_downlink_receiver as usb_downlink_receiver
from apollo.usb_signal_source import usb_signal_source as usb_signal_source
from apollo.usb_uplink_receiver import usb_uplink_receiver as usb_uplink_receiver
from apollo.usb_uplink_source import usb_uplink_source as usb_uplink_source
except (ImportError, NameError):
pass

View File

@ -174,3 +174,36 @@ TX_IMPEDANCE_OHM = 50
TWT_LOW_POWER_W = 5
TWT_HIGH_POWER_W = 20
TWT_WARMUP_S = 90
# ---------------------------------------------------------------------------
# Uplink Modulation Parameters (IMPL_SPEC section 2.2)
# ---------------------------------------------------------------------------
UPLINK_PM_DEVIATION_RAD = 1.0 # 1.0 rad peak phase deviation
UPLINK_DATA_BIT_RATE = 2_000 # 2 kbps NRZ
UPLINK_DATA_FM_DEVIATION_HZ = 4_000 # ±4 kHz on 70 kHz subcarrier
UPLINK_VOICE_FM_DEVIATION_HZ = 7_500 # ±7.5 kHz on 30 kHz subcarrier
UPLINK_WORD_BITS = 15 # AGC word width
UPLINK_INTER_WORD_GAP = 3 # bit periods of zeros between words
# ---------------------------------------------------------------------------
# PRN Ranging (Ken Shirriff / NASA docs)
# ---------------------------------------------------------------------------
RANGING_CHIP_RATE_HZ = 993_963 # ~994 kchip/s
RANGING_CODE_LENGTH = 5_456_682 # 2 × 11 × 31 × 63 × 127
RANGING_CL_LENGTH = 2
RANGING_X_LENGTH = 11
RANGING_A_LENGTH = 31
RANGING_B_LENGTH = 63
RANGING_C_LENGTH = 127
RANGING_X_INIT = 22 # 0b10110
RANGING_A_INIT = 0x1F # all ones (5-bit)
RANGING_B_INIT = 0x3F # all ones (6-bit)
RANGING_C_INIT = 0x7F # all ones (7-bit)
# LFSR taps: zero-indexed bit positions for XOR feedback (Shirriff's Teensy code)
RANGING_A_TAPS = (2, 0) # 5-bit: x^5 + x^2 + 1
RANGING_B_TAPS = (1, 0) # 6-bit: x^6 + x + 1
RANGING_C_TAPS = (1, 0) # 7-bit: x^7 + x + 1
SPEED_OF_LIGHT_M_S = 299_792_458

View File

@ -28,29 +28,23 @@ Reference: Ken Shirriff's analysis of the Apollo ranging system
import numpy as np
# ---------------------------------------------------------------------------
# Ranging constants (local to this module; will migrate to constants.py)
# ---------------------------------------------------------------------------
RANGING_CHIP_RATE_HZ = 993_963
RANGING_CODE_LENGTH = 5_456_682 # 2 * 11 * 31 * 63 * 127
RANGING_CL_LENGTH = 2
RANGING_X_LENGTH = 11
RANGING_A_LENGTH = 31
RANGING_B_LENGTH = 63
RANGING_C_LENGTH = 127
RANGING_X_INIT = 22 # 0b10110
RANGING_A_INIT = 0x1F # all ones (5-bit)
RANGING_B_INIT = 0x3F # all ones (6-bit)
RANGING_C_INIT = 0x7F # all ones (7-bit)
# LFSR taps: zero-indexed bit positions for XOR feedback, per Shirriff's code.
# These produce maximal-length sequences (period = 2^n - 1).
RANGING_A_TAPS = (2, 0) # 5-bit: x^5 + x^2 + 1
RANGING_B_TAPS = (1, 0) # 6-bit: x^6 + x + 1
RANGING_C_TAPS = (1, 0) # 7-bit: x^7 + x + 1
SPEED_OF_LIGHT_M_S = 299_792_458
from apollo.constants import (
RANGING_A_INIT,
RANGING_A_LENGTH,
RANGING_A_TAPS,
RANGING_B_INIT,
RANGING_B_LENGTH,
RANGING_B_TAPS,
RANGING_C_INIT,
RANGING_C_LENGTH,
RANGING_C_TAPS,
RANGING_CHIP_RATE_HZ,
RANGING_CL_LENGTH,
RANGING_CODE_LENGTH,
RANGING_X_INIT,
RANGING_X_LENGTH,
SPEED_OF_LIGHT_M_S,
)
class RangingCodeGenerator:

View File

@ -17,13 +17,12 @@ import time
import numpy as np
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.ranging import (
RANGING_CHIP_RATE_HZ,
RangingCorrelator,
)
SAMPLE_RATE_BASEBAND = 5_120_000
# ---------------------------------------------------------------------------
# GNU Radio block (optional -- only if gnuradio is available)
# ---------------------------------------------------------------------------

View File

@ -16,11 +16,9 @@ Reference: IMPLEMENTATION_SPEC.md ranging modulation path
from gnuradio import blocks, gr
from apollo.constants import SAMPLE_RATE_BASEBAND
from apollo.ranging import RANGING_CHIP_RATE_HZ
# Use the standard baseband sample rate
SAMPLE_RATE_BASEBAND = 5_120_000
class ranging_mod(gr.hier_block2):
"""Ranging chip NRZ modulator: byte (0/1) -> float (+1/-1) at sample_rate.

View File

@ -18,15 +18,15 @@ from collections import deque
import numpy as np
from apollo.constants import AGC_CH_INLINK
from apollo.constants import (
AGC_CH_INLINK,
UPLINK_DATA_BIT_RATE,
UPLINK_INTER_WORD_GAP,
UPLINK_WORD_BITS,
)
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

View File

@ -20,16 +20,14 @@ from gnuradio import analog, blocks, digital, filter, gr
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
UPLINK_DATA_BIT_RATE,
UPLINK_DATA_FM_DEVIATION_HZ,
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.

View File

@ -22,17 +22,15 @@ from gnuradio import analog, blocks, gr
from apollo.constants import (
SAMPLE_RATE_BASEBAND,
UPLINK_DATA_BIT_RATE,
UPLINK_DATA_FM_DEVIATION_HZ,
UPLINK_DATA_SUBCARRIER_HZ,
UPLINK_PM_DEVIATION_RAD,
)
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.