#!/usr/bin/env python3 """Run the C-mirroring WLS + timestamped PPS PI simulation.""" from __future__ import annotations import argparse import os from pathlib import Path os.environ.setdefault("MPLCONFIGDIR", "/tmp/pps_python_matplotlib") import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np try: from .pps_loop import PIConfig, ControllerTrace, simulate_controller from .pps_model import ( C, DEFAULT_IONO, POSTPONE_TOW_S, RF_HZ, START_OFFSET_S, TRACKING_HZ, ReceiverEpochSimulator, build_clock_trajectory, c_tropo_delay_m, corrected_geometric_range, ecef_to_lla, azel_from_ecef, klobuchar_delay_m, lla_to_ecef, make_satellite_constellation, ) except ImportError: # allow: python3 pps_python/simulate_pps.py from pps_loop import PIConfig, ControllerTrace, simulate_controller from pps_model import ( C, DEFAULT_IONO, POSTPONE_TOW_S, RF_HZ, START_OFFSET_S, TRACKING_HZ, ReceiverEpochSimulator, build_clock_trajectory, c_tropo_delay_m, corrected_geometric_range, ecef_to_lla, azel_from_ecef, klobuchar_delay_m, lla_to_ecef, make_satellite_constellation, ) def finite_rms(values: np.ndarray) -> float: finite = values[np.isfinite(values)] return float(np.sqrt(np.mean(finite * finite))) if finite.size else np.nan def finite_percentile_abs(values: np.ndarray, percentile: float) -> float: finite = values[np.isfinite(values)] return float(np.percentile(np.abs(finite), percentile)) if finite.size else np.nan def tail(values: np.ndarray, fraction: float = 0.5) -> np.ndarray: start = int(values.size * (1.0 - fraction)) return values[start:] def save_epoch_csv(output_dir: Path, epochs, receiver_ecef: np.ndarray) -> None: rows = [] for epoch in epochs: rows.append( [ epoch.sequence, epoch.transmit_true_s, epoch.reference_arrival_true_s, epoch.snapshot_ready_true_s, epoch.apply_true_s, epoch.satellite_count, epoch.reference_svid, epoch.wls.clock_bias_m, epoch.wls.clock_bias_m / C, epoch.timing_measurement_error_s, np.linalg.norm(epoch.wls.position_ecef_m - receiver_ecef), epoch.compute_delay_s, epoch.apb_delay_s, epoch.wls.iterations, epoch.wls.final_clock_update_m, ] ) header = ( "sequence,tx_true_s,reference_true_s,snapshot_ready_true_s,apply_true_s," "satellite_count,reference_svid,clock_bias_m,clock_bias_s," "reference_error_s,position_error_m,compute_delay_s,apb_delay_s," "wls_iterations,final_clock_update_m" ) np.savetxt( output_dir / "epoch_results.csv", np.asarray(rows), delimiter=",", header=header, comments="", fmt="%.12g", ) def plot_wls(output_dir: Path, epochs, receiver_ecef: np.ndarray) -> None: time_s = np.asarray([epoch.transmit_true_s for epoch in epochs]) bias_ms = np.asarray([epoch.raw_bias_phase_s for epoch in epochs]) * 1.0e3 reference_error_ns = ( np.asarray([epoch.timing_measurement_error_s for epoch in epochs]) * 1.0e9 ) position_error_m = np.asarray( [np.linalg.norm(epoch.wls.position_ecef_m - receiver_ecef) for epoch in epochs] ) satellite_count = np.asarray([epoch.satellite_count for epoch in epochs]) reference_svid = np.asarray([epoch.reference_svid for epoch in epochs]) figure, axes = plt.subplots(4, 1, figsize=(12, 11), sharex=True) axes[0].plot(time_s, bias_ms, linewidth=1.0) axes[0].axhline(0.0, color="black", linewidth=0.6) axes[0].set_ylabel("WLS bias / c [ms]") axes[0].set_title( "Current C observation datum: raw WLS bias is not the PPS phase error" ) axes[0].grid(True, alpha=0.3) axes[1].plot(time_s, reference_error_ns, linewidth=0.8, color="tab:green") axes[1].axhline(0.0, color="black", linewidth=0.6) axes[1].set_ylabel("TOW+bias reference error [ns]") axes[1].grid(True, alpha=0.3) axes[2].semilogy(time_s, np.maximum(position_error_m, 1.0e-3), linewidth=0.8) axes[2].set_ylabel("Position error [m]") axes[2].grid(True, which="both", alpha=0.3) axes[3].step(time_s, satellite_count, where="post", label="satellite count") axes[3].step(time_s, reference_svid, where="post", label="minimum/reference SVID") axes[3].set_ylabel("count / SVID") axes[3].set_xlabel("True elapsed time [s]") axes[3].legend(loc="best") axes[3].grid(True, alpha=0.3) figure.tight_layout() figure.savefig(output_dir / "01_wls_time_model.png", dpi=160) plt.close(figure) def plot_latency(output_dir: Path, epochs) -> None: time_s = np.asarray([epoch.transmit_true_s for epoch in epochs]) propagation_span_ms = np.asarray( [epoch.snapshot_ready_true_s - epoch.reference_arrival_true_s for epoch in epochs] ) * 1.0e3 compute_ms = np.asarray([epoch.compute_delay_s for epoch in epochs]) * 1.0e3 apb_ms = np.asarray([epoch.apb_delay_s for epoch in epochs]) * 1.0e3 total_ms = np.asarray( [epoch.apply_true_s - epoch.reference_arrival_true_s for epoch in epochs] ) * 1.0e3 figure, axes = plt.subplots(2, 1, figsize=(12, 7), sharex=True) axes[0].plot(time_s, propagation_span_ms, label="satellite arrival span") axes[0].plot(time_s, compute_ms, label="WLS compute") axes[0].plot(time_s, apb_ms, label="APB") axes[0].set_ylabel("Delay component [ms]") axes[0].legend(loc="best") axes[0].grid(True, alpha=0.3) axes[1].plot(time_s, total_ms, color="tab:red") axes[1].set_ylabel("Capture to commit [ms]") axes[1].set_xlabel("True elapsed time [s]") axes[1].grid(True, alpha=0.3) figure.suptitle("Compute/APB latency belongs to an old, timestamped epoch") figure.tight_layout() figure.savefig(output_dir / "02_latency_timeline.png", dpi=160) plt.close(figure) def plot_pi( output_dir: Path, traces: list[ControllerTrace], tracking_clock, ) -> None: timestamped, free_running, untagged = traces figure, axes = plt.subplots(4, 1, figsize=(12, 12), sharex=True) axes[0].plot( timestamped.time_s, timestamped.phase_error_s * 1.0e9, label="timestamped PI", ) axes[0].set_ylabel("PI phase [ns]") axes[0].legend(loc="best") axes[0].grid(True, alpha=0.3) axes[1].plot( free_running.time_s, free_running.phase_error_s * 1.0e6, color="tab:orange", label="timestamped, PI disabled", ) axes[1].set_ylabel("Free-run phase [us]") axes[1].legend(loc="best") axes[1].grid(True, alpha=0.3) axes[2].plot( untagged.time_s, untagged.phase_error_s * 1.0e3, color="tab:red", label="old reference treated as APB-now", ) axes[2].set_ylabel("Untagged phase [ms]") axes[2].legend(loc="best") axes[2].grid(True, alpha=0.3) axes[3].plot( timestamped.time_s, timestamped.frequency_command * 1.0e6, label="PI frequency command", ) axes[3].plot( timestamped.time_s, tracking_clock.ppm_at(timestamped.time_s), label="raw 125 MHz oscillator", alpha=0.8, ) axes[3].set_ylabel("Frequency [ppm]") axes[3].set_xlabel("True elapsed time [s]") axes[3].legend(loc="best") axes[3].grid(True, alpha=0.3) figure.suptitle("125 MHz disciplined time: timestamping versus delayed APB-now") figure.tight_layout() figure.savefig(output_dir / "03_pi_loop_response.png", dpi=160) plt.close(figure) def plot_pps( output_dir: Path, traces: list[ControllerTrace], gps_tow_start_s: float, ) -> None: timestamped, free_running, untagged = traces ts_time, ts_error = timestamped.pps_samples(gps_tow_start_s) fr_time, fr_error = free_running.pps_samples(gps_tow_start_s) un_time, un_error = untagged.pps_samples(gps_tow_start_s) figure, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True) axes[0].plot(ts_time, ts_error * 1.0e9, marker=".", label="timestamped PI") axes[0].set_ylabel("Predicted PPS error [ns]") axes[0].legend(loc="best") axes[0].grid(True, alpha=0.3) axes[1].plot(fr_time, fr_error * 1.0e6, marker=".", color="tab:orange") axes[1].set_ylabel("Free-run PPS error [us]") axes[1].grid(True, alpha=0.3) axes[2].plot(un_time, un_error * 1.0e3, marker=".", color="tab:red") axes[2].set_ylabel("Untagged PPS error [ms]") axes[2].set_xlabel("True elapsed time [s]") axes[2].grid(True, alpha=0.3) figure.suptitle("PPS edge error, including 8 ns output quantization") figure.tight_layout() figure.savefig(output_dir / "04_pps_edge_error.png", dpi=160) plt.close(figure) def simulate_startup_tow( receiver_ecef: np.ndarray, satellites, epochs, rng: np.random.Generator, *, trial_count: int = 4000, ) -> dict[str, np.ndarray]: latitude, longitude, height = ecef_to_lla(receiver_ecef) flight_times = [] for satellite in satellites: geometric, rotated = corrected_geometric_range(receiver_ecef, satellite.ecef_m) azimuth, elevation = azel_from_ecef(receiver_ecef, rotated) ionosphere = klobuchar_delay_m( DEFAULT_IONO, 345_600.0, latitude, longitude, azimuth, elevation, ) troposphere = c_tropo_delay_m( np.sin(np.deg2rad(max(elevation, 0.0))), height / 1000.0 ) flight_times.append( (geometric + ionosphere + troposphere) / C - satellite.clock_correction(0.0) ) flight_times = np.asarray(flight_times) # Acquisition/bit-lock availability is intentionally independent per # channel. A full subframe can only be decoded after the next complete # six-second interval, so the first HOW is not necessarily the nearest SV. acquisition_ready = rng.uniform(0.0, 18.0, size=(trial_count, len(satellites))) complete_subframe_tx = (np.floor(acquisition_ready / 6.0) + 2.0) * 6.0 # The current hardware validates/delivers the completed subframe only # on the ninth following bit because validation reads the old shift # register. At 50 bit/s # this is a deterministic 180 ms readout delay (POSTPONE_TOW), distinct # from satellite signal flight time. how_interrupt_rx = ( complete_subframe_tx + POSTPONE_TOW_S + flight_times[None, :] ) first_index = np.argmin(how_interrupt_rx, axis=1) first_flight = flight_times[first_index] timing_noise = np.asarray( [epoch.timing_measurement_error_s for epoch in epochs] ) wls_error = rng.choice(timing_noise, size=trial_count, replace=True) latency = np.asarray( [epoch.apply_true_s - epoch.reference_arrival_true_s for epoch in epochs] ) sampled_latency = rng.choice(latency, size=trial_count, replace=True) return { "first_svid": first_index + 1, "first_flight_s": first_flight, # Parsed HOW labels transmit subframe time, not receive-now. "how_as_receive_error_s": -(POSTPONE_TOW_S + first_flight), "how_plus_pipeline_startoffset_error_s": START_OFFSET_S - first_flight, "wls_aligned_error_s": wls_error, # Old receive reference written as if it were valid at APB-now. "apb_now_error_s": wls_error - sampled_latency, } def plot_startup_tow(output_dir: Path, startup: dict[str, np.ndarray]) -> None: first_svid = startup["first_svid"] unique, counts = np.unique(first_svid, return_counts=True) probability = counts / counts.sum() figure, axes = plt.subplots(2, 2, figsize=(12, 8)) axes[0, 0].bar(unique, probability) axes[0, 0].set_xlabel("First valid HOW SVID") axes[0, 0].set_ylabel("Probability") axes[0, 0].set_title("Acquisition/subframe phase can dominate proximity") axes[0, 0].grid(True, axis="y", alpha=0.3) axes[0, 1].hist( startup["how_as_receive_error_s"] * 1.0e3, bins=35, alpha=0.8, ) axes[0, 1].set_xlabel("Phase error [ms]") axes[0, 1].set_title("Wrong: parsed HOW used as receive-now") axes[0, 1].grid(True, alpha=0.3) axes[1, 0].hist( startup["how_plus_pipeline_startoffset_error_s"] * 1.0e3, bins=35, color="tab:orange", alpha=0.8, ) axes[1, 0].set_xlabel("Phase error [ms]") axes[1, 0].set_title("Provisional: HOW + 180 ms + 68.802 ms") axes[1, 0].grid(True, alpha=0.3) axes[1, 1].hist( startup["wls_aligned_error_s"] * 1.0e9, bins=45, label="timestamped WLS alignment", alpha=0.8, ) axes[1, 1].set_xlabel("Phase error [ns]") axes[1, 1].set_title("Correct: TOW + flight time paired with 125 MHz timestamp") axes[1, 1].grid(True, alpha=0.3) figure.suptitle("Cold-start TOW ownership and PPS coarse alignment") figure.tight_layout() figure.savefig(output_dir / "05_startup_tow_alignment.png", dpi=160) plt.close(figure) def build_summary( epochs, receiver_ecef: np.ndarray, traces: list[ControllerTrace], gps_tow_start_s: float, startup: dict[str, np.ndarray], ) -> str: times = np.asarray([epoch.transmit_true_s for epoch in epochs]) position_error = np.asarray( [np.linalg.norm(epoch.wls.position_ecef_m - receiver_ecef) for epoch in epochs] ) timing_error = np.asarray([epoch.timing_measurement_error_s for epoch in epochs]) raw_bias = np.asarray([epoch.raw_bias_phase_s for epoch in epochs]) latency = np.asarray( [epoch.apply_true_s - epoch.reference_arrival_true_s for epoch in epochs] ) lines = [ "C-mirroring WLS + PPS timing simulation", "========================================", f"epochs: {len(epochs)}", f"raw WLS bias/c range: {np.min(raw_bias)*1e3:.6f} .. {np.max(raw_bias)*1e3:.6f} ms", f"derived GPS reference RMS error: {finite_rms(timing_error)*1e9:.3f} ns", f"capture-to-commit delay mean/p99: {np.mean(latency)*1e3:.3f} / {np.percentile(latency, 99)*1e3:.3f} ms", f"first HOW used as receive-now, median phase error: {np.median(startup['how_as_receive_error_s'])*1e3:.3f} ms", f"first HOW + 180 ms + 68.802 ms, median phase error: {np.median(startup['how_plus_pipeline_startoffset_error_s'])*1e3:.3f} ms", ] for start, stop, label in [ (0.0, 20.0, "4 satellites"), (20.0, 50.0, "5 satellites"), (50.0, np.inf, "7 satellites / drop-reacquisition"), ]: selected = (times >= start) & (times < stop) if np.any(selected): lines.append( f"{label}: position RMS={finite_rms(position_error[selected]):.3f} m, " f"timing RMS={finite_rms(timing_error[selected])*1e9:.3f} ns" ) for trace in traces: phase_tail = tail(trace.phase_error_s) pps_time, pps_error = trace.pps_samples(gps_tow_start_s) del pps_time lines.append( f"{trace.name}: tail phase RMS={finite_rms(phase_tail)*1e9:.3f} ns, " f"|phase| p95={finite_percentile_abs(phase_tail, 95)*1e9:.3f} ns, " f"PPS RMS={finite_rms(pps_error)*1e9:.3f} ns, " f"rejected stale commits={trace.rejected_updates}" ) lines.extend( [ "", "Interpretation:", "- bias/c is millisecond-scale because it contains the 68.802 ms synthetic datum and the minimum satellite path.", "- epoch-aligned TOW (first delivery: HOW + 180 ms) + STARTOFFSET - bias/c reconstructs GPS time at the paired timestamp.", "- treating that old reference as valid at APB arrival converts software latency into PPS phase error.", "- timestamping removes that phase misassociation; control application delay remains only as loop delay.", "- baremetal applies the fixed 180 ms readout correction once; the PPS peripheral must not add it again.", ] ) return "\n".join(lines) + "\n" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--duration", type=float, default=180.0) parser.add_argument("--seed", type=int, default=7) parser.add_argument( "--output-dir", type=Path, default=Path(__file__).resolve().parent / "output", ) parser.add_argument("--no-plots", action="store_true") parser.add_argument("--loop-bandwidth-hz", type=float, default=0.035) parser.add_argument("--max-slew-ppm", type=float, default=20.0) return parser.parse_args() def main() -> int: args = parse_args() if args.duration < 5.0: raise SystemExit("duration must be at least 5 seconds") args.output_dir.mkdir(parents=True, exist_ok=True) rng = np.random.default_rng(args.seed) clock_duration = args.duration + 2.0 shared = build_clock_trajectory( clock_duration, 1.0, rng, base_ppm=1.45, temperature_ppm=0.45, temperature_period_s=140.0, random_walk_ppm_sqrt_s=0.002, ) rf_clock = build_clock_trajectory( clock_duration, RF_HZ, rng, base_ppm=0.10, temperature_ppm=0.05, temperature_period_s=95.0, random_walk_ppm_sqrt_s=0.001, shared_fractional=shared.fractional_frequency, # Force an early 32-bit wrap to exercise the firmware unwrap logic. initial_ticks=float((1 << 32) - 100_000), ) tracking_clock = build_clock_trajectory( clock_duration, TRACKING_HZ, rng, base_ppm=0.20, temperature_ppm=0.12, temperature_period_s=180.0, random_walk_ppm_sqrt_s=0.001, shared_fractional=shared.fractional_frequency, ) receiver_ecef = lla_to_ecef(39.9208, 32.8541, 890.0) satellites = make_satellite_constellation(receiver_ecef) gps_tow_start_s = 345_600.0 receiver = ReceiverEpochSimulator( receiver_ecef, satellites, rf_clock, tracking_clock, rng, gps_tow_start_s=gps_tow_start_s, ) epochs = receiver.simulate(args.duration) if not epochs: raise SystemExit("no valid WLS epochs were generated") pi_config = PIConfig( natural_frequency_hz=args.loop_bandwidth_hz, damping=0.9, max_slew_ppm=args.max_slew_ppm, ) disabled_config = PIConfig( natural_frequency_hz=0.0, damping=0.9, max_slew_ppm=args.max_slew_ppm, ) timestamped = simulate_controller( "timestamped PI", "timestamped", epochs, tracking_clock, gps_tow_start_s, args.duration, pi_config, ) free_running = simulate_controller( "timestamped, PI disabled", "timestamped", epochs, tracking_clock, gps_tow_start_s, args.duration, disabled_config, ) untagged = simulate_controller( "untagged APB-now PI", "untagged", epochs, tracking_clock, gps_tow_start_s, args.duration, pi_config, ) traces = [timestamped, free_running, untagged] startup = simulate_startup_tow(receiver_ecef, satellites, epochs, rng) save_epoch_csv(args.output_dir, epochs, receiver_ecef) summary = build_summary( epochs, receiver_ecef, traces, gps_tow_start_s, startup ) (args.output_dir / "summary.txt").write_text(summary, encoding="utf-8") if not args.no_plots: plot_wls(args.output_dir, epochs, receiver_ecef) plot_latency(args.output_dir, epochs) plot_pi(args.output_dir, traces, tracking_clock) plot_pps(args.output_dir, traces, gps_tow_start_s) plot_startup_tow(args.output_dir, startup) print(summary, end="") print(f"output: {args.output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())