Compare commits

..

3 Commits

23 changed files with 4789 additions and 953 deletions
+8 -4
View File
@@ -954,7 +954,9 @@ olarak yayinlanir.
Ionosphere correction receiver azimut/elevasyonu hazir oldugunda WLS icinde
uygulanir.
- Ephemeris alanlarinin mevcut isimleri ile ICD anlamlari su sekildedir: `omgdot` gercekte `Omega0`, `omg0` gercekte argument of perigee `omega`, `omgkdot` gercekte `OmegaDot` degeridir. `satpos()` bunlari dogru matematiksel yerde kullanir; konu su an hesap hatasi degil, ileride yapilacak kontrollu isim duzeltmesidir.
- `POSTPONE_TOW=0.160` donanim/pipeline zaman kalibrasyonunun bir parcasidir; kaynagi dogrulanmadan kaldirilmamalidir.
- `POSTPONE_TOW=0.180` mevcut RTL'nin dokuzuncu takip eden navigation bitinde
yaptigi teslimi duzeltir. `satpos()` ve PPS receive-time anchor bu degeri
tam bir kez ve ayni firmware surumunde kullanir.
- Ilk nav-solve interrupt'i geldiginde o anda allocated, hardware busy ve nav-solve interrupt'i etkin olan kanallar epoch'un sabit bekleme maskesine alinir. Unlock interrupt'i gelen kanal artik tracked olmadigi icin bekleme maskesinden cikarilir; epoch ortasinda navigation-ready olan yeni kanal ise aktif maskeyi buyutmez ve sonraki epoch'tan itibaren beklenir. Kalan beklenen kanallarin tamami gelirse snapshot son interrupt'ta hemen yayinlanir. 30 ms holdoff dolarsa veya daha yeni fiziksel epoch baslarsa eksik eski epoch WLS calistirilmadan discard edilir; eski ve yeni olcumler birlestirilmez.
- Interrupt bekleme maskesi tamamlandiktan sonra frozen kanallarin tumu ortak TOW/`ABS_SAMPLE` kumesinde degilse epoch reddedilir. Selector'in buldugu daha kucuk alt kume yalniz diagnostic bilgidir ve WLS girdisi olamaz.
- Dort uydu 3D konum + receiver clock bias icin minimum ve yeterlidir. Bu durumda
@@ -1348,9 +1350,11 @@ Bu timer/scheduler yavasligi degildir. Firmware start ediyor; tamamlanma icin RF
T_rx = TOW_ref + POSTPONE_TOW + STARTOFFSET/1000 - b/c
```
- `POSTPONE_TOW=0.160 s`, takip eden subframe'dan parity icin beklenen sekiz
bitin gecikmesidir ve yazilim tarafinda tam bir kez uygulanir. PPS RTL bu
degeri tekrar eklemez.
- `POSTPONE_TOW=0.180 s`, mevcut tracking RTL'nin parity kontrolunu dokuzuncu
takip eden bitte tamamlamasinin gecikmesidir ve yazilim tarafinda tam bir kez
uygulanir. PPS RTL bu degeri tekrar eklemez. Tracking RTL sekizinci bitte
validate edecek sekilde degistirilirse bu sabit ayni release'de 160 ms'ye
geri alinmalidir.
- Bu GPS zamani, ayni kalici referans kanalinin 125 MHz capture counter'i ile atomik
APB mailbox uzerinden eslenir. Ilk cozum coarse-align, sonraki cozumler Q2.46
dusuk bant genislikli PI rate discipline yapar. WLS/APB calisma suresi faz
@@ -174,7 +174,10 @@
// gps defines from plutogpssim.
#define POSTPONE_TOW (double)(0.160)
/* Current tracking RTL validates the subframe on the ninth following
* navigation bit: 9 * 20 ms = 180 ms. satpos() and the PPS receive-time
* anchor must use this same delivery-epoch correction exactly once. */
#define POSTPONE_TOW (double)(0.180)
#define MAX_SAT (12) // 32 (TODO. OBSOLATED)
#define SECONDS_IN_WEEK 604800.0
@@ -0,0 +1,176 @@
# GPS PPS end-to-end Linux validation
This package is a deterministic, causal validation of the project timing path
from synthetic GPS LNAV data to the physical PPS edge. It exists separately
from `pps_python`, whose models concentrate on WLS noise, oscillator behavior,
and adaptive-loop performance.
The validation does not use Linux wall-clock scheduling. The 1 kHz, 50 bit/s,
RF-sample, PCLK, and 125 MHz events advance from synthetic timestamps, making
the signed absolute-time result repeatable on a normal Linux host.
## What is modeled
The model follows the current firmware and RTL conventions:
1. It generates deterministic GPS LNAV subframes with D29*/D30* data inversion
and valid parity for all ten 30-bit words.
2. Each navigation bit is expanded to twenty 1 kHz prompt-sign samples.
3. The `bit_gen.sv` 40 ms `1 -> 0` transition acquisition, majority decision,
20 ms `bit_valid` cadence, and registered ABS_SAMPLE behavior are applied.
4. The tracking wrapper's chronological `2 + 300 + 8 = 310` bit window,
preamble search, parity check, `load_sigs`, and subframe interrupt ordering
are applied.
5. `nav_solve_timer` is initialized from HOW Z-count and counts later bit-valid
events. Navigation-solve interrupt, PPS counter capture, capture sequence,
and a 32-bit RF ABS_SAMPLE wrap are exercised.
6. Firmware reconstructs periodic TOW from the HOW/sample anchor and evaluates
`TOW + POSTPONE_TOW + STARTOFFSET - b/c`.
7. The timestamped 125 MHz PPS peripheral performs coarse/normal mapping,
ignores synthetic CPU/APB delay as phase, acquires lock after five valid
updates, and generates integer-second edges.
8. DUT edges are compared with an independent trusted PPS. Optional DUT and
trusted-output delays make board, cable, and vendor delay sign explicit.
This is a digital epoch-path validation, not an RF waveform simulator. The
existing `pps_python` tests continue to cover WLS, oscillator, PI, adaptive
quality, and holdover behavior.
## Run
From `soc/software`:
```bash
python3 -m pps_end_to_end_validation.pps_end_to_end
python3 -m unittest pps_end_to_end_validation/test_pps_end_to_end.py
```
The default command executes the diagnostic matrix:
```text
scenario bits correction reference error DUT - trusted PPS
current-rtl/180ms 9 180 ms -0.000032 ms +0.000032 ms
current-rtl/160ms 9 160 ms -20.000032 ms +20.000032 ms
current-bit/160ms 8 160 ms -0.000032 ms +0.000032 ms
```
Positive `DUT - trusted PPS` means the project output is late. The default
32 ns remainder is the explicitly configured four-tick PCLK capture pipeline;
it is not part of the LNAV-bit mismatch.
Run one scenario or obtain machine-readable output:
```bash
python3 -m pps_end_to_end_validation.pps_end_to_end \
--mode current-rtl --postpone-ms 160 --json
python3 -m pps_end_to_end_validation.pps_end_to_end \
--mode current-bit \
--dut-output-delay-ns 12 \
--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.
The stability analysis does not alter loop constants. Production firmware now
uses the current-RTL-compatible 180 ms epoch correction described below.
## 20 ms diagnosis
The current tracking wrapper shifts the current bit into `shift_reg_n`, while
its preamble/parity predicate reads the preceding `shift_reg_p`. On the eighth
following preamble bit, the validator therefore sees only the previous seven
bits. The register contains all eight after the edge, but the bit-valid edge
condition has ended. Validation occurs on the ninth bit, 20 ms later.
The implemented interface is consequently:
```text
current RTL delivery 9 * 20 ms = 180 ms
production POSTPONE_TOW 9 * 20 ms = 180 ms
signed epoch mismatch 0 ms
former firmware value 8 * 20 ms = 160 ms
former signed mismatch 20 ms
```
Production firmware now uses `POSTPONE_TOW=0.180` as the current-RTL-compatible
correction, consistently in both satellite-state and PPS calculations. It is
180 ms, not 190 ms. If RTL is later changed to validate a candidate window
containing the current eighth bit, firmware must return to 160 ms in the same
coordinated release.
The validation intentionally does not modify production RTL or firmware.
## Other regression coverage
The unit tests verify:
- parity-valid LNAV round-trip and rejection after one corrupted data bit;
- 1 kHz prompt-sign to 50 bit/s generation and the registered 1 ms sample lag;
- ninth-bit delivery in current RTL and eighth-bit delivery in the candidate
implementation;
- constant signed PPS error over successive physical edges;
- 180 ms firmware compatibility with current RTL;
- invariance to synthetic CPU/APB stalls when capture timestamps are used;
- DUT-versus-trusted output-delay sign; and
- rejection behavior for the firmware-permitted `NAV_SOLVE_TIMER=40` case.
The `N=40` case divides the GPS week but not a 300-bit subframe. When firmware
refreshes the HOW/sample anchor after six seconds, the timer phase is half a
solve period away from that anchor and the current `N * 20 ms` snap rejects
otherwise valid solves. Production `N=10` divides 300 and is unaffected.
If general timer periods are required, reconstruct elapsed time on the
fundamental 20 ms navigation-bit grid. A narrower compatibility restriction is
to accept only periods for which `300 % N == 0`.
## Hardware confirmation
Before selecting a production correction:
1. Probe `bit_valid`, the final preamble bits, `preamble_ok`, parity result, and
`load_sigs` with ILA.
2. Count whether delivery is on following bit 8 or 9 in the flashed bitstream.
3. Measure DUT and trusted PPS with a common time-interval counter and record
the sign. The diagnosis predicts a DUT edge approximately 20 ms late.
4. Apply only nanosecond-scale output/cable calibration after the epoch
mismatch is removed.
@@ -0,0 +1 @@
"""End-to-end Linux validation for the GPS LNAV-to-PPS timing path."""
@@ -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.
- Production firmware and this analysis both define `POSTPONE_TOW` as 180 ms for the current RTL delivery semantics.
## 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,874 @@
"""Causal Linux model of the GPS LNAV-to-PPS timing path.
This module is intentionally narrower than a general GNSS simulator. It
models the project boundary which can create a constant absolute-time error:
* parity-correct 30-bit GPS LNAV words, including D30* data inversion;
* the 1 kHz prompt-sign transition detector and 20 ms bit-valid generator;
* the tracking wrapper's 310-bit subframe/preamble search window;
* current registered-window and proposed current-bit-aware validation;
* subframe-ready and periodic navigation-solve interrupts;
* RF ABS_SAMPLE anchoring and the timestamped 125 MHz PPS capture;
* the firmware HOW + POSTPONE_TOW + STARTOFFSET - b/c mapping; and
* coarse/normal PPS commits followed by comparison with an independent PPS.
Long stretches between 50 bit/s events are evaluated analytically. No result
depends on the Linux scheduler or wall clock.
"""
from __future__ import annotations
import argparse
import json
import math
from collections import deque
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Iterable, Sequence
C = 299_792_458.0
PREAMBLE = 0x8B
LNAV_WORD_BITS = 30
LNAV_DATA_BITS = 24
LNAV_WORDS_PER_SUBFRAME = 10
LNAV_SUBFRAME_BITS = LNAV_WORD_BITS * LNAV_WORDS_PER_SUBFRAME
LNAV_BIT_PERIOD_S = 0.020
LNAV_BITS_PER_SUBFRAME = 300
LNAV_WINDOW_BITS = 2 + LNAV_SUBFRAME_BITS + 8
TRACKING_INTEGRATION_PERIOD_S = 0.001
TRACKING_INTEGRATIONS_PER_BIT = 20
RF_HZ = 16_367_981.0
PPS_HZ = 125_000_000.0
START_OFFSET_S = 68.802e-3
NAV_TOW_SNAP_TOLERANCE_S = 0.005
Q32 = 1 << 32
TICK_INTEGER_TOLERANCE = 0.01
class DecoderMode(str, Enum):
"""Subframe search ordering relative to the current bit-valid event."""
CURRENT_RTL = "current-rtl"
CURRENT_BIT = "current-bit"
def _bits(value: int, width: int) -> list[int]:
return [(value >> shift) & 1 for shift in range(width - 1, -1, -1)]
def _integer(bits: Iterable[int]) -> int:
value = 0
for bit in bits:
value = (value << 1) | int(bit)
return value
def _xor(values: Iterable[int]) -> int:
result = 0
for value in values:
result ^= int(value)
return result
def lnav_parity(data_bits: Sequence[int], previous_d29: int, previous_d30: int) -> tuple[int, ...]:
"""Return transmitted D25*..D30* using the equations in tracking_wrapper.
``data_bits`` are normalized D1..D24. The equations below are a direct
index-form transcription of ``parity_calc`` in tracking_wrapper.sv.
"""
if len(data_bits) != LNAV_DATA_BITS:
raise ValueError("an LNAV word must contain 24 data bits")
d = [0] + [int(bit) for bit in data_bits] # ICD bit numbers are one based.
return (
_xor((previous_d29, d[1], d[2], d[3], d[5], d[6], d[10], d[11],
d[12], d[13], d[14], d[17], d[18], d[20], d[23])),
_xor((previous_d30, d[2], d[3], d[4], d[6], d[7], d[11], d[12],
d[13], d[14], d[15], d[18], d[19], d[21], d[24])),
_xor((previous_d29, d[1], d[3], d[4], d[5], d[7], d[8], d[12],
d[13], d[14], d[15], d[16], d[19], d[20], d[22])),
_xor((previous_d30, d[2], d[4], d[5], d[6], d[8], d[9], d[13],
d[14], d[15], d[16], d[17], d[20], d[21], d[23])),
_xor((previous_d30, d[1], d[3], d[5], d[6], d[7], d[9], d[10],
d[14], d[15], d[16], d[17], d[18], d[21], d[22], d[24])),
_xor((previous_d29, d[3], d[5], d[6], d[8], d[9], d[10], d[11],
d[13], d[15], d[19], d[22], d[23], d[24])),
)
def encode_lnav_word(
data: int, previous_d29: int, previous_d30: int
) -> tuple[tuple[int, ...], int, int, int]:
"""Encode one normalized 24-bit word and return transmitted bits/state."""
if data < 0 or data >= (1 << LNAV_DATA_BITS):
raise ValueError("LNAV data does not fit in 24 bits")
normalized = _bits(data, LNAV_DATA_BITS)
parity = lnav_parity(normalized, previous_d29, previous_d30)
transmitted_data = tuple(bit ^ previous_d30 for bit in normalized)
transmitted = transmitted_data + parity
normalized_word = (data << 6) | _integer(parity)
return transmitted, parity[4], parity[5], normalized_word
@dataclass(frozen=True)
class EncodedSubframe:
z_count: int
subframe_id: int
transmitted_bits: tuple[int, ...]
normalized_words: tuple[int, ...]
ending_d29: int
ending_d30: int
def encode_lnav_subframe(
z_count: int,
subframe_id: int,
previous_d29: int,
previous_d30: int,
*,
week: int = 2200,
) -> EncodedSubframe:
"""Generate a deterministic, parity-valid LNAV subframe.
The TLM preamble, HOW Z-count/subframe ID, and the modulo-1024 week field
used by this firmware are placed in their implemented bit positions. The
remaining data is deterministic filler so all ten parity checks are live.
"""
if not 0 <= z_count < (1 << 17):
raise ValueError("HOW Z-count must fit in 17 bits")
if not 1 <= subframe_id <= 5:
raise ValueError("subframe ID must be 1..5")
data_words = [0] * LNAV_WORDS_PER_SUBFRAME
data_words[0] = PREAMBLE << 16
data_words[1] = (z_count << 7) | (subframe_id << 2)
if subframe_id == 1:
data_words[2] = (week & 0x3FF) << 14
for index in range(2 if subframe_id != 1 else 3, LNAV_WORDS_PER_SUBFRAME):
# Avoid changing the semantic fields above while exercising parity
# with a stable, non-zero bit pattern.
data_words[index] = (
(0x5A_0000 ^ (z_count * 0x45D9F3B) ^ (subframe_id << 12) ^ index)
& 0xFF_FFFF
)
emitted: list[int] = []
normalized_words: list[int] = []
d29, d30 = int(previous_d29), int(previous_d30)
for data in data_words:
word, d29, d30, normalized_word = encode_lnav_word(data, d29, d30)
emitted.extend(word)
normalized_words.append(normalized_word)
return EncodedSubframe(
z_count=z_count,
subframe_id=subframe_id,
transmitted_bits=tuple(emitted),
normalized_words=tuple(normalized_words),
ending_d29=d29,
ending_d30=d30,
)
@dataclass(frozen=True)
class DecodedSubframe:
z_count: int
subframe_id: int
normalized_words: tuple[int, ...]
def validate_rtl_window(window: Sequence[int]) -> DecodedSubframe | None:
"""Mirror the RTL preamble and ten-word parity predicate.
The chronological window is ``previous D29*, D30*`` followed by the
300-bit candidate subframe and eight bits of the following preamble.
"""
if len(window) != LNAV_WINDOW_BITS:
return None
preamble = tuple(_bits(PREAMBLE, 8))
inverse_preamble = tuple(bit ^ 1 for bit in preamble)
first = tuple(window[2:10])
following = tuple(window[302:310])
if first not in (preamble, inverse_preamble):
return None
if following not in (preamble, inverse_preamble):
return None
d29, d30 = int(window[0]), int(window[1])
normalized_words: list[int] = []
for word_index in range(LNAV_WORDS_PER_SUBFRAME):
start = 2 + word_index * LNAV_WORD_BITS
transmitted = tuple(int(bit) for bit in window[start : start + LNAV_WORD_BITS])
normalized_data = tuple(bit ^ d30 for bit in transmitted[:LNAV_DATA_BITS])
expected_parity = lnav_parity(normalized_data, d29, d30)
if tuple(transmitted[LNAV_DATA_BITS:]) != expected_parity:
return None
normalized_words.append((_integer(normalized_data) << 6) | _integer(expected_parity))
d29, d30 = transmitted[28], transmitted[29]
how_word = normalized_words[1]
return DecodedSubframe(
z_count=(how_word >> 13) & 0x1FFFF,
subframe_id=(how_word >> 8) & 0x7,
normalized_words=tuple(normalized_words),
)
@dataclass(frozen=True)
class SampleStamp:
raw: int
corrected: float
@dataclass(frozen=True)
class BitEvent:
stream_index: int
value: int
transmit_time_s: float
receive_time_s: float
sample_time_s: float
sample: SampleStamp
@dataclass(frozen=True)
class PromptSample:
value: int
receive_time_s: float
sample: SampleStamp
class BitGeneratorModel:
"""Mirror bit_gen.sv at its 1 kHz valid-input clock enables."""
def __init__(self, *, propagation_s: float):
self.propagation_s = propagation_s
self.prompt_history: deque[int] = deque([0] * 40, maxlen=40)
self.record_bits = False
self.loop_counter = 0
self.prompt_ones = 0
self.absolute_sample: SampleStamp | None = None
self.absolute_sample_time_s = 0.0
self.output_index = 0
def feed(self, prompt: PromptSample) -> BitEvent | None:
bit_transition = tuple(self.prompt_history) == (
(1,) * TRACKING_INTEGRATIONS_PER_BIT
+ (0,) * TRACKING_INTEGRATIONS_PER_BIT
)
loop_enabled = self.record_bits
loop_full = self.loop_counter == TRACKING_INTEGRATIONS_PER_BIT - 1
bit_value = int(self.prompt_ones > TRACKING_INTEGRATIONS_PER_BIT / 2 - 1)
sampled_before_edge = self.absolute_sample
sample_time_before_edge = self.absolute_sample_time_s
# All assignments in bit_gen are nonblocking: the output on a full
# loop sees the ABS_SAMPLE registered by the preceding 1 ms input.
self.prompt_history.append(prompt.value)
self.absolute_sample = prompt.sample
self.absolute_sample_time_s = prompt.receive_time_s
if bit_transition:
self.record_bits = True
if loop_enabled:
if loop_full:
self.loop_counter = 0
self.prompt_ones = 0
else:
self.loop_counter += 1
self.prompt_ones += prompt.value
if not loop_full or sampled_before_edge is None:
return None
event = BitEvent(
stream_index=self.output_index,
value=bit_value,
transmit_time_s=prompt.receive_time_s - self.propagation_s,
receive_time_s=prompt.receive_time_s,
sample_time_s=sample_time_before_edge,
sample=sampled_before_edge,
)
self.output_index += 1
return event
@dataclass(frozen=True)
class SubframeInterrupt:
z_count: int
subframe_id: int
words: tuple[int, ...]
bit_index_after_how: int
sample_time_s: float
irq_time_s: float
sample: SampleStamp
class TrackingDecoderModel:
"""Event-level model of tracking_wrapper's shift/search/IRQ ordering."""
def __init__(self, mode: DecoderMode, pps_hz: float = PPS_HZ):
self.mode = DecoderMode(mode)
self.pps_hz = pps_hz
self.history: deque[int] = deque(maxlen=LNAV_WINDOW_BITS)
def feed(self, event: BitEvent) -> SubframeInterrupt | None:
registered = tuple(self.history)
if self.mode == DecoderMode.CURRENT_RTL:
decoded = validate_rtl_window(registered)
self.history.append(event.value)
else:
candidate = (registered + (event.value,))[-LNAV_WINDOW_BITS:]
decoded = validate_rtl_window(candidate)
self.history.append(event.value)
if decoded is None:
return None
how_boundary = decoded.z_count * 6.0
bit_index = int(round((event.transmit_time_s - how_boundary) / LNAV_BIT_PERIOD_S))
# load_sigs is edge-detected before gps_frame_ready. This two-PCLK
# delay affects interrupt latency but not the latched ABS_SAMPLE.
irq_time = event.receive_time_s + 2.0 / self.pps_hz
return SubframeInterrupt(
z_count=decoded.z_count,
subframe_id=decoded.subframe_id,
words=decoded.normalized_words,
bit_index_after_how=bit_index,
sample_time_s=event.sample_time_s,
irq_time_s=irq_time,
sample=event.sample,
)
@dataclass(frozen=True)
class CounterClock:
nominal_hz: float
epoch_time_s: float
oscillator_ppm: float = 0.0
@property
def physical_hz(self) -> float:
return self.nominal_hz * (1.0 + self.oscillator_ppm * 1.0e-6)
def first_tick_at_or_after(self, time_s: float) -> int:
exact = (time_s - self.epoch_time_s) * self.physical_hz
nearest = round(exact)
# Absolute GPS TOW is intentionally retained in the model. Subtracting
# two ~345 ks binary64 values can leave a few thousandths of one 8 ns
# tick even when the synthetic event was constructed exactly on that
# tick. Snap only this numerical residue; genuinely asynchronous
# events still use the following hardware clock edge.
if abs(exact - nearest) <= TICK_INTEGER_TOLERANCE:
exact = float(nearest)
return max(0, int(math.ceil(exact)))
def time_for_tick(self, tick: int) -> float:
return self.epoch_time_s + tick / self.physical_hz
@dataclass(frozen=True)
class NavSolveInterrupt:
sample_time_s: float
irq_time_s: float
bit_index_after_how: int
sample: SampleStamp
pps_counter: int
pps_capture_time_s: float
pps_capture_sequence: int
class NavSolveTimerModel:
"""Mirror the current timer: seed from Z*300, then count later bit pulses."""
def __init__(
self,
period_bits: int,
subframe: SubframeInterrupt,
clock: CounterClock,
*,
capture_pipeline_ticks: int,
):
if period_bits <= 0:
raise ValueError("navigation solve period must be positive")
self.period_bits = period_bits
self.counter = (subframe.z_count * LNAV_BITS_PER_SUBFRAME) % period_bits
self.start_sample_time_s = subframe.sample_time_s
self.how_boundary_s = subframe.z_count * 6.0
self.clock = clock
self.capture_pipeline_ticks = capture_pipeline_ticks
self.sequence = 0
def feed(self, event: BitEvent) -> NavSolveInterrupt | None:
if event.sample_time_s <= self.start_sample_time_s:
return None
reached = self.counter == self.period_bits - 1
self.counter = 0 if reached else self.counter + 1
if not reached:
return None
bit_tick = self.clock.first_tick_at_or_after(event.receive_time_s)
capture_tick = bit_tick + self.capture_pipeline_ticks
self.sequence += 1
return NavSolveInterrupt(
sample_time_s=event.sample_time_s,
irq_time_s=self.clock.time_for_tick(capture_tick + 1),
bit_index_after_how=int(
round((event.transmit_time_s - self.how_boundary_s) / LNAV_BIT_PERIOD_S)
),
sample=event.sample,
pps_counter=capture_tick,
pps_capture_time_s=self.clock.time_for_tick(capture_tick),
pps_capture_sequence=self.sequence,
)
@dataclass(frozen=True)
class FirmwareReference:
nav_bit_index_after_how: int
periodic_elapsed_s: float
transmit_tow_s: float
gps_time_at_capture_s: float
gps_time_at_capture_q32_s: float
true_capture_time_s: float
signed_reference_error_s: float
epoch_counter: int
class FirmwareTimingModel:
"""Mirror HOW sample anchoring and the current PPS receiver-time equation."""
def __init__(
self,
*,
nav_solve_period_bits: int,
postpone_tow_s: float,
propagation_s: float,
):
self.event_period_s = nav_solve_period_bits * LNAV_BIT_PERIOD_S
self.postpone_tow_s = postpone_tow_s
self.propagation_s = propagation_s
# In this project's WLS datum STARTOFFSET - b/c recovers flight time.
self.synthetic_clock_bias_m = (START_OFFSET_S - propagation_s) * C
self.anchor: SubframeInterrupt | None = None
self.rejected_nav_solves = 0
def on_subframe(self, event: SubframeInterrupt) -> None:
self.anchor = event
@staticmethod
def _signed_u32_delta(current: int, previous: int) -> int:
delta = (current - previous) & 0xFFFF_FFFF
return delta - (1 << 32) if delta & 0x8000_0000 else delta
def on_nav_solve(self, event: NavSolveInterrupt) -> FirmwareReference | None:
if self.anchor is None:
raise RuntimeError("navigation solve arrived before a valid HOW anchor")
raw_delta = self._signed_u32_delta(event.sample.raw, self.anchor.sample.raw)
anchor_fraction = self.anchor.sample.corrected - self.anchor.sample.raw
event_fraction = event.sample.corrected - event.sample.raw
elapsed_s = (raw_delta + event_fraction - anchor_fraction) / RF_HZ
event_index = int(math.floor(elapsed_s / self.event_period_s + 0.5))
periodic_elapsed_s = event_index * self.event_period_s
if abs(periodic_elapsed_s - elapsed_s) > NAV_TOW_SNAP_TOLERANCE_S:
# gps_time_from_periodic_sample_anchor() returns false and the
# interrupt handler drops this otherwise coherent NAV solve.
self.rejected_nav_solves += 1
return None
transmit_tow = self.anchor.z_count * 6.0 + periodic_elapsed_s
gps_reference = (
transmit_tow
+ self.postpone_tow_s
+ START_OFFSET_S
- self.synthetic_clock_bias_m / C
)
whole = math.floor(gps_reference)
fraction_q32 = int(math.floor((gps_reference - whole) * Q32 + 0.5))
if fraction_q32 == Q32:
whole += 1
fraction_q32 = 0
gps_reference_q32 = whole + fraction_q32 / Q32
return FirmwareReference(
nav_bit_index_after_how=event.bit_index_after_how,
periodic_elapsed_s=periodic_elapsed_s,
transmit_tow_s=transmit_tow,
gps_time_at_capture_s=gps_reference,
gps_time_at_capture_q32_s=gps_reference_q32,
true_capture_time_s=event.pps_capture_time_s,
signed_reference_error_s=gps_reference_q32 - event.pps_capture_time_s,
epoch_counter=event.pps_counter,
)
@dataclass(frozen=True)
class PpsEdge:
gps_second: int
dut_edge_time_s: float
trusted_edge_time_s: float
dut_minus_trusted_s: float
@dataclass(frozen=True)
class PpsCoreResult:
normal_commits_to_lock: int
lock_time_s: float
commit_age_s: float
edges: tuple[PpsEdge, ...]
def simulate_pps_core(
references: Sequence[FirmwareReference],
clock: CounterClock,
*,
compute_delay_s: float,
core_pipeline_ticks: int,
lock_updates: int,
dut_output_delay_s: float,
trusted_output_delay_s: float,
) -> PpsCoreResult:
"""Model timestamped coarse/normal commits and nominal locked PPS edges."""
if len(references) < lock_updates + 1:
raise RuntimeError("not enough navigation references to acquire PPS lock")
coarse = references[0]
mapping_counter = coarse.epoch_counter
mapping_time = coarse.gps_time_at_capture_q32_s
good_updates = 0
lock_reference: FirmwareReference | None = None
for reference in references[1:]:
mapped_at_capture = mapping_time + (
reference.epoch_counter - mapping_counter
) / clock.nominal_hz
phase_error = reference.gps_time_at_capture_q32_s - mapped_at_capture
if abs(phase_error) <= 8.0 / clock.nominal_hz:
good_updates += 1
else:
good_updates = 0
if good_updates >= lock_updates:
lock_reference = reference
break
if lock_reference is None:
raise RuntimeError("PPS loop did not acquire lock")
commit_time = lock_reference.true_capture_time_s + compute_delay_s
commit_tick = clock.first_tick_at_or_after(commit_time) + core_pipeline_ticks
lock_time = clock.time_for_tick(commit_tick)
commit_age_s = lock_time - lock_reference.true_capture_time_s
lock_counter = commit_tick
mapped_at_lock = mapping_time + (lock_counter - mapping_counter) / clock.nominal_hz
edges: list[PpsEdge] = []
target_second = int(math.floor(mapped_at_lock)) + 1
for _ in range(3):
ticks_exact = (target_second - mapping_time) * clock.nominal_hz
nearest_tick = round(ticks_exact)
if abs(ticks_exact - nearest_tick) <= TICK_INTEGER_TOLERANCE:
ticks_exact = float(nearest_tick)
ticks_from_mapping = int(math.ceil(ticks_exact))
edge_tick = mapping_counter + ticks_from_mapping
dut_edge = clock.time_for_tick(edge_tick) + dut_output_delay_s
trusted_edge = target_second + trusted_output_delay_s
edges.append(
PpsEdge(
gps_second=target_second,
dut_edge_time_s=dut_edge,
trusted_edge_time_s=trusted_edge,
dut_minus_trusted_s=dut_edge - trusted_edge,
)
)
target_second += 1
return PpsCoreResult(
normal_commits_to_lock=good_updates,
lock_time_s=lock_time,
commit_age_s=commit_age_s,
edges=tuple(edges),
)
@dataclass(frozen=True)
class EndToEndConfig:
decoder_mode: DecoderMode = DecoderMode.CURRENT_RTL
postpone_tow_s: float = 0.180
nav_solve_period_bits: int = 10
propagation_s: float = 0.070
pps_capture_pipeline_ticks: int = 4
firmware_compute_delay_s: float = 0.010
core_pipeline_ticks: int = 12
dut_output_delay_s: float = 0.0
trusted_output_delay_s: float = 0.0
first_z_count: int = 57_600
subframes: int = 3
# Put the first HOW capture just before a modulo-32 wrap so the first
# periodic solve also exercises the firmware's signed unwrap rule.
rf_initial_sample: int = (
(1 << 32) - int(10.25 * RF_HZ) - 1_000
)
@dataclass(frozen=True)
class EndToEndResult:
decoder_mode: str
postpone_tow_ms: float
bit_valid_events: int
bit_generator_sample_lag_ms: float
subframe_interrupts: int
nav_solve_interrupts: int
firmware_rejected_nav_solves: int
first_subframe_readout_bits: int
first_nav_solve_bit: int
first_reference_error_ms: float
expected_semantic_offset_ms: float
pps: PpsCoreResult
def _sample_at(time_s: float, epoch_s: float, initial_sample: int) -> SampleStamp:
exact = initial_sample + (time_s - epoch_s) * RF_HZ
raw_unwrapped = int(math.ceil(exact - 1.0e-10))
fractional_delay = raw_unwrapped - exact
raw = raw_unwrapped & 0xFFFF_FFFF
# This is the firmware representation: corrected_abs is near the wrapped
# integer ABS_SAMPLE, not a separately unwrapped floating-point counter.
return SampleStamp(raw=raw, corrected=raw - fractional_delay)
def _build_stream(config: EndToEndConfig) -> tuple[tuple[int, ...], float]:
d29, d30 = 0, 0
stream: list[int] = [d29, d30]
for offset in range(config.subframes):
subframe = encode_lnav_subframe(
config.first_z_count + offset,
offset % 5 + 1,
d29,
d30,
)
stream.extend(subframe.transmitted_bits)
d29, d30 = subframe.ending_d29, subframe.ending_d30
return tuple(stream), config.first_z_count * 6.0
def _generate_bit_events(
stream: Sequence[int],
*,
first_how_boundary_s: float,
propagation_s: float,
counter_epoch_s: float,
rf_initial_sample: int,
) -> tuple[BitEvent, ...]:
"""Expand LNAV bits to the 1 kHz prompt-sign input consumed by bit_gen."""
first_bit_event_tx = first_how_boundary_s - (
LNAV_SUBFRAME_BITS + 1
) * LNAV_BIT_PERIOD_S
first_prompt_receive = (
first_bit_event_tx
+ propagation_s
- LNAV_BIT_PERIOD_S
)
prompt_values = (
[1] * TRACKING_INTEGRATIONS_PER_BIT
+ [0] * TRACKING_INTEGRATIONS_PER_BIT
)
for value in stream:
prompt_values.extend([int(value)] * TRACKING_INTEGRATIONS_PER_BIT)
# The first integration of the following bit makes loop_counter_full true
# and emits the final requested bit, just as in bit_gen.sv.
prompt_values.append(0)
training_duration_s = 2 * LNAV_BIT_PERIOD_S
prompt_start_s = first_prompt_receive - training_duration_s
generator = BitGeneratorModel(propagation_s=propagation_s)
events: list[BitEvent] = []
for prompt_index, value in enumerate(prompt_values):
receive_time = (
prompt_start_s
+ prompt_index * TRACKING_INTEGRATION_PERIOD_S
)
output = generator.feed(
PromptSample(
value=value,
receive_time_s=receive_time,
sample=_sample_at(
receive_time,
counter_epoch_s,
rf_initial_sample,
),
)
)
if output is not None:
events.append(output)
if tuple(event.value for event in events) != tuple(stream):
raise RuntimeError("bit_gen model did not reproduce the requested LNAV stream")
return tuple(events)
def run_end_to_end(config: EndToEndConfig) -> EndToEndResult:
if config.subframes < 2:
raise ValueError("at least two subframes are required for preamble validation")
stream, first_how_boundary = _build_stream(config)
counter_epoch = first_how_boundary - 10.0
# Oscillator/PI behavior is already covered by pps_loop/adaptive_pps. This
# absolute-epoch regression uses an exact clock so a control-loop transient
# cannot hide or imitate an integer navigation-bit error.
clock = CounterClock(PPS_HZ, counter_epoch)
bit_events = _generate_bit_events(
stream,
first_how_boundary_s=first_how_boundary,
propagation_s=config.propagation_s,
counter_epoch_s=counter_epoch,
rf_initial_sample=config.rf_initial_sample,
)
decoder = TrackingDecoderModel(config.decoder_mode)
firmware = FirmwareTimingModel(
nav_solve_period_bits=config.nav_solve_period_bits,
postpone_tow_s=config.postpone_tow_s,
propagation_s=config.propagation_s,
)
timer: NavSolveTimerModel | None = None
subframe_events: list[SubframeInterrupt] = []
nav_events: list[NavSolveInterrupt] = []
references: list[FirmwareReference] = []
for bit_event in bit_events:
# An already-running timer sees the bit even when a later subframe is
# validated on the same event. Firmware handles NAV_BITS before
# NAV_SOLVE when both status flags share one interrupt.
nav_event = timer.feed(bit_event) if timer is not None else None
subframe_event = decoder.feed(bit_event)
if subframe_event is not None:
subframe_events.append(subframe_event)
firmware.on_subframe(subframe_event)
if timer is None:
timer = NavSolveTimerModel(
config.nav_solve_period_bits,
subframe_event,
clock,
capture_pipeline_ticks=config.pps_capture_pipeline_ticks,
)
if nav_event is not None:
nav_events.append(nav_event)
reference = firmware.on_nav_solve(nav_event)
if reference is not None:
references.append(reference)
if not subframe_events or not nav_events or not references:
raise RuntimeError("synthetic stream did not reach the PPS timing path")
pps = simulate_pps_core(
references,
clock,
compute_delay_s=config.firmware_compute_delay_s,
core_pipeline_ticks=config.core_pipeline_ticks,
lock_updates=5,
dut_output_delay_s=config.dut_output_delay_s,
trusted_output_delay_s=config.trusted_output_delay_s,
)
return EndToEndResult(
decoder_mode=config.decoder_mode.value,
postpone_tow_ms=config.postpone_tow_s * 1.0e3,
bit_valid_events=len(bit_events),
bit_generator_sample_lag_ms=(
bit_events[0].receive_time_s - bit_events[0].sample_time_s
)
* 1.0e3,
subframe_interrupts=len(subframe_events),
nav_solve_interrupts=len(nav_events),
firmware_rejected_nav_solves=firmware.rejected_nav_solves,
first_subframe_readout_bits=subframe_events[0].bit_index_after_how,
first_nav_solve_bit=nav_events[0].bit_index_after_how,
first_reference_error_ms=references[0].signed_reference_error_s * 1.0e3,
expected_semantic_offset_ms=(
subframe_events[0].bit_index_after_how * LNAV_BIT_PERIOD_S
- config.postpone_tow_s
)
* 1.0e3,
pps=pps,
)
def default_comparison() -> tuple[EndToEndResult, ...]:
return (
run_end_to_end(EndToEndConfig()),
run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_RTL,
postpone_tow_s=0.160,
)
),
run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_BIT,
postpone_tow_s=0.160,
)
),
)
def _result_dict(result: EndToEndResult) -> dict[str, object]:
value = asdict(result)
value["pps"]["edges"] = [asdict(edge) for edge in result.pps.edges]
return value
def _print_table(results: Sequence[EndToEndResult]) -> None:
print(
"scenario bits correction reference error "
"DUT - trusted PPS"
)
for result in results:
offset_ms = result.pps.edges[0].dut_minus_trusted_s * 1.0e3
scenario = f"{result.decoder_mode}/{result.postpone_tow_ms:.0f}ms"
print(
f"{scenario:<25} {result.first_subframe_readout_bits:>4d} "
f"{result.postpone_tow_ms:>7.0f} ms "
f"{result.first_reference_error_ms:>+12.6f} ms "
f"{offset_ms:>+13.6f} ms"
)
print("\nPositive DUT - trusted means that the project PPS edge is late.")
print(
"The residual tens of nanoseconds are the configured PCLK capture "
"pipeline, not an LNAV bit."
)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--mode",
choices=("matrix", DecoderMode.CURRENT_RTL.value, DecoderMode.CURRENT_BIT.value),
default="matrix",
help="run the three-way diagnosis matrix or one decoder mode",
)
parser.add_argument("--postpone-ms", type=float, default=180.0)
parser.add_argument("--propagation-ms", type=float, default=70.0)
parser.add_argument("--capture-pipeline-ticks", type=int, default=4)
parser.add_argument("--compute-delay-ms", type=float, default=10.0)
parser.add_argument("--dut-output-delay-ns", type=float, default=0.0)
parser.add_argument("--trusted-output-delay-ns", type=float, default=0.0)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
if args.mode == "matrix":
results = default_comparison()
else:
results = (
run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode(args.mode),
postpone_tow_s=args.postpone_ms * 1.0e-3,
propagation_s=args.propagation_ms * 1.0e-3,
pps_capture_pipeline_ticks=args.capture_pipeline_ticks,
firmware_compute_delay_s=args.compute_delay_ms * 1.0e-3,
dut_output_delay_s=args.dut_output_delay_ns * 1.0e-9,
trusted_output_delay_s=args.trusted_output_delay_ns * 1.0e-9,
)
),
)
if args.json:
print(json.dumps([_result_dict(result) for result in results], indent=2))
else:
_print_table(results)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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.",
"- Production firmware and this analysis both define `POSTPONE_TOW` "
"as 180 ms for the current RTL delivery semantics.",
"",
"## 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,160 @@
from __future__ import annotations
from pathlib import Path
import re
import unittest
from pps_end_to_end_validation.pps_end_to_end import (
DecoderMode,
EndToEndConfig,
LNAV_WINDOW_BITS,
encode_lnav_subframe,
run_end_to_end,
validate_rtl_window,
)
class LnavDecoderTests(unittest.TestCase):
def make_window(self):
previous_d29 = 0
previous_d30 = 0
current = encode_lnav_subframe(
57_600, 1, previous_d29, previous_d30
)
following = encode_lnav_subframe(
57_601,
2,
current.ending_d29,
current.ending_d30,
)
window = (
previous_d29,
previous_d30,
*current.transmitted_bits,
*following.transmitted_bits[:8],
)
self.assertEqual(len(window), LNAV_WINDOW_BITS)
return current, window
def test_parity_valid_subframe_round_trips_through_rtl_predicate(self):
current, window = self.make_window()
decoded = validate_rtl_window(window)
self.assertIsNotNone(decoded)
self.assertEqual(decoded.z_count, current.z_count)
self.assertEqual(decoded.subframe_id, current.subframe_id)
self.assertEqual(decoded.normalized_words, current.normalized_words)
def test_single_data_bit_corruption_prevents_subframe_interrupt(self):
_, window = self.make_window()
corrupted = list(window)
corrupted[2 + 73] ^= 1
self.assertIsNone(validate_rtl_window(corrupted))
class PpsEndToEndTests(unittest.TestCase):
def test_model_default_matches_production_firmware_postpone_tow(self):
header = (
Path(__file__).resolve().parents[1]
/ "gps_system/header/gps/gps_ip_defines.h"
).read_text(encoding="utf-8")
match = re.search(
r"#define\s+POSTPONE_TOW\s+\(double\)\(([0-9.]+)\)",
header,
)
self.assertIsNotNone(match)
self.assertAlmostEqual(float(match.group(1)), 0.180)
self.assertAlmostEqual(
EndToEndConfig().postpone_tow_s,
float(match.group(1)),
)
def test_bit_generator_recreates_50bps_stream_and_registered_sample_lag(self):
result = run_end_to_end(EndToEndConfig())
self.assertEqual(result.bit_valid_events, 2 + 3 * 300)
self.assertAlmostEqual(result.bit_generator_sample_lag_ms, 1.0, places=6)
def test_current_rtl_validates_on_ninth_following_bit(self):
result = run_end_to_end(EndToEndConfig())
self.assertEqual(result.first_subframe_readout_bits, 9)
self.assertEqual(result.first_nav_solve_bit, 19)
self.assertGreaterEqual(result.subframe_interrupts, 2)
self.assertGreater(result.nav_solve_interrupts, 20)
self.assertEqual(result.firmware_rejected_nav_solves, 0)
def test_permitted_40_bit_period_rejects_after_how_anchor_moves(self):
# Firmware's compile-time rule accepts 40 because it divides the GPS
# week. It does not divide a 300-bit subframe, so refreshing the HOW
# sample anchor changes timer phase by half a solve period. The
# current N*20 ms snap then rejects otherwise valid capture events.
self.assertEqual(30_240_000 % 40, 0)
self.assertNotEqual(300 % 40, 0)
result = run_end_to_end(
EndToEndConfig(nav_solve_period_bits=40, subframes=4)
)
self.assertGreater(result.firmware_rejected_nav_solves, 0)
def test_current_rtl_and_160ms_correction_make_pps_20ms_late(self):
result = run_end_to_end(EndToEndConfig(postpone_tow_s=0.160))
offset_s = result.pps.edges[0].dut_minus_trusted_s
self.assertAlmostEqual(result.expected_semantic_offset_ms, 20.0, places=9)
self.assertGreater(offset_s, 0.019999)
self.assertLess(offset_s, 0.020001)
for edge in result.pps.edges:
self.assertAlmostEqual(edge.dut_minus_trusted_s, offset_s, places=9)
def test_current_bit_validation_and_160ms_remove_lnav_bit_error(self):
result = run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_BIT,
postpone_tow_s=0.160,
)
)
self.assertEqual(result.first_subframe_readout_bits, 8)
self.assertEqual(result.first_nav_solve_bit, 18)
self.assertAlmostEqual(result.expected_semantic_offset_ms, 0.0, places=9)
self.assertLess(abs(result.pps.edges[0].dut_minus_trusted_s), 100.0e-9)
def test_180ms_production_firmware_matches_current_rtl(self):
result = run_end_to_end(EndToEndConfig())
self.assertEqual(result.first_subframe_readout_bits, 9)
self.assertAlmostEqual(result.expected_semantic_offset_ms, 0.0, places=9)
self.assertLess(abs(result.pps.edges[0].dut_minus_trusted_s), 100.0e-9)
def test_timestamped_commit_rejects_cpu_delay_as_phase(self):
fast = run_end_to_end(
EndToEndConfig(firmware_compute_delay_s=0.001)
)
stalled = run_end_to_end(
EndToEndConfig(firmware_compute_delay_s=0.080)
)
self.assertAlmostEqual(
fast.pps.edges[0].dut_minus_trusted_s,
stalled.pps.edges[0].dut_minus_trusted_s,
places=9,
)
self.assertGreater(stalled.pps.commit_age_s, fast.pps.commit_age_s)
def test_vendor_and_dut_fixed_delays_have_expected_sign(self):
result = run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_BIT,
postpone_tow_s=0.160,
dut_output_delay_s=12.0e-9,
trusted_output_delay_s=35.0e-9,
)
)
baseline = run_end_to_end(
EndToEndConfig(
decoder_mode=DecoderMode.CURRENT_BIT,
postpone_tow_s=0.160,
)
)
delta = (
result.pps.edges[0].dut_minus_trusted_s
- baseline.pps.edges[0].dut_minus_trusted_s
)
self.assertAlmostEqual(delta, -23.0e-9, places=10)
if __name__ == "__main__":
unittest.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()
+38 -9
View File
@@ -59,6 +59,28 @@ python3 pps_python/simulate_adaptive_pps.py
python3 -m unittest pps_python/test_pps_adaptive.py
```
## Bit-level Linux PPS uçtan uca testi
Bit üretiminden trusted PPS karşılaştırmasına kadar deterministik mutlak zaman
regresyonu ayrı `pps_end_to_end_validation` paketine taşınmıştır. Ayrıntılı
kapsam, 20 ms teşhisi ve donanım doğrulama adımları için
[`pps_end_to_end_validation/README.md`](../pps_end_to_end_validation/README.md)
dosyasına bakın.
Software dizininden çalıştırma:
```bash
python3 -m pps_end_to_end_validation.pps_end_to_end
python3 -m unittest pps_end_to_end_validation/test_pps_end_to_end.py
```
```text
scenario bits correction reference error DUT - trusted PPS
current-rtl/180ms 9 180 ms -0.000032 ms +0.000032 ms
current-rtl/160ms 9 160 ms -20.000032 ms +20.000032 ms
current-bit/160ms 8 160 ms -0.000032 ms +0.000032 ms
```
Kısa veya farklı PI koşusu:
```bash
@@ -145,10 +167,11 @@ bias_drift =
uydunun transmit subframe sınırı etiketidir. Full-subframe interrupt'ının
receiver'a ulaştığı an değildir.
Mevcut hardware, subframe doğrulama/parity akışında takip eden subframe'den
8 bit daha bekler. Navigation data 50 bit/s olduğu için bu sabit readout
gecikmesi `8 × 20 ms = 160 ms` olur ve yazılımda `POSTPONE_TOW=0.160` ile
temsil edilir. Bu yüzden ilk teslim edilen subframe için:
Mevcut hardware, subframe doğrulama/parity akışında eski shift register'i
okuduğu için takip eden subframe'in dokuzuncu bitinde teslim yapar. Navigation
data 50 bit/s olduğu için sabit readout gecikmesi `9 × 20 ms = 180 ms` olur ve
production yazılımda `POSTPONE_TOW=0.180` ile temsil edilir. Bu yüzden ilk
teslim edilen subframe için:
```text
transmit_epoch_TOW = HOW_TOW + POSTPONE_TOW
@@ -165,6 +188,10 @@ epoch-aligned transmit time'ı baremetal navigation katmanında tam bir kez
oluşturmalıdır. PPS APB periferiki kendisine verilen GPS reference time'a
`POSTPONE_TOW` değerini tekrar eklememelidir.
Tracking RTL ileride current-bit candidate window ile sekizinci bitte validate
edecek şekilde düzeltilirse aynı release'de firmware değeri 160 ms'ye geri
alınmalıdır; RTL ve firmware düzeltmeleri birbirinden bağımsız uygulanamaz.
İlk HOW'un hangi kanaldan geleceği sadece uydunun yakınlığına bağlı değildir.
Acquisition tamamlanma anı, bit lock ve bir sonraki tam subframe sınırı çoğu
zaman propagation delay farkından daha baskındır. Monte Carlo startup modeli
@@ -173,8 +200,9 @@ bu üç etkiyi ayrı gösterir.
Önerilen ownership:
1. Raw 125 MHz counter resetten itibaren free-running çalışır.
2. İlk geçerli HOW week/6-second mutlak zaman etiketini sağlar; ilk hardware
delivery epoch'u için 160 ms sabit readout düzeltmesi ayrıca eklenir.
2. İlk geçerli HOW week/6-second mutlak zaman etiketini sağlar; mevcut RTL'nin
ilk hardware delivery epoch'u için 180 ms sabit readout düzeltmesi ayrıca
eklenir.
3. İlk coherent WLS çözümünde minimum corrected-ABS kanalı datum olarak
seçilir; aynı SVID mevcut kaldığı sürece onun uçuş süresi
`STARTOFFSET - b/c` ile elde edilir.
@@ -303,9 +331,10 @@ yapar.
çözümde minimum corrected ABS kanalı seçiliyor ve aynı SVID coherent
snapshot'ta kaldığı sürece datum sabit tutuluyor. O kanalın TOW'u ve aynı
nav-solve olayındaki C125 capture'ı birlikte taşınıyor.
5. `POSTPONE_TOW=0.160`, hardware'ın mevcut subframe'i doğrulamak için takip
eden subframe'den 8 bit daha beklemesinden oluşan sabit readout gecikmesidir.
Uydu konum/saat epoch'u buna göre 160 ms ileri alınır. Bu değer signal
5. `POSTPONE_TOW=0.180`, hardware'ın mevcut subframe'i eski shift register
üzerinden doğrulayıp dokuzuncu takip eden bitte teslim etmesinden oluşan
sabit readout gecikmesidir. Uydu konum/saat epoch'u buna göre 180 ms ileri
alınır. Bu değer signal
flight time veya WLS receiver clock bias ile birleştirilmemelidir. Mevcut
`satpos()` zaten kullanım anında bir kez ekler; PPS peripheral tekrar eklemez.
6. 125 MHz timestamp `ABS_SAMPLE` ve `REM_CODE_PHASE` ile aynı hardware
+61 -18
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum
import math
from typing import Iterable
from typing import Iterable, Mapping
TRACKING_HZ = 125_000_000
@@ -242,17 +242,7 @@ class AdaptiveTrace:
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."""
selected = {
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
}
selected = dict(self.integer_second_edge_errors(after_s=after_s))
differences = [
edge_error - selected[round(time_s - 1.0, 9)]
for time_s, edge_error in selected.items()
@@ -264,6 +254,31 @@ class AdaptiveTrace:
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:
values = [
phase_s
@@ -319,6 +334,10 @@ class FixedPointAdaptivePps:
software_filter: bool = True,
receiver_clock_bias_m: float = 1200.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:
self.update_period_s = update_period_s
self.oscillator_fraction = oscillator_ppm * 1.0e-6
@@ -326,9 +345,24 @@ class FixedPointAdaptivePps:
self.software_filter = software_filter
self.receiver_clock_bias_m = receiver_clock_bias_m
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.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.parameters.fixed_coefficients(update_period_s)
)
@@ -376,10 +410,10 @@ class FixedPointAdaptivePps:
def _select_loop_parameters(self) -> None:
parameters = (
ACQUIRE_PROFILE
self.acquire_profile
if self.adaptive and
self.profile != Profile.HOLDOVER and not self.locked
else PROFILES[self.profile]
else self.profiles[self.profile]
)
if parameters == self.parameters:
return
@@ -541,15 +575,16 @@ class FixedPointAdaptivePps:
+ self.parameters.software_bias_gain * innovation_m
)
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
)
else:
if self.software_updates < self.parameters.software_settle_updates:
self.software_clock_drift_mps = fallback_drift_mps
else:
self.software_clock_drift_mps += (1.0 / 16.0) * (
fallback_drift_mps - self.software_clock_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_error_s = -(
@@ -705,6 +740,10 @@ def run_observations(
software_filter: bool = True,
receiver_clock_bias_m: float = 1200.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:
model = FixedPointAdaptivePps(
oscillator_ppm=oscillator_ppm,
@@ -712,6 +751,10 @@ def run_observations(
software_filter=software_filter,
receiver_clock_bias_m=receiver_clock_bias_m,
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:
model.update(observation)
+8 -8
View File
@@ -338,7 +338,7 @@ def page_executive(report: Report) -> None:
ax,
0.55,
r"T_{GPS,k}=T_{TX,k}+S-\frac{b_k}{c}",
"Minimum corrected-ABS kanalının receive GPS zamanı; ilk delivery için TTX,k = HOW + 0.160 s",
"Minimum corrected-ABS kanalının receive GPS zamanı; ilk delivery için TTX,k = HOW + 0.180 s",
)
report.equation(
ax,
@@ -455,7 +455,7 @@ def page_lnav_timeline(report: Report) -> None:
ax.text(x + 0.039, 0.58, label, color="white" if index < 2 else INK, ha="center", va="center", fontsize=7.5, weight="bold" if index < 2 else "normal")
report.arrow(ax, (0.205, 0.545), (0.86, 0.49), color=ORANGE, text="HOW Z-count: sonraki 6 s subframe sınırının transmit-time etiketi", rad=-0.12)
ax.add_patch(FancyBboxPatch((0.18, 0.445), 0.64, 0.045, boxstyle="round,pad=0.006", facecolor=PALE_ORANGE, edgecolor=ORANGE))
ax.text(0.50, 0.467, "Hardware takip eden subframe'den 8 bit bekler: 8 × 20 ms = POSTPONE_TOW = 160 ms", fontsize=7.6, color=ORANGE, ha="center", va="center", weight="bold")
ax.text(0.50, 0.467, "Mevcut hardware 9. takip eden bitte teslim eder: 9 × 20 ms = POSTPONE_TOW = 180 ms", fontsize=7.6, color=ORANGE, ha="center", va="center", weight="bold")
report.box(
ax,
@@ -518,7 +518,7 @@ def page_tx_rx(report: Report) -> None:
color=ORANGE,
)
report.equation(ax, 0.42, r"T_{RX,i}=T_{HOW}+\frac{\rho_i+I_i+T_i}{c}-\delta t_{SV,i}", "Aynı transmit sınırı her kanalda farklı receive timestamp üretir")
ax.text(0.50, 0.325, r"Hardware delivery epoch: $T_{event,i}=T_{HOW}+0.160\,s+\tau_i$", fontsize=9.2, color=ORANGE, ha="center", weight="bold")
ax.text(0.50, 0.325, r"Hardware delivery epoch: $T_{event,i}=T_{HOW}+0.180\,s+\tau_i$", fontsize=9.2, color=ORANGE, ha="center", weight="bold")
report.box(
ax,
0.07,
@@ -557,7 +557,7 @@ def page_project_map(report: Report) -> None:
(0.54, 0.54, 0.18, 0.10, "Linked-list state", "TOW owner\nsequence + sample", LIGHT, NAVY),
(0.28, 0.54, 0.20, 0.10, "Coherent snapshot", "25 ms window\nmodulo unwrap", PALE_GREEN, GREEN),
(0.06, 0.54, 0.17, 0.10, "Pseudorange", "minimum +\n68.802 ms", PALE_GREEN, GREEN),
(0.06, 0.34, 0.17, 0.10, "Satellite model", "TOW + 160 ms\nephemeris/clock", PALE_BLUE, BLUE),
(0.06, 0.34, 0.17, 0.10, "Satellite model", "TOW + 180 ms\nephemeris/clock", PALE_BLUE, BLUE),
(0.32, 0.34, 0.23, 0.10, "Weighted LS", "[X Y Z b]\niono + tropo + URA/el", PALE_GREEN, GREEN),
(0.64, 0.34, 0.30, 0.10, "Bugünkü çıkış", "position log + static last_solution[4]\nclock bias dışarı taşınmıyor", "#FBEAEA", RED),
]
@@ -796,7 +796,7 @@ def page_counter_mapping(report: Report) -> None:
fig, ax = report.new_page("GPS time ile 125 MHz counter mapping'i")
ax.text(0.08, 0.84, "İlk coherent WLS epoch'u k", fontsize=11, color=NAVY, weight="bold")
boxes = [
(0.07, 0.68, 0.25, 0.11, "Transmit etiketi", "İlk delivery: HOW + 0.160 s\nsonra nav-solve epoch", PALE_BLUE, BLUE),
(0.07, 0.68, 0.25, 0.11, "Transmit etiketi", "İlk delivery: HOW + 0.180 s\nsonra nav-solve epoch", PALE_BLUE, BLUE),
(0.375, 0.68, 0.25, 0.11, "WLS flight time", "τmin = S − b/c\nall-satellite solution", PALE_GREEN, GREEN),
(0.68, 0.68, 0.25, 0.11, "Receive reference", "T_GPS,k = T_TX,k + τmin", PALE_ORANGE, ORANGE),
(0.375, 0.47, 0.25, 0.11, "Hardware timestamp", "C125,k\naynı ABS/REM event", PALE_BLUE, BLUE),
@@ -1304,7 +1304,7 @@ def page_open_points(report: Report) -> None:
fig, ax = report.new_page("Projede ölçülerek kapatılacak açık noktalar")
points = [
("HOW boundary semantiği", "parse_subframe Z-count × 6 yapıyor. Full-subframe interrupt, bit_gen ABS_SAMPLE ve gerçek transmit next-subframe sınırı arasındaki sabit pipeline cycle sayısı scope/ILA ile ölçülmeli."),
("POSTPONE_TOW = 0.160", "Doğrulanmış hardware readout gecikmesidir: takip eden subframe'den 8 bit beklenir; 50 bit/s hızda 160 ms. Mevcut satpos() kullanım anında bir kez ekler; PPS peripheral tekrar eklemez."),
("POSTPONE_TOW = 0.180", "Mevcut RTL readout gecikmesidir: doğrulama dokuzuncu takip eden bitte yapılır; 50 bit/s hızda 180 ms. Mevcut satpos() kullanım anında bir kez ekler; PPS peripheral tekrar eklemez."),
("RF ↔ 125 MHz bağı", "Aynı PLL/reference mı, bağımsız mı? Ortak drift ve independent jitter ölçülmeli; cross-timestamp uncertainty buna göre bütçelenmeli."),
("Cold-start WLS timing", "Dört uyduda vertical/clock korelasyonu yüksek. Coarse alignment yapılabilir; PPS quality düşük işaretlenmeli ve PI bandwidth agresif seçilmemeli."),
("Atmosfer/multipath", "Iono/tropo modele girse de residual b ve position'a sızar. Timing-only mode veya sabit/smoothed position ileride clock çözümünü iyileştirebilir."),
@@ -1361,7 +1361,7 @@ def page_conclusion(report: Report) -> None:
0.80,
0.09,
"En önemli mimari karar",
"İlk uydu PPS'i başlatmaz. Baremetal HOW'a 160 ms readout düzeltmesini bir kez uygular; ilk coherent WLS bu etiketi free-running 125 MHz counter'a bağlar. PPS peripheral 160 ms'yi tekrar eklemez.",
"İlk uydu PPS'i başlatmaz. Baremetal HOW'a 180 ms readout düzeltmesini bir kez uygular; ilk coherent WLS bu etiketi free-running 125 MHz counter'a bağlar. PPS peripheral 180 ms'yi tekrar eklemez.",
face=NAVY,
edge=NAVY,
title_color="#8ED6E8",
@@ -1414,7 +1414,7 @@ def generate(output_pdf: Path) -> None:
report,
"İlk HOW neden PPS receive-now değildir?",
required_plots[4],
"Sentetik acquisition/subframe Monte Carlo: hardware delivery, takip eden subframe'in 8 biti nedeniyle HOW sınırından 160 ms ve signal flight time kadar sonradır. HOW +160 ms +68.802 ms kaba yaklaşım bırakır; WLS flight-time + paired timestamp doğru handover'dır.",
"Sentetik acquisition/subframe Monte Carlo: mevcut hardware delivery, dokuzuncu takip eden bit nedeniyle HOW sınırından 180 ms ve signal flight time kadar sonradır. HOW +180 ms +68.802 ms kaba yaklaşım bırakır; WLS flight-time + paired timestamp doğru handover'dır.",
)
plot_page(
report,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -4,18 +4,18 @@ epochs: 900
raw WLS bias/c range: -5.888015 .. 0.673753 ms
derived GPS reference RMS error: 60.522 ns
capture-to-commit delay mean/p99: 21.600 / 31.529 ms
first HOW used as receive-now, median phase error: -229.876 ms
first HOW + 160 ms + 68.802 ms, median phase error: -1.074 ms
first HOW used as receive-now, median phase error: -249.876 ms
first HOW + 180 ms + 68.802 ms, median phase error: -1.074 ms
4 satellites: position RMS=103.877 m, timing RMS=144.920 ns
5 satellites: position RMS=20.194 m, timing RMS=45.603 ns
7 satellites / drop-reacquisition: position RMS=14.626 m, timing RMS=36.888 ns
timestamped PI: tail phase RMS=274.599 ns, |phase| p95=409.200 ns, PPS RMS=815.734 ns, rejected stale commits=0
timestamped, PI disabled: tail phase RMS=233591.232 ns, |phase| p95=302909.699 ns, PPS RMS=181359.923 ns, rejected stale commits=0
timestamped PI: tail phase RMS=274.256 ns, |phase| p95=410.422 ns, PPS RMS=815.642 ns, rejected stale commits=0
timestamped, PI disabled: tail phase RMS=233589.285 ns, |phase| p95=302907.720 ns, PPS RMS=181358.257 ns, rejected stale commits=0
untagged APB-now PI: tail phase RMS=18651473.186 ns, |phase| p95=19146158.593 ns, PPS RMS=18060541.154 ns, rejected stale commits=0
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 + 160 ms) + STARTOFFSET - bias/c reconstructs GPS time at the paired timestamp.
- 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 160 ms readout correction once; the PPS peripheral must not add it again.
- baremetal applies the fixed 180 ms readout correction once; the PPS peripheral must not add it again.
+1 -1
View File
@@ -30,7 +30,7 @@ CODE_FREQ_BASIS = 1.023e6
START_OFFSET_MS = 68.802
START_OFFSET_S = START_OFFSET_MS / 1000.0
NAV_SOLVE_PERIOD_S = 10 * 0.020
POSTPONE_TOW_S = 0.160
POSTPONE_TOW_S = 0.180
OMEGA_EARTH = 7.2921151467e-5
WEEK_S = 604_800.0
+7 -6
View File
@@ -298,8 +298,9 @@ def simulate_startup_tow(
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
# after receiving eight bits from the following subframe. At 50 bit/s
# this is a deterministic 160 ms readout delay (POSTPONE_TOW), distinct
# 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, :]
@@ -354,7 +355,7 @@ def plot_startup_tow(output_dir: Path, startup: dict[str, np.ndarray]) -> None:
alpha=0.8,
)
axes[1, 0].set_xlabel("Phase error [ms]")
axes[1, 0].set_title("Provisional: HOW + 160 ms + 68.802 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(
@@ -397,7 +398,7 @@ def build_summary(
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 + 160 ms + 68.802 ms, median phase error: {np.median(startup['how_plus_pipeline_startoffset_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"),
@@ -426,10 +427,10 @@ def build_summary(
"",
"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 + 160 ms) + STARTOFFSET - bias/c reconstructs GPS time at the paired timestamp.",
"- 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 160 ms readout correction once; the PPS peripheral must not add it again.",
"- baremetal applies the fixed 180 ms readout correction once; the PPS peripheral must not add it again.",
]
)
return "\n".join(lines) + "\n"