Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a697284bff | |||
| 988f9686e7 | |||
| 73db2a8355 |
@@ -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
|
||||
|
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||