gr-apollo/examples/ranging_demo.py
Ryan Malloy 86a5b08e9d Add PRN ranging system: code generator, modulator, demodulator, and demo
Implements the Apollo composite PRN ranging code (5,456,682 chips) from
five component sequences (CL, X, A, B, C) combined via majority-vote
logic, matching Ken Shirriff's Teensy rangeGenerator.ino bit-for-bit.

LFSR taps corrected to produce maximal-length sequences:
  A: 5-bit, taps [2,0] (x^5+x^2+1, period 31)
  B: 6-bit, taps [1,0] (x^6+x+1, period 63)
  C: 7-bit, taps [1,0] (x^7+x+1, period 127)

New files:
  src/apollo/ranging.py          -- pure-Python code generator and correlator
  src/apollo/ranging_source.py   -- GR sync_block streaming PRN chips
  src/apollo/ranging_mod.py      -- GR hier_block2 NRZ chip modulator
  src/apollo/ranging_demod.py    -- GR basic_block FFT-based range correlator
  grc/apollo_ranging_*.block.yml -- GRC block definitions (3 files)
  examples/ranging_demo.py       -- standalone demo with delay simulation
2026-02-24 14:21:02 -07:00

205 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""
Apollo PRN Ranging Demo -- generate, delay, correlate, measure.
Demonstrates the Apollo ranging system by:
1. Verifying component code properties (lengths, periodicity, balance)
2. Generating the PRN ranging code
3. NRZ encoding to a bipolar waveform
4. Applying a known propagation delay (simulating spacecraft distance)
5. Optionally adding AWGN noise
6. Cross-correlating to recover the delay
7. Comparing measured range to true range
The demo works at chip rate (1 sample per chip) for simplicity and speed.
No GNU Radio runtime is required.
Usage:
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
"""
import argparse
import time
import numpy as np
from apollo.ranging import (
RANGING_A_LENGTH,
RANGING_B_LENGTH,
RANGING_C_LENGTH,
RANGING_CHIP_RATE_HZ,
RANGING_CL_LENGTH,
RANGING_X_LENGTH,
SPEED_OF_LIGHT_M_S,
RangingCodeGenerator,
RangingCorrelator,
range_m_to_chips,
verify_code_properties,
)
def main():
parser = argparse.ArgumentParser(description="Apollo PRN ranging demo")
parser.add_argument(
"--range-km",
type=float,
default=100.0,
help="Target range in km (default: 100)",
)
parser.add_argument(
"--snr",
type=float,
default=None,
help="SNR in dB (default: no noise)",
)
parser.add_argument(
"--chips",
type=int,
default=50_000,
help="Number of chips for correlation (default: 50000)",
)
args = parser.parse_args()
print("=" * 60)
print("Apollo PRN Ranging Demo")
print("=" * 60)
# ------------------------------------------------------------------
# Step 1: Verify code properties
# ------------------------------------------------------------------
print()
print("1. Component code verification")
print("-" * 40)
props = verify_code_properties()
for name in ("cl", "x", "a", "b", "c"):
p = props[name]
status = "OK" if (p["length_correct"] and p["periodic"]) else "FAIL"
balance = ""
if "balance_correct" in p:
balance = f", balance={'OK' if p['balance_correct'] else 'FAIL'}"
print(
f" {name.upper():>2}: length={p['length']:>3} "
f"(1s={p['ones_count']}, 0s={p['zeros_count']}), "
f"periodic={p['periodic']}{balance} [{status}]"
)
lp = props["length_product"]
print(
f" Code length: {lp['expected']:,} "
f"= {RANGING_CL_LENGTH}*{RANGING_X_LENGTH}*{RANGING_A_LENGTH}"
f"*{RANGING_B_LENGTH}*{RANGING_C_LENGTH} "
f"[{'OK' if lp['matches_constant'] else 'FAIL'}]"
)
cs = props["composite_sample"]
print(f" Composite sample ({cs['length']:,} chips): balance={cs['balance']:.3f} (ideal ~0.5)")
# ------------------------------------------------------------------
# Step 2: Generate PRN code
# ------------------------------------------------------------------
print()
print(f"2. Generating {args.chips:,} PRN chips...")
t0 = time.time()
gen = RangingCodeGenerator()
code = gen.generate_sequence(n_chips=args.chips)
t_gen = time.time() - t0
print(f" Generated in {t_gen * 1000:.1f} ms")
print(
f" Ones: {int(np.sum(code)):,} / {args.chips:,} ({100 * np.sum(code) / args.chips:.1f}%)"
)
# ------------------------------------------------------------------
# Step 3: NRZ encode
# ------------------------------------------------------------------
nrz = code.astype(np.float32) * 2.0 - 1.0
# ------------------------------------------------------------------
# Step 4: Apply delay
# ------------------------------------------------------------------
true_range_m = args.range_km * 1000.0
delay_chips = range_m_to_chips(true_range_m, two_way=True)
delay_samples = int(round(delay_chips)) # At chip rate, 1 sample = 1 chip
# Wrap delay within sequence length
delay_samples = delay_samples % args.chips
print()
print(f"3. Simulating range: {args.range_km:.1f} km")
print(f" Round-trip distance: {true_range_m * 2 / 1000:.1f} km")
print(f" Round-trip time: {2 * true_range_m / SPEED_OF_LIGHT_M_S * 1000:.4f} ms")
print(f" Delay: {delay_chips:.2f} chips ({delay_samples} samples)")
received = np.roll(nrz, delay_samples)
# ------------------------------------------------------------------
# Step 5: Add noise
# ------------------------------------------------------------------
if args.snr is not None:
noise_power = 1.0 / (10.0 ** (args.snr / 10.0))
noise = np.random.default_rng(42).standard_normal(len(received)).astype(np.float32)
noise *= np.sqrt(noise_power)
received = received + noise
print(f" Added AWGN noise at {args.snr:.0f} dB SNR (noise power = {noise_power:.4f})")
# ------------------------------------------------------------------
# Step 6: Correlate
# ------------------------------------------------------------------
print()
print("4. Cross-correlating...")
correlator = RangingCorrelator(
chip_rate=RANGING_CHIP_RATE_HZ,
sample_rate=RANGING_CHIP_RATE_HZ, # 1 sample per chip
two_way=True,
)
t0 = time.time()
result = correlator.correlate(received)
t_corr = time.time() - t0
measured_range_m = result["range_m"]
error_m = abs(measured_range_m - true_range_m)
# The quantization error at chip rate:
# one chip = c / (2 * chip_rate) meters (two-way)
quant_m = SPEED_OF_LIGHT_M_S / (2.0 * RANGING_CHIP_RATE_HZ)
print(f" Correlation time: {t_corr * 1000:.1f} ms")
print(f" Peak-to-average ratio: {result['peak_to_avg_ratio']:.1f}")
# ------------------------------------------------------------------
# Step 7: Results
# ------------------------------------------------------------------
print()
print("5. Range measurement results")
print("-" * 40)
print(f" True range: {args.range_km:>12.1f} km")
print(f" Measured range: {measured_range_m / 1000:>12.1f} km")
print(f" Error: {error_m:>12.1f} m")
print(f" Quantization step: {quant_m:>12.1f} m (1 chip, two-way)")
print(f" Delay (chips): {result['delay_chips']:>12.2f}")
print(f" Delay (samples): {result['delay_samples']:>12d}")
print(f" Correlation peak: {result['correlation_peak']:>12.0f}")
if error_m <= quant_m:
print()
print(" Error is within one quantization step -- measurement is correct.")
elif error_m <= quant_m * 2:
print()
print(" Error is within two quantization steps -- acceptable.")
else:
print()
print(" Error exceeds quantization limit. Try more --chips or higher --snr.")
print()
print("=" * 60)
if __name__ == "__main__":
main()