test(pps): analyze loop stability and telemetry

This commit is contained in:
2026-07-28 21:41:41 +03:00
parent 73db2a8355
commit 988f9686e7
7 changed files with 2648 additions and 18 deletions
@@ -70,6 +70,48 @@ python3 -m pps_end_to_end_validation.pps_end_to_end \
--trusted-output-delay-ns 35 --trusted-output-delay-ns 35
``` ```
## Closed-loop stability and dynamic tuning
The bit-level command above isolates the absolute epoch convention. The
companion stability analysis connects that result to the fixed-point
firmware/RTL PI model, replays the real GDOP and tracked-satellite history in
`fusion/logs/gps_live_5min_20260713.csv`, and synthesizes only the telemetry
that the repository did not capture:
```bash
python3 -m pps_end_to_end_validation.stability_analysis
python3 -m unittest \
pps_end_to_end_validation/test_stability_analysis.py
```
Generated results are in `output/stability/`:
- `stability_report.md`: real-data inventory, discrete poles, absolute phase
wander, period jitter, dynamic parameter sweep, and recommendations;
- `parameter_sweep.csv`: machine-readable results for every scenario/tuning;
- `synthetic_telemetry.csv`: 5 Hz ABS_SAMPLE, REM_CODE_PHASE, corrected 125 MHz
capture, HDOP/VDOP/TDOP/GDOP, C/N0, pseudorange noise, clock sigma/drift,
oscillator and timing-error inputs.
The real capture contains 989 GDOP values and 990 tracked-satellite values
(eight throughout), but no PPS phase/rate, ABS_SAMPLE, C/N0, TDOP/HDOP,
pseudorange residual, oscillator temperature, or trusted-edge/TIC samples.
The generated CSV labels its replacements as synthetic; it must not be read as
board telemetry.
The current acquire, precision, and robust fixed-point poles are inside the
unit circle at the 200 ms update rate, and the stress analysis observes no
unlock. It also exposes an important metrics issue: cycle-to-cycle PPS period
jitter can remain below 20 ns while absolute phase moves slowly through tens
of nanoseconds. A physical 20 ns maximum therefore requires a common-clock
time-interval-counter capture and cable/output-delay calibration; a p95 period
jitter assertion or synthetic run cannot certify it.
The sweep includes the current coefficients, quieter/faster precision loops,
a robust-noise guard, a larger Doppler-drift gain, and a quality-scheduled
candidate. The generated report contains the current numerical comparison.
No production constants are changed by the analysis.
## 20 ms diagnosis ## 20 ms diagnosis
The current tracking wrapper shifts the current bit into `shift_reg_n`, while The current tracking wrapper shifts the current bit into `shift_reg_n`, while
@@ -0,0 +1,25 @@
scenario,tuning,runs,lock_p95_s,absolute_rms_median_ns,absolute_max_p95_ns,absolute_peak_to_peak_median_ns,period_rms_median_ns,period_p95_median_ns,period_max_worst_ns,wander_10s_peak_to_peak_median_ns,profile_transitions_total,unlocks_total
real_geometry_board_noise,current,24,10.770000000000001,10.881176406988356,32.0,48.00000000000001,3.867084378182096,8.0,8.000000000000002,36.8,0,0
real_geometry_board_noise,robust_noise_guard,24,10.770000000000001,10.881176406988356,32.0,48.00000000000001,3.867084378182096,8.0,8.000000000000002,36.8,0,0
real_geometry_board_noise,quiet_precision,24,10.770000000000001,6.311779001549132,16.000000000000004,24.0,2.7197398634103065,7.999999999999999,8.000000000000005,20.0,0,0
real_geometry_board_noise,fast_precision,24,10.770000000000001,11.482151620012546,40.0,56.0,4.4110037618184545,8.0,16.0,40.0,0,0
real_geometry_board_noise,drift_gain_1_2,24,10.770000000000001,9.830551108590587,32.0,40.0,3.761748764716249,8.0,8.000000000000002,33.6,0,0
real_geometry_board_noise,quality_scheduled_drift,24,10.770000000000001,9.830551108590587,32.0,40.0,3.761748764716249,8.0,8.000000000000002,33.6,0,0
scope_like_multipath,current,24,10.770000000000001,22.943340809908015,62.79999999999998,88.0,4.727734084565672,8.000000000000002,16.0,80.80000000000001,0,0
scope_like_multipath,robust_noise_guard,24,10.770000000000001,22.943340809908015,62.79999999999998,88.0,4.727734084565672,8.000000000000002,16.0,80.80000000000001,0,0
scope_like_multipath,quiet_precision,24,10.770000000000001,17.147532219212493,40.0,63.99999999999999,3.7617487647162484,8.000000000000004,8.000000000000005,58.4,0,0
scope_like_multipath,fast_precision,24,10.770000000000001,22.40714058089752,56.00000000000001,92.0,5.040539175116031,8.000000000000002,16.0,78.0,0,0
scope_like_multipath,drift_gain_1_2,24,10.8,21.354092092763203,62.79999999999998,88.0,4.465388951979694,8.0,16.0,74.80000000000001,0,0
scope_like_multipath,quality_scheduled_drift,24,10.8,21.354092092763203,62.79999999999998,88.0,4.465388951979694,8.0,16.0,74.80000000000001,0,0
quality_transitions,current,24,10.770000000000001,18.351896116449183,96.0,80.0,4.4110037618184545,8.0,23.99999999999999,73.20000000000002,72,0
quality_transitions,robust_noise_guard,24,10.770000000000001,17.481868755279223,80.0,80.0,4.374396022586886,8.0,16.00000000000001,67.6,72,0
quality_transitions,quiet_precision,24,10.770000000000001,10.467771592924619,56.0,47.99999999999999,3.0800577533580347,7.999999999999999,16.0,42.0,72,0
quality_transitions,fast_precision,24,10.770000000000001,19.145424296464835,88.00000000000001,88.0,4.861884148700504,8.0,24.000000000000004,73.6,72,0
quality_transitions,drift_gain_1_2,24,10.770000000000001,16.291766736261422,83.39999999999999,76.0,4.089452052592408,8.0,16.000000000000004,63.599999999999994,72,0
quality_transitions,quality_scheduled_drift,24,10.770000000000001,16.766578539927025,83.39999999999999,80.0,4.224793965697071,8.0,16.00000000000001,67.6,72,0
thermal_0p05ppm,current,24,10.770000000000001,39.2873108676108,102.79999999999998,136.0,5.727479384554999,8.000000000000005,24.0,124.0,0,0
thermal_0p05ppm,robust_noise_guard,24,10.770000000000001,39.2873108676108,102.79999999999998,136.0,5.727479384554999,8.000000000000005,24.0,124.0,0,0
thermal_0p05ppm,quiet_precision,24,10.770000000000001,210.25812707207118,416.0,648.0,13.000191801484194,24.000000000000004,32.0,632.4,0,0
thermal_0p05ppm,fast_precision,24,10.770000000000001,21.850277755888733,63.39999999999999,96.0,5.82476895848467,8.000000000000002,16.000000000000004,77.60000000000001,0,0
thermal_0p05ppm,drift_gain_1_2,24,10.770000000000001,38.40828815100511,96.0,136.0,5.656836393570529,8.000000000000005,16.000000000000004,122.0,0,0
thermal_0p05ppm,quality_scheduled_drift,24,10.770000000000001,38.40828815100511,96.0,136.0,5.656836393570529,8.000000000000005,16.000000000000004,122.0,0,0
1 scenario tuning runs lock_p95_s absolute_rms_median_ns absolute_max_p95_ns absolute_peak_to_peak_median_ns period_rms_median_ns period_p95_median_ns period_max_worst_ns wander_10s_peak_to_peak_median_ns profile_transitions_total unlocks_total
2 real_geometry_board_noise current 24 10.770000000000001 10.881176406988356 32.0 48.00000000000001 3.867084378182096 8.0 8.000000000000002 36.8 0 0
3 real_geometry_board_noise robust_noise_guard 24 10.770000000000001 10.881176406988356 32.0 48.00000000000001 3.867084378182096 8.0 8.000000000000002 36.8 0 0
4 real_geometry_board_noise quiet_precision 24 10.770000000000001 6.311779001549132 16.000000000000004 24.0 2.7197398634103065 7.999999999999999 8.000000000000005 20.0 0 0
5 real_geometry_board_noise fast_precision 24 10.770000000000001 11.482151620012546 40.0 56.0 4.4110037618184545 8.0 16.0 40.0 0 0
6 real_geometry_board_noise drift_gain_1_2 24 10.770000000000001 9.830551108590587 32.0 40.0 3.761748764716249 8.0 8.000000000000002 33.6 0 0
7 real_geometry_board_noise quality_scheduled_drift 24 10.770000000000001 9.830551108590587 32.0 40.0 3.761748764716249 8.0 8.000000000000002 33.6 0 0
8 scope_like_multipath current 24 10.770000000000001 22.943340809908015 62.79999999999998 88.0 4.727734084565672 8.000000000000002 16.0 80.80000000000001 0 0
9 scope_like_multipath robust_noise_guard 24 10.770000000000001 22.943340809908015 62.79999999999998 88.0 4.727734084565672 8.000000000000002 16.0 80.80000000000001 0 0
10 scope_like_multipath quiet_precision 24 10.770000000000001 17.147532219212493 40.0 63.99999999999999 3.7617487647162484 8.000000000000004 8.000000000000005 58.4 0 0
11 scope_like_multipath fast_precision 24 10.770000000000001 22.40714058089752 56.00000000000001 92.0 5.040539175116031 8.000000000000002 16.0 78.0 0 0
12 scope_like_multipath drift_gain_1_2 24 10.8 21.354092092763203 62.79999999999998 88.0 4.465388951979694 8.0 16.0 74.80000000000001 0 0
13 scope_like_multipath quality_scheduled_drift 24 10.8 21.354092092763203 62.79999999999998 88.0 4.465388951979694 8.0 16.0 74.80000000000001 0 0
14 quality_transitions current 24 10.770000000000001 18.351896116449183 96.0 80.0 4.4110037618184545 8.0 23.99999999999999 73.20000000000002 72 0
15 quality_transitions robust_noise_guard 24 10.770000000000001 17.481868755279223 80.0 80.0 4.374396022586886 8.0 16.00000000000001 67.6 72 0
16 quality_transitions quiet_precision 24 10.770000000000001 10.467771592924619 56.0 47.99999999999999 3.0800577533580347 7.999999999999999 16.0 42.0 72 0
17 quality_transitions fast_precision 24 10.770000000000001 19.145424296464835 88.00000000000001 88.0 4.861884148700504 8.0 24.000000000000004 73.6 72 0
18 quality_transitions drift_gain_1_2 24 10.770000000000001 16.291766736261422 83.39999999999999 76.0 4.089452052592408 8.0 16.000000000000004 63.599999999999994 72 0
19 quality_transitions quality_scheduled_drift 24 10.770000000000001 16.766578539927025 83.39999999999999 80.0 4.224793965697071 8.0 16.00000000000001 67.6 72 0
20 thermal_0p05ppm current 24 10.770000000000001 39.2873108676108 102.79999999999998 136.0 5.727479384554999 8.000000000000005 24.0 124.0 0 0
21 thermal_0p05ppm robust_noise_guard 24 10.770000000000001 39.2873108676108 102.79999999999998 136.0 5.727479384554999 8.000000000000005 24.0 124.0 0 0
22 thermal_0p05ppm quiet_precision 24 10.770000000000001 210.25812707207118 416.0 648.0 13.000191801484194 24.000000000000004 32.0 632.4 0 0
23 thermal_0p05ppm fast_precision 24 10.770000000000001 21.850277755888733 63.39999999999999 96.0 5.82476895848467 8.000000000000002 16.000000000000004 77.60000000000001 0 0
24 thermal_0p05ppm drift_gain_1_2 24 10.770000000000001 38.40828815100511 96.0 136.0 5.656836393570529 8.000000000000005 16.000000000000004 122.0 0 0
25 thermal_0p05ppm quality_scheduled_drift 24 10.770000000000001 38.40828815100511 96.0 136.0 5.656836393570529 8.000000000000005 16.000000000000004 122.0 0 0
@@ -0,0 +1,70 @@
# PPS stability analysis: current RTL + 180 ms firmware model
This report is generated by `stability_analysis.py`. The absolute LNAV epoch path and the stochastic control loop are evaluated separately so a 20 ms semantic error cannot be hidden by loop noise.
## Absolute epoch result
- Current RTL delivered the validated subframe after 9 following bits.
- With 180 ms postponement, reference error was -0.000032 ms.
- DUT minus trusted PPS was 32.01 ns. That remainder is the model's configured four-PCLK capture pipeline, not a measured board delay.
- The production source still defines `POSTPONE_TOW` as 160 ms; this analysis uses the requested 180 ms candidate configuration.
## Repository hardware data
- Real GPS capture: 989 GDOP samples and 990 tracked-satellite samples over about five minutes.
- GDOP min/median/p95/max: 1.86 / 1.93 / 3.15 / 3.15.
- Tracked satellites: 8..8 (median 8).
- Horizontal scatter RMS/p95/max: 8.58 / 15.15 / 23.77 m. No surveyed truth was recorded.
- No real ABS_SAMPLE, REM_CODE_PHASE, TDOP/HDOP, C/N0, pseudorange residual, PPS phase/rate/integrator, temperature, or trusted-edge TIC samples exist in the repository. Those columns in the companion CSV are explicitly synthetic.
Synthetic timing uses the board-calibration values already encoded in the PPS tests: TDOP 1.50, GDOP 2.92, clock sigma 15.87 m (52.94 ns), C/N0 40.3/34.8 dB-Hz, drift noise 3.47 m/s, seven-satellite reference point, -291.75 m/s datum range rate, and 1.592 ppm local oscillator. Real GDOP and satellite count replace the geometry fields when available.
## Current-loop results
`period max` is the worst |edge[n]-edge[n-1]| across all seeds. `absolute p-p` and `10 s wander p-p` expose the slow left/right motion that period RMS hides.
| scenario | abs RMS median (ns) | abs p-p median (ns) | 10 s wander p-p (ns) | period RMS (ns) | period max (ns) | unlocks |
|---|---:|---:|---:|---:|---:|---:|
| real_geometry_board_noise | 10.88 | 48.00 | 36.80 | 3.87 | 8.00 | 0 |
| scope_like_multipath | 22.94 | 88.00 | 80.80 | 4.73 | 16.00 | 0 |
| quality_transitions | 18.35 | 80.00 | 73.20 | 4.41 | 24.00 | 0 |
| thermal_0p05ppm | 39.29 | 136.00 | 124.00 | 5.73 | 24.00 | 0 |
The loop does not numerically oscillate or lose lock in these runs. The scope-like symptom is low-frequency phase wander: it can be tens of nanoseconds while one-second period jitter remains below 20 ns. Therefore a `p95 period jitter < 20 ns` assertion is not sufficient for an absolute ±20 ns requirement.
## Linear stability
The fixed-point coefficients give the following discrete poles at the 200 ms navigation update rate:
| profile | natural frequency | damping | poles | max magnitude |
|---|---:|---:|---|---:|
| acquire | 0.120 Hz | 0.90 | 0.852913+0.033242j, 0.852913-0.033242j | 0.853561 |
| precision | 0.035 Hz | 0.90 | 0.959449+0.017031j, 0.959449-0.017031j | 0.959600 |
| robust | 0.012 Hz | 1.00 | 0.986668+0.000000j, 0.982945+0.000000j | 0.986668 |
All magnitudes are below one. Profile changes preserve the PI integrator, and the RTL snapshots Kp/Ki/max-slew/lock limits together with the next commit, so the current acquire/precision/robust switching is structurally sound. Stable individual modes do not prove arbitrary-switching stability, but the asymmetric 8-update upgrade / 3-update downgrade hysteresis and the stress runs show no coefficient chatter or unlock.
## Dynamic parameter sweep
| tuning | worst period max (ns) | median abs RMS over scenarios (ns) | transitions | unlocks |
|---|---:|---:|---:|---:|
| current | 24.00 | 20.65 | 72 | 0 |
| robust_noise_guard | 24.00 | 20.21 | 72 | 0 |
| quiet_precision | 32.00 | 13.81 | 72 | 0 |
| fast_precision | 24.00 | 20.50 | 72 | 0 |
| drift_gain_1_2 | 16.00 | 18.82 | 72 | 0 |
| quality_scheduled_drift | 16.00 | 19.06 | 72 | 0 |
The safest small candidate is `robust_noise_guard`: retain the current 0.035 Hz precision loop and 0.012 Hz robust loop, but reduce only the robust-profile WLS residual gain from 1/32 to 1/64. In the synthetic quality-transition case its worst period step changed from 24.00 to 16.00 ns. This is a candidate for hardware A/B testing, not enough evidence for a production constant change.
The best synthetic all-scenario period result is `quality_scheduled_drift`: use drift gain 1/2 only with a precision-quality Doppler solution, fall back to the current 1/4 gain in robust quality, and combine that with the 1/64 robust bias gain. Its worst period step was 16.00 ns with no unlocks. It is the most meaningful dynamic A/B candidate, but the repository has no real drift-covariance or TIC samples with which to validate the threshold.
Narrowing precision to 0.012 Hz improves stationary noise but lags even the explicit 0.05 ppm/120 s synthetic oscillator variation. Widening it improves oscillator tracking but passes more WLS/multipath motion. A geometry-only bandwidth scheduler cannot resolve that tradeoff.
## Recommended architecture update
1. Keep current acquire/precision/robust PI profiles until real TIC and oscillator-temperature data are available. Do not tune from scope appearance alone.
2. Log the existing firmware status line at every solve during validation: ABS_SAMPLE, REM_CODE_PHASE, capture sequence/counter, TDOP/HDOP/GDOP, C/N0, pseudorange residual RMS, raw/filtered bias, innovation, bias drift, hardware phase, rate, integrator, profile, lock, FPGA/oscillator temperature, DUT edge and trusted edge.
3. A/B test the simulated scheduled filter first: precision drift gain 1/2, robust drift gain 1/4, precision bias gain 1/32, and robust bias gain 1/64. Longer term, use estimated phase/drift covariance to interpolate those software gains within bounds. Keep RTL PI bandwidth changes hysteretic and preserve its integrator as today.
4. If temperature/frequency acceleration is the main source, add oscillator temperature feed-forward or a phase/frequency/frequency-drift estimator. Switching PI bandwidth alone either passes WLS wander or lags the oscillator.
5. Define acceptance separately: calibrated mean offset, absolute centered error/p-p wander, one-second period error, holdover, and re-lock. A physical max-20 ns claim requires a common-clock TIC run after cable/output-delay calibration; this synthetic test cannot certify it.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,865 @@
#!/usr/bin/env python3
"""End-to-end PPS epoch, telemetry, and closed-loop stability analysis.
The bit-level model proves the current-RTL/180 ms epoch convention. This
module then drives the fixed-point firmware/RTL loop model with the telemetry
that is present in the repository and deterministic synthetic replacements for
the timing fields that were not captured on the board.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import dataclass, replace
import math
from pathlib import Path
import random
import re
import statistics
from typing import Iterable
from pps_python.adaptive_pps import (
ACQUIRE_PROFILE,
PROFILES,
FixedPointAdaptivePps,
LoopProfile,
Observation,
Profile,
QualityMetrics,
TRACKING_HZ,
classify_quality,
)
from .pps_end_to_end import DecoderMode, EndToEndConfig, run_end_to_end
RF_HZ = 16_367_981.0
CODE_FREQ_HZ = 1_023_000.0
SPEED_OF_LIGHT_MPS = 299_792_458.0
UPDATE_PERIOD_S = 0.2
BOARD_CLOCK_SIGMA_M = 15.87
BOARD_TDOP = 1.50
BOARD_GDOP = 2.92
BOARD_CN0_MEAN_DBHZ = 40.3
BOARD_CN0_MIN_DBHZ = 34.8
BOARD_DRIFT_SIGMA_MPS = 3.47
BOARD_OSCILLATOR_PPM = 1.592
BOARD_REFERENCE_RANGE_RATE_MPS = -291.75
@dataclass(frozen=True)
class RealTelemetry:
gdop_time_s: tuple[float, ...]
gdop: tuple[float, ...]
tracked_time_s: tuple[float, ...]
tracked: tuple[int, ...]
horizontal_rms_m: float
horizontal_p95_m: float
horizontal_max_m: float
def gdop_at(self, time_s: float) -> float:
return _linear_sample(self.gdop_time_s, self.gdop, time_s)
def tracked_at(self, time_s: float) -> int:
if not self.tracked:
return 8
index = _previous_index(self.tracked_time_s, time_s)
return self.tracked[index]
@dataclass(frozen=True)
class Scenario:
name: str
multipath: bool = False
geometry_transitions: bool = False
thermal_amplitude_ppm: float = 0.0
thermal_period_s: float = 120.0
@dataclass(frozen=True)
class Tuning:
name: str
profiles: dict[Profile, LoopProfile]
acquire: LoopProfile
drift_gain: float
robust_drift_gain: float | None = None
@dataclass(frozen=True)
class SyntheticEpoch:
scenario: str
time_s: float
abs_sample: int
rem_code_phase: int
corrected_abs_sample: float
capture_counter_125: int
capture_correction_ns: float
used_satellites: int
gdop: float
hdop: float
vdop: float
tdop: float
cn0_mean_dbhz: float
cn0_min_dbhz: float
pseudorange_noise_rms_m: float
clock_bias_sigma_m: float
clock_drift_error_mps: float
timing_error_ns: float
oscillator_ppm: float
quality_profile: str
observation: Observation
@dataclass(frozen=True)
class RunMetrics:
first_lock_s: float
absolute_rms_ns: float
absolute_p95_ns: float
absolute_max_ns: float
absolute_peak_to_peak_ns: float
period_rms_ns: float
period_p95_ns: float
period_max_ns: float
wander_10s_peak_to_peak_ns: float
profile_transitions: int
unlocks: int
@dataclass(frozen=True)
class Aggregate:
scenario: str
tuning: str
runs: int
lock_p95_s: float
absolute_rms_median_ns: float
absolute_max_p95_ns: float
absolute_peak_to_peak_median_ns: float
period_rms_median_ns: float
period_p95_median_ns: float
period_max_worst_ns: float
wander_10s_peak_to_peak_median_ns: float
profile_transitions_total: int
unlocks_total: int
def _percentile(values: Iterable[float], fraction: float) -> float:
ordered = sorted(values)
if not ordered:
return math.nan
position = (len(ordered) - 1) * fraction
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return ordered[lower]
weight = position - lower
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
def _previous_index(times: tuple[float, ...], time_s: float) -> int:
if not times:
return 0
low = 0
high = len(times)
while low < high:
middle = (low + high) // 2
if times[middle] <= time_s:
low = middle + 1
else:
high = middle
return max(0, low - 1)
def _linear_sample(
times: tuple[float, ...], values: tuple[float, ...], time_s: float
) -> float:
if not values:
return 1.93
if time_s <= times[0]:
return values[0]
if time_s >= times[-1]:
return values[-1]
lower = _previous_index(times, time_s)
upper = min(lower + 1, len(times) - 1)
span = times[upper] - times[lower]
if span <= 0.0:
return values[lower]
fraction = (time_s - times[lower]) / span
return values[lower] + fraction * (values[upper] - values[lower])
def load_real_telemetry(software_dir: Path) -> RealTelemetry:
"""Load the only real GPS capture and its horizontal replay output."""
log_path = software_dir / "fusion/logs/gps_live_5min_20260713.csv"
comparison_path = (
software_dir
/ "fusion/output/model_compare_5min_20260713/model_comparison.csv"
)
gdop_time: list[float] = []
gdop: list[float] = []
tracked_time: list[float] = []
tracked: list[int] = []
if log_path.exists():
with log_path.open(
"r", encoding="utf-8", errors="replace", newline=""
) as source:
for row in csv.reader(source):
if len(row) < 3:
continue
try:
time_s = float(row[0])
except ValueError:
continue
match = re.search(r"GDOP=([0-9.]+)", row[2])
if match:
gdop_time.append(time_s)
gdop.append(float(match.group(1)))
match = re.search(r"tracked satellites=(\d+)", row[2])
if match:
tracked_time.append(time_s)
tracked.append(int(match.group(1)))
radii: list[float] = []
if comparison_path.exists():
with comparison_path.open("r", encoding="utf-8", newline="") as source:
for row in csv.DictReader(source):
try:
radii.append(
math.hypot(float(row["gps_e_m"]), float(row["gps_n_m"]))
)
except (KeyError, TypeError, ValueError):
continue
horizontal_rms = (
math.sqrt(statistics.mean(value * value for value in radii))
if radii
else math.nan
)
return RealTelemetry(
tuple(gdop_time),
tuple(gdop),
tuple(tracked_time),
tuple(tracked),
horizontal_rms,
_percentile(radii, 0.95),
max(radii, default=math.nan),
)
def scenario_matrix() -> tuple[Scenario, ...]:
return (
Scenario("real_geometry_board_noise"),
Scenario("scope_like_multipath", multipath=True),
Scenario("quality_transitions", geometry_transitions=True),
Scenario("thermal_0p05ppm", thermal_amplitude_ppm=0.05),
)
def tuning_matrix() -> tuple[Tuning, ...]:
current = dict(PROFILES)
guarded = dict(PROFILES)
guarded[Profile.ROBUST] = replace(
guarded[Profile.ROBUST], software_bias_gain=1.0 / 64.0
)
quiet = dict(guarded)
quiet[Profile.PRECISION] = replace(
quiet[Profile.PRECISION],
natural_frequency_hz=0.012,
damping=1.0,
software_bias_gain=1.0 / 16.0,
)
fast = dict(guarded)
fast[Profile.PRECISION] = replace(
fast[Profile.PRECISION], natural_frequency_hz=0.050, damping=1.0
)
return (
Tuning("current", current, ACQUIRE_PROFILE, 1.0 / 4.0),
Tuning("robust_noise_guard", guarded, ACQUIRE_PROFILE, 1.0 / 4.0),
Tuning("quiet_precision", quiet, ACQUIRE_PROFILE, 1.0 / 4.0),
Tuning("fast_precision", fast, ACQUIRE_PROFILE, 1.0 / 4.0),
Tuning("drift_gain_1_2", guarded, ACQUIRE_PROFILE, 1.0 / 2.0),
Tuning(
"quality_scheduled_drift",
guarded,
ACQUIRE_PROFILE,
1.0 / 2.0,
1.0 / 4.0,
),
)
def _weak_geometry(time_s: float) -> bool:
return (80.0 <= time_s < 110.0) or (180.0 <= time_s < 205.0)
def synthesize_epochs(
telemetry: RealTelemetry,
scenario: Scenario,
*,
duration_s: float,
seed: int,
) -> list[SyntheticEpoch]:
"""Combine real GDOP/satellite count with documented board noise."""
rng = random.Random(seed)
result: list[SyntheticEpoch] = []
count = round(duration_s / UPDATE_PERIOD_S)
rf_initial = (1 << 32) - round(10.25 * RF_HZ) - 1000
for update in range(count):
time_s = update * UPDATE_PERIOD_S
gdop = telemetry.gdop_at(time_s)
satellites = max(4, min(8, telemetry.tracked_at(time_s)))
tdop = gdop * (BOARD_TDOP / BOARD_GDOP)
pdop = math.sqrt(max(0.0, gdop * gdop - tdop * tdop))
vdop = 0.65 * pdop
hdop = math.sqrt(max(0.0, pdop * pdop - vdop * vdop))
cn0_mean = (
BOARD_CN0_MEAN_DBHZ
+ 1.2 * math.sin(2.0 * math.pi * time_s / 93.0)
+ rng.gauss(0.0, 0.35)
)
cn0_min = cn0_mean - (
BOARD_CN0_MEAN_DBHZ - BOARD_CN0_MIN_DBHZ
)
chi_square = 0.35
velocity_residual = 0.23
drift_sigma = BOARD_DRIFT_SIGMA_MPS
if scenario.geometry_transitions and _weak_geometry(time_s):
gdop = 8.0
tdop = 5.0
hdop = 4.3
vdop = math.sqrt(max(0.0, gdop * gdop - tdop * tdop - hdop * hdop))
satellites = 5
cn0_mean = 27.0
cn0_min = 18.0
chi_square = 0.85
velocity_residual = 1.8
clock_sigma = 65.0
drift_sigma = 5.0
uere_sigma = clock_sigma / tdop
else:
uere_sigma = (
(BOARD_CLOCK_SIGMA_M / BOARD_TDOP)
* 10.0 ** ((BOARD_CN0_MEAN_DBHZ - cn0_mean) / 20.0)
)
clock_sigma = tdop * uere_sigma
pseudorange_errors = [
rng.gauss(0.0, uere_sigma) for _ in range(satellites)
]
pseudorange_rms = math.sqrt(
statistics.mean(value * value for value in pseudorange_errors)
)
long_omega = 2.0 * math.pi / 75.0
short_omega = 2.0 * math.pi / 19.0
true_datum_drift = (
5.0 * math.sin(long_omega * time_s)
+ 2.0 * math.sin(short_omega * time_s)
)
true_datum_offset = (
5.0 * (1.0 - math.cos(long_omega * time_s)) / long_omega
+ 2.0 * (1.0 - math.cos(short_omega * time_s)) / short_omega
)
timing_error_s = rng.gauss(0.0, clock_sigma / SPEED_OF_LIGHT_MPS)
if scenario.multipath:
timing_error_s += (
25.0e-9 * math.sin(2.0 * math.pi * time_s / 67.0)
+ 15.0e-9
* math.sin(2.0 * math.pi * time_s / 29.0 + 0.8)
)
drift_error = true_datum_drift + rng.gauss(0.0, drift_sigma)
oscillator_ppm = BOARD_OSCILLATOR_PPM + (
scenario.thermal_amplitude_ppm
* math.sin(2.0 * math.pi * time_s / scenario.thermal_period_s)
)
quality = QualityMetrics(
tdop,
gdop,
clock_sigma,
cn0_mean,
cn0_min,
chi_square,
velocity_residual,
satellites,
)
observation = Observation(
time_s,
timing_error_s,
drift_error,
quality,
True,
BOARD_REFERENCE_RANGE_RATE_MPS,
1,
true_datum_offset,
)
continuous_abs = rf_initial + time_s * RF_HZ
raw_unwrapped = math.ceil(continuous_abs - 1.0e-12)
fractional_sample = raw_unwrapped - continuous_abs
rem_code_phase = round(fractional_sample * CODE_FREQ_HZ)
abs_sample = raw_unwrapped & 0xFFFF_FFFF
corrected_abs = abs_sample - rem_code_phase / CODE_FREQ_HZ
raw_capture_counter = math.floor(
time_s * TRACKING_HZ * (1.0 + oscillator_ppm * 1.0e-6)
)
capture_correction_ticks = round(
(rem_code_phase / CODE_FREQ_HZ) * (TRACKING_HZ / RF_HZ)
)
capture_counter = raw_capture_counter - capture_correction_ticks
result.append(
SyntheticEpoch(
scenario.name,
time_s,
abs_sample,
rem_code_phase,
corrected_abs,
capture_counter,
capture_correction_ticks / TRACKING_HZ * 1.0e9,
satellites,
gdop,
hdop,
vdop,
tdop,
cn0_mean,
cn0_min,
pseudorange_rms,
clock_sigma,
drift_error,
timing_error_s * 1.0e9,
oscillator_ppm,
classify_quality(quality).name.lower(),
observation,
)
)
return result
def _run_loop(epochs: list[SyntheticEpoch], tuning: Tuning):
model = FixedPointAdaptivePps(
oscillator_ppm=BOARD_OSCILLATOR_PPM,
profiles=tuning.profiles,
acquire_profile=tuning.acquire,
software_drift_gain=tuning.drift_gain,
)
for epoch in epochs:
# Update at a solve boundary; the selected value applies to the
# preceding synthetic interval, whose endpoint is this observation.
model.oscillator_fraction = epoch.oscillator_ppm * 1.0e-6
if tuning.robust_drift_gain is not None:
target = classify_quality(epoch.observation.quality)
model.software_drift_gain = (
tuning.robust_drift_gain
if target != Profile.PRECISION
else tuning.drift_gain
)
model.update(epoch.observation)
finish_s = epochs[-1].time_s + UPDATE_PERIOD_S if epochs else 0.0
return model.finish(finish_s)
def measure_trace(trace, *, after_s: float) -> RunMetrics:
edge_errors = [
error for _, error in trace.integer_second_edge_errors(after_s=after_s)
]
if len(edge_errors) < 12:
raise RuntimeError("not enough locked integer-second PPS edges")
center = statistics.median(edge_errors)
centered = [value - center for value in edge_errors]
periods = [
edge_errors[index] - edge_errors[index - 1]
for index in range(1, len(edge_errors))
]
moving_10s = [
statistics.mean(centered[max(0, index - 9) : index + 1])
for index in range(len(centered))
]
profiles = [
profile
for time_s, profile in zip(trace.time_s, trace.profile)
if time_s >= after_s
]
profile_transitions = sum(
profiles[index] != profiles[index - 1]
for index in range(1, len(profiles))
)
return RunMetrics(
trace.first_lock_time() if trace.first_lock_time() is not None else math.inf,
math.sqrt(statistics.mean(value * value for value in centered)) * 1.0e9,
_percentile((abs(value) for value in centered), 0.95) * 1.0e9,
max(abs(value) for value in centered) * 1.0e9,
(max(centered) - min(centered)) * 1.0e9,
math.sqrt(statistics.mean(value * value for value in periods)) * 1.0e9,
_percentile((abs(value) for value in periods), 0.95) * 1.0e9,
max(abs(value) for value in periods) * 1.0e9,
(max(moving_10s) - min(moving_10s)) * 1.0e9,
profile_transitions,
trace.unlock_events,
)
def aggregate(
scenario: Scenario, tuning: Tuning, runs: list[RunMetrics]
) -> Aggregate:
return Aggregate(
scenario.name,
tuning.name,
len(runs),
_percentile((run.first_lock_s for run in runs), 0.95),
statistics.median(run.absolute_rms_ns for run in runs),
_percentile((run.absolute_max_ns for run in runs), 0.95),
statistics.median(run.absolute_peak_to_peak_ns for run in runs),
statistics.median(run.period_rms_ns for run in runs),
statistics.median(run.period_p95_ns for run in runs),
max(run.period_max_ns for run in runs),
statistics.median(run.wander_10s_peak_to_peak_ns for run in runs),
sum(run.profile_transitions for run in runs),
sum(run.unlocks for run in runs),
)
def discrete_poles(profile: LoopProfile) -> tuple[complex, complex]:
"""Poles of the exact sample/commit ordering, before slew saturation."""
kp_q46, ki_q46, _ = profile.fixed_coefficients(UPDATE_PERIOD_S)
kp = kp_q46 / (1 << 46)
ki = ki_q46 / (1 << 46)
ticks = UPDATE_PERIOD_S * TRACKING_HZ
a = 1.0 - ticks * (kp + ki)
b = ticks
c = -ki
d = 1.0
trace = a + d
determinant = a * d - b * c
discriminant = complex(trace * trace - 4.0 * determinant, 0.0) ** 0.5
return (trace + discriminant) / 2.0, (trace - discriminant) / 2.0
def write_telemetry(path: Path, epochs: list[SyntheticEpoch]) -> None:
fields = [
"scenario", "time_s", "abs_sample", "rem_code_phase",
"corrected_abs_sample", "capture_counter_125", "used_satellites",
"capture_correction_ns",
"gdop", "hdop", "vdop", "tdop", "cn0_mean_dbhz",
"cn0_min_dbhz", "pseudorange_noise_rms_m", "clock_bias_sigma_m",
"clock_drift_error_mps", "timing_error_ns", "oscillator_ppm",
"quality_profile",
]
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as output:
writer = csv.DictWriter(
output, fieldnames=fields, lineterminator="\n"
)
writer.writeheader()
for epoch in epochs:
writer.writerow({field: getattr(epoch, field) for field in fields})
def write_sweep(path: Path, results: list[Aggregate]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as output:
writer = csv.DictWriter(
output,
fieldnames=list(Aggregate.__annotations__),
lineterminator="\n",
)
writer.writeheader()
for item in results:
writer.writerow(item.__dict__)
def _fmt(value: float) -> str:
return "n/a" if not math.isfinite(value) else f"{value:.2f}"
def write_report(
path: Path,
telemetry: RealTelemetry,
results: list[Aggregate],
epoch_result,
) -> None:
gdop = telemetry.gdop
tracked = telemetry.tracked
current = [item for item in results if item.tuning == "current"]
guarded = [
item for item in results if item.tuning == "robust_noise_guard"
]
lines = [
"# PPS stability analysis: current RTL + 180 ms firmware model",
"",
"This report is generated by `stability_analysis.py`. The absolute "
"LNAV epoch path and the stochastic control loop are evaluated "
"separately so a 20 ms semantic error cannot be hidden by loop noise.",
"",
"## Absolute epoch result",
"",
f"- Current RTL delivered the validated subframe after "
f"{epoch_result.first_subframe_readout_bits} following bits.",
f"- With 180 ms postponement, reference error was "
f"{epoch_result.first_reference_error_ms:.6f} ms.",
f"- DUT minus trusted PPS was "
f"{epoch_result.pps.edges[0].dut_minus_trusted_s * 1.0e9:.2f} ns. "
"That remainder is the model's configured four-PCLK capture pipeline, "
"not a measured board delay.",
"- The production source still defines `POSTPONE_TOW` as 160 ms; this "
"analysis uses the requested 180 ms candidate configuration.",
"",
"## Repository hardware data",
"",
f"- Real GPS capture: {len(gdop)} GDOP samples and {len(tracked)} "
"tracked-satellite samples over about five minutes.",
f"- GDOP min/median/p95/max: {_fmt(min(gdop))} / "
f"{_fmt(statistics.median(gdop))} / {_fmt(_percentile(gdop, 0.95))} / "
f"{_fmt(max(gdop))}.",
f"- Tracked satellites: {min(tracked)}..{max(tracked)} "
f"(median {statistics.median(tracked):.0f}).",
f"- Horizontal scatter RMS/p95/max: "
f"{_fmt(telemetry.horizontal_rms_m)} / "
f"{_fmt(telemetry.horizontal_p95_m)} / "
f"{_fmt(telemetry.horizontal_max_m)} m. No surveyed truth was recorded.",
"- No real ABS_SAMPLE, REM_CODE_PHASE, TDOP/HDOP, C/N0, pseudorange "
"residual, PPS phase/rate/integrator, temperature, or trusted-edge TIC "
"samples exist in the repository. Those columns in the companion CSV "
"are explicitly synthetic.",
"",
"Synthetic timing uses the board-calibration values already encoded "
"in the PPS tests: TDOP 1.50, GDOP 2.92, clock sigma 15.87 m "
"(52.94 ns), C/N0 40.3/34.8 dB-Hz, drift noise 3.47 m/s, seven-satellite "
"reference point, -291.75 m/s datum range rate, and 1.592 ppm local "
"oscillator. Real GDOP and satellite count replace the geometry fields "
"when available.",
"",
"## Current-loop results",
"",
"`period max` is the worst |edge[n]-edge[n-1]| across all seeds. "
"`absolute p-p` and `10 s wander p-p` expose the slow left/right "
"motion that period RMS hides.",
"",
"| scenario | abs RMS median (ns) | abs p-p median (ns) | "
"10 s wander p-p (ns) | period RMS (ns) | period max (ns) | unlocks |",
"|---|---:|---:|---:|---:|---:|---:|",
]
for item in current:
lines.append(
f"| {item.scenario} | {item.absolute_rms_median_ns:.2f} | "
f"{item.absolute_peak_to_peak_median_ns:.2f} | "
f"{item.wander_10s_peak_to_peak_median_ns:.2f} | "
f"{item.period_rms_median_ns:.2f} | "
f"{item.period_max_worst_ns:.2f} | {item.unlocks_total} |"
)
lines.extend(
[
"",
"The loop does not numerically oscillate or lose lock in these "
"runs. The scope-like symptom is low-frequency phase wander: it "
"can be tens of nanoseconds while one-second period jitter remains "
"below 20 ns. Therefore a `p95 period jitter < 20 ns` assertion is "
"not sufficient for an absolute ±20 ns requirement.",
"",
"## Linear stability",
"",
"The fixed-point coefficients give the following discrete poles "
"at the 200 ms navigation update rate:",
"",
"| profile | natural frequency | damping | poles | max magnitude |",
"|---|---:|---:|---|---:|",
]
)
profile_rows = [("acquire", ACQUIRE_PROFILE)] + [
(name, PROFILES[profile])
for name, profile in (
("precision", Profile.PRECISION),
("robust", Profile.ROBUST),
)
]
for name, profile in profile_rows:
poles = discrete_poles(profile)
pole_text = ", ".join(
f"{pole.real:.6f}{pole.imag:+.6f}j" for pole in poles
)
lines.append(
f"| {name} | {profile.natural_frequency_hz:.3f} Hz | "
f"{profile.damping:.2f} | {pole_text} | "
f"{max(abs(pole) for pole in poles):.6f} |"
)
lines.extend(
[
"",
"All magnitudes are below one. Profile changes preserve the PI "
"integrator, and the RTL snapshots Kp/Ki/max-slew/lock limits "
"together with the next commit, so the current acquire/precision/"
"robust switching is structurally sound. Stable individual modes "
"do not prove arbitrary-switching stability, but the asymmetric "
"8-update upgrade / 3-update downgrade hysteresis and the stress "
"runs show no coefficient chatter or unlock.",
"",
"## Dynamic parameter sweep",
"",
"| tuning | worst period max (ns) | median abs RMS over scenarios "
"(ns) | transitions | unlocks |",
"|---|---:|---:|---:|---:|",
]
)
for tuning in tuning_matrix():
subset = [item for item in results if item.tuning == tuning.name]
median_scenario_rms = statistics.median(
item.absolute_rms_median_ns for item in subset
)
lines.append(
f"| {tuning.name} | "
f"{max(item.period_max_worst_ns for item in subset):.2f} | "
f"{median_scenario_rms:.2f} | "
f"{sum(item.profile_transitions_total for item in subset)} | "
f"{sum(item.unlocks_total for item in subset)} |"
)
current_transition = next(
item for item in current if item.scenario == "quality_transitions"
)
guarded_transition = next(
item for item in guarded if item.scenario == "quality_transitions"
)
scheduled = [
item for item in results
if item.tuning == "quality_scheduled_drift"
]
lines.extend(
[
"",
"The safest small candidate is `robust_noise_guard`: retain the "
"current 0.035 Hz precision loop and 0.012 Hz robust loop, but "
"reduce only the robust-profile WLS residual gain from 1/32 to "
"1/64. In the synthetic quality-transition case its worst period "
f"step changed from {current_transition.period_max_worst_ns:.2f} "
f"to {guarded_transition.period_max_worst_ns:.2f} ns. This is a "
"candidate for hardware A/B testing, not enough evidence for a "
"production constant change.",
"",
"The best synthetic all-scenario period result is "
"`quality_scheduled_drift`: use drift gain 1/2 only with a "
"precision-quality Doppler solution, fall back to the current 1/4 "
"gain in robust quality, and combine that with the 1/64 robust "
"bias gain. Its worst period step was "
f"{max(item.period_max_worst_ns for item in scheduled):.2f} ns "
"with no unlocks. It is the most meaningful dynamic A/B candidate, "
"but the repository has no real drift-covariance or TIC samples "
"with which to validate the threshold.",
"",
"Narrowing precision to 0.012 Hz improves stationary noise but "
"lags even the explicit 0.05 ppm/120 s synthetic oscillator "
"variation. Widening it improves oscillator tracking but passes "
"more WLS/multipath motion. A geometry-only bandwidth scheduler "
"cannot resolve that tradeoff.",
"",
"## Recommended architecture update",
"",
"1. Keep current acquire/precision/robust PI profiles until real "
"TIC and oscillator-temperature data are available. Do not tune "
"from scope appearance alone.",
"2. Log the existing firmware status line at every solve during "
"validation: ABS_SAMPLE, REM_CODE_PHASE, capture sequence/counter, "
"TDOP/HDOP/GDOP, C/N0, pseudorange residual RMS, raw/filtered bias, "
"innovation, bias drift, hardware phase, rate, integrator, profile, "
"lock, FPGA/oscillator temperature, DUT edge and trusted edge.",
"3. A/B test the simulated scheduled filter first: precision "
"drift gain 1/2, robust drift gain 1/4, precision bias gain 1/32, "
"and robust bias gain 1/64. Longer term, use estimated phase/drift "
"covariance to interpolate those software gains within bounds. "
"Keep RTL PI bandwidth changes hysteretic and preserve its "
"integrator as today.",
"4. If temperature/frequency acceleration is the main source, add "
"oscillator temperature feed-forward or a phase/frequency/frequency-"
"drift estimator. Switching PI bandwidth alone either passes WLS "
"wander or lags the oscillator.",
"5. Define acceptance separately: calibrated mean offset, absolute "
"centered error/p-p wander, one-second period error, holdover, and "
"re-lock. A physical max-20 ns claim requires a common-clock TIC "
"run after cable/output-delay calibration; this synthetic test "
"cannot certify it.",
"",
]
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--duration", type=float, default=300.0)
parser.add_argument("--seeds", type=int, default=24)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).resolve().parent / "output/stability",
)
parser.add_argument("--no-write", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.duration < 120.0 or args.seeds < 1:
raise SystemExit("duration must be >= 120 s and seeds must be positive")
software_dir = Path(__file__).resolve().parents[1]
telemetry = load_real_telemetry(software_dir)
if not telemetry.gdop or not telemetry.tracked:
raise SystemExit("real GPS GDOP/satellite capture was not found")
epoch_result = run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_RTL,
postpone_tow_s=0.180,
)
)
analysis_start = max(60.0, args.duration / 3.0)
results: list[Aggregate] = []
telemetry_epochs: list[SyntheticEpoch] = []
for scenario in scenario_matrix():
seed_epochs = [
synthesize_epochs(
telemetry,
scenario,
duration_s=args.duration,
seed=seed,
)
for seed in range(1, args.seeds + 1)
]
if scenario.name == "scope_like_multipath":
telemetry_epochs = seed_epochs[0]
for tuning in tuning_matrix():
runs = [
measure_trace(
_run_loop(epochs, tuning), after_s=analysis_start
)
for epochs in seed_epochs
]
results.append(aggregate(scenario, tuning, runs))
print(
"scenario tuning abs_rms "
"abs_pp wander10 period_rms period_max unlock"
)
for item in results:
print(
f"{item.scenario:27s} {item.tuning:22s} "
f"{item.absolute_rms_median_ns:7.2f} "
f"{item.absolute_peak_to_peak_median_ns:7.2f} "
f"{item.wander_10s_peak_to_peak_median_ns:8.2f} "
f"{item.period_rms_median_ns:10.2f} "
f"{item.period_max_worst_ns:10.2f} "
f"{item.unlocks_total:6d}"
)
print(
"current RTL + 180 ms: DUT-trusted = "
f"{epoch_result.pps.edges[0].dut_minus_trusted_s * 1.0e9:.2f} ns"
)
if not args.no_write:
write_telemetry(args.output_dir / "synthetic_telemetry.csv", telemetry_epochs)
write_sweep(args.output_dir / "parameter_sweep.csv", results)
write_report(
args.output_dir / "stability_report.md",
telemetry,
results,
epoch_result,
)
print(f"wrote {args.output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,84 @@
from __future__ import annotations
import math
from pathlib import Path
import unittest
from pps_python.adaptive_pps import ACQUIRE_PROFILE, PROFILES, Profile
from pps_end_to_end_validation.stability_analysis import (
discrete_poles,
load_real_telemetry,
measure_trace,
scenario_matrix,
synthesize_epochs,
tuning_matrix,
_run_loop,
)
SOFTWARE_DIR = Path(__file__).resolve().parents[1]
class StabilityAnalysisTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.telemetry = load_real_telemetry(SOFTWARE_DIR)
def test_repository_capture_is_loaded(self):
self.assertEqual(len(self.telemetry.gdop), 989)
self.assertEqual(len(self.telemetry.tracked), 990)
self.assertEqual(set(self.telemetry.tracked), {8})
self.assertAlmostEqual(min(self.telemetry.gdop), 1.8593929921)
self.assertAlmostEqual(max(self.telemetry.gdop), 3.1537716348)
self.assertGreater(self.telemetry.horizontal_rms_m, 8.0)
self.assertLess(self.telemetry.horizontal_rms_m, 9.0)
def test_fixed_point_profiles_have_inside_unit_circle_poles(self):
for profile in (
ACQUIRE_PROFILE,
PROFILES[Profile.PRECISION],
PROFILES[Profile.ROBUST],
):
poles = discrete_poles(profile)
self.assertTrue(all(abs(pole) < 1.0 for pole in poles))
def test_synthetic_telemetry_exercises_wrap_and_physical_fields(self):
epochs = synthesize_epochs(
self.telemetry,
scenario_matrix()[0],
duration_s=120.0,
seed=1,
)
self.assertEqual(len(epochs), 600)
self.assertTrue(any(epochs[i].abs_sample < epochs[i - 1].abs_sample
for i in range(1, len(epochs))))
self.assertTrue(all(0 <= epoch.rem_code_phase <= 1_023_000
for epoch in epochs))
self.assertTrue(all(epoch.used_satellites == 8 for epoch in epochs))
self.assertTrue(all(math.isfinite(epoch.corrected_abs_sample)
for epoch in epochs))
self.assertTrue(all(epoch.capture_correction_ns >= 0.0
for epoch in epochs))
def test_current_loop_locks_without_unlock_in_board_scenario(self):
epochs = synthesize_epochs(
self.telemetry,
scenario_matrix()[0],
duration_s=120.0,
seed=4,
)
trace = _run_loop(epochs, tuning_matrix()[0])
metrics = measure_trace(trace, after_s=60.0)
self.assertLess(metrics.first_lock_s, 15.0)
self.assertEqual(metrics.unlocks, 0)
self.assertLessEqual(metrics.period_max_ns, 16.0 + 1.0e-9)
# Slow absolute motion is deliberately measured separately.
self.assertGreater(
metrics.absolute_peak_to_peak_ns,
metrics.period_max_ns,
)
if __name__ == "__main__":
unittest.main()
+61 -18
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
import math import math
from typing import Iterable from typing import Iterable, Mapping
TRACKING_HZ = 125_000_000 TRACKING_HZ = 125_000_000
@@ -242,17 +242,7 @@ class AdaptiveTrace:
def one_second_edge_jitter_rms(self, *, after_s: float = 0.0) -> float: def one_second_edge_jitter_rms(self, *, after_s: float = 0.0) -> float:
"""Return 1 s PPS-edge jitter including the 125 MHz output grid.""" """Return 1 s PPS-edge jitter including the 125 MHz output grid."""
selected = { selected = dict(self.integer_second_edge_errors(after_s=after_s))
round(time_s, 9): (
round(-phase_s * TRACKING_HZ) / TRACKING_HZ
)
for time_s, phase_s, locked in zip(
self.time_s, self.phase_error_s, self.locked
)
if locked
and time_s >= after_s
and abs(time_s - round(time_s)) < 1.0e-9
}
differences = [ differences = [
edge_error - selected[round(time_s - 1.0, 9)] edge_error - selected[round(time_s - 1.0, 9)]
for time_s, edge_error in selected.items() for time_s, edge_error in selected.items()
@@ -264,6 +254,31 @@ class AdaptiveTrace:
sum(value * value for value in differences) / len(differences) sum(value * value for value in differences) / len(differences)
) )
def integer_second_edge_errors(
self,
*,
after_s: float = 0.0,
locked_only: bool = True,
) -> list[tuple[float, float]]:
"""Return physical PPS edge error samples on the 125 MHz grid.
Period jitter alone hides low-frequency motion. Exposing the signed
edge error lets stability analyses report absolute RMS, peak-to-peak
wander and moving-average wander alongside cycle-to-cycle jitter.
"""
return [
(
round(time_s, 9),
round(-phase_s * TRACKING_HZ) / TRACKING_HZ,
)
for time_s, phase_s, locked in zip(
self.time_s, self.phase_error_s, self.locked
)
if (locked or not locked_only)
and time_s >= after_s
and abs(time_s - round(time_s)) < 1.0e-9
]
def phase_rms(self, *, after_s: float = 0.0) -> float: def phase_rms(self, *, after_s: float = 0.0) -> float:
values = [ values = [
phase_s phase_s
@@ -319,6 +334,10 @@ class FixedPointAdaptivePps:
software_filter: bool = True, software_filter: bool = True,
receiver_clock_bias_m: float = 1200.0, receiver_clock_bias_m: float = 1200.0,
receiver_clock_drift_mps: float = 475.0, receiver_clock_drift_mps: float = 475.0,
profiles: Mapping[Profile, LoopProfile] | None = None,
acquire_profile: LoopProfile | None = None,
software_drift_gain: float = 1.0 / 4.0,
software_fallback_drift_gain: float = 1.0 / 16.0,
) -> None: ) -> None:
self.update_period_s = update_period_s self.update_period_s = update_period_s
self.oscillator_fraction = oscillator_ppm * 1.0e-6 self.oscillator_fraction = oscillator_ppm * 1.0e-6
@@ -326,9 +345,24 @@ class FixedPointAdaptivePps:
self.software_filter = software_filter self.software_filter = software_filter
self.receiver_clock_bias_m = receiver_clock_bias_m self.receiver_clock_bias_m = receiver_clock_bias_m
self.receiver_clock_drift_mps = receiver_clock_drift_mps self.receiver_clock_drift_mps = receiver_clock_drift_mps
self.profiles = dict(PROFILES if profiles is None else profiles)
missing_profiles = set(Profile) - self.profiles.keys()
if missing_profiles:
raise ValueError(f"missing loop profiles: {sorted(missing_profiles)}")
self.acquire_profile = (
ACQUIRE_PROFILE if acquire_profile is None else acquire_profile
)
if not 0.0 < software_drift_gain <= 1.0:
raise ValueError("software_drift_gain must be in (0, 1]")
if not 0.0 < software_fallback_drift_gain <= 1.0:
raise ValueError(
"software_fallback_drift_gain must be in (0, 1]"
)
self.software_drift_gain = software_drift_gain
self.software_fallback_drift_gain = software_fallback_drift_gain
self.selector = QualitySelector() self.selector = QualitySelector()
self.profile = Profile.HOLDOVER if adaptive else Profile.PRECISION self.profile = Profile.HOLDOVER if adaptive else Profile.PRECISION
self.parameters = PROFILES[self.profile] self.parameters = self.profiles[self.profile]
self.kp_q46, self.ki_q46, self.max_slew_q32 = ( self.kp_q46, self.ki_q46, self.max_slew_q32 = (
self.parameters.fixed_coefficients(update_period_s) self.parameters.fixed_coefficients(update_period_s)
) )
@@ -376,10 +410,10 @@ class FixedPointAdaptivePps:
def _select_loop_parameters(self) -> None: def _select_loop_parameters(self) -> None:
parameters = ( parameters = (
ACQUIRE_PROFILE self.acquire_profile
if self.adaptive and if self.adaptive and
self.profile != Profile.HOLDOVER and not self.locked self.profile != Profile.HOLDOVER and not self.locked
else PROFILES[self.profile] else self.profiles[self.profile]
) )
if parameters == self.parameters: if parameters == self.parameters:
return return
@@ -541,15 +575,16 @@ class FixedPointAdaptivePps:
+ self.parameters.software_bias_gain * innovation_m + self.parameters.software_bias_gain * innovation_m
) )
if observation.clock_drift_valid: if observation.clock_drift_valid:
self.software_clock_drift_mps += 0.25 * ( self.software_clock_drift_mps += self.software_drift_gain * (
measured_drift_mps - self.software_clock_drift_mps measured_drift_mps - self.software_clock_drift_mps
) )
else: else:
if self.software_updates < self.parameters.software_settle_updates: if self.software_updates < self.parameters.software_settle_updates:
self.software_clock_drift_mps = fallback_drift_mps self.software_clock_drift_mps = fallback_drift_mps
else: else:
self.software_clock_drift_mps += (1.0 / 16.0) * ( self.software_clock_drift_mps += (
fallback_drift_mps - self.software_clock_drift_mps self.software_fallback_drift_gain *
(fallback_drift_mps - self.software_clock_drift_mps)
) )
self.software_previous_measurement_m = measured_bias_m self.software_previous_measurement_m = measured_bias_m
self.software_error_s = -( self.software_error_s = -(
@@ -705,6 +740,10 @@ def run_observations(
software_filter: bool = True, software_filter: bool = True,
receiver_clock_bias_m: float = 1200.0, receiver_clock_bias_m: float = 1200.0,
receiver_clock_drift_mps: float = 475.0, receiver_clock_drift_mps: float = 475.0,
profiles: Mapping[Profile, LoopProfile] | None = None,
acquire_profile: LoopProfile | None = None,
software_drift_gain: float = 1.0 / 4.0,
software_fallback_drift_gain: float = 1.0 / 16.0,
) -> AdaptiveTrace: ) -> AdaptiveTrace:
model = FixedPointAdaptivePps( model = FixedPointAdaptivePps(
oscillator_ppm=oscillator_ppm, oscillator_ppm=oscillator_ppm,
@@ -712,6 +751,10 @@ def run_observations(
software_filter=software_filter, software_filter=software_filter,
receiver_clock_bias_m=receiver_clock_bias_m, receiver_clock_bias_m=receiver_clock_bias_m,
receiver_clock_drift_mps=receiver_clock_drift_mps, receiver_clock_drift_mps=receiver_clock_drift_mps,
profiles=profiles,
acquire_profile=acquire_profile,
software_drift_gain=software_drift_gain,
software_fallback_drift_gain=software_fallback_drift_gain,
) )
for observation in observations: for observation in observations:
model.update(observation) model.update(observation)