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
205 lines
7.0 KiB
Python
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()
|