feat(pps): add adaptive disciplined timing loop
This commit is contained in:
@@ -7,7 +7,7 @@ event-driven Python modelidir.
|
||||
Bu bir genel amaçlı GNSS simülatörü değildir. Özellikle aşağıdaki proje
|
||||
davranışlarını taklit eder:
|
||||
|
||||
- RF `ABS_SAMPLE` sayacı: `16.368 MHz`, 32 bit ve wrap eden sayaç.
|
||||
- RF `ABS_SAMPLE` sayacı: `16.367981 MHz`, 32 bit ve wrap eden sayaç.
|
||||
- Tracking/PPS sayacı: `125 MHz` free-running sayaç.
|
||||
- Navigation solve periyodu: `NAV_SOLVE_TIMER=10`, yani `200 ms`.
|
||||
- Ortak navigation bit transmit epoch'u ve uyduların farklı propagation
|
||||
@@ -18,11 +18,21 @@ davranışlarını taklit eder:
|
||||
corrected_abs = ABS_SAMPLE - REM_CODE_PHASE / 1.023e6
|
||||
```
|
||||
|
||||
- Integer RF sample kenarinda yakalanan 125 MHz counter da ayni code
|
||||
boundary'ye geri cekilir:
|
||||
|
||||
```text
|
||||
capture_correction_ticks =
|
||||
round((REM_CODE_PHASE / 1.023e6) * (125e6 / 16.367981e6))
|
||||
corrected_capture = raw_capture - capture_correction_ticks
|
||||
```
|
||||
|
||||
Model ve C ayni 8 ns integer mailbox yuvarlamasini kullanir.
|
||||
- `gps_unwrap_corrected_abs_sample()` ile aynı signed modulo-32 unwrap.
|
||||
- `gps_ip.c` ile aynı pseudorange datum'u:
|
||||
|
||||
```text
|
||||
precise_ms_i = abs_i / 16368 - minimum + 68.802
|
||||
precise_ms_i = abs_i / 16367.981 - reference + 68.802
|
||||
P_i = precise_ms_i * c / 1000 + satellite_clock_i * c
|
||||
```
|
||||
|
||||
@@ -45,6 +55,8 @@ Software dizininden:
|
||||
```bash
|
||||
python3 pps_python/simulate_pps.py
|
||||
python3 -m unittest pps_python/test_pps_model.py
|
||||
python3 pps_python/simulate_adaptive_pps.py
|
||||
python3 -m unittest pps_python/test_pps_adaptive.py
|
||||
```
|
||||
|
||||
Kısa veya farklı PI koşusu:
|
||||
@@ -92,11 +104,12 @@ olarak yazılır; simülasyon sonuç sayfaları `output/` altındaki PNG'leri ku
|
||||
|
||||
## Modeldeki doğru zaman bağı
|
||||
|
||||
Mevcut observation denkleminde WLS bias doğrudan PPS fazı değildir. Minimum
|
||||
kanal için yaklaşık ilişki:
|
||||
Mevcut observation denkleminde WLS bias doğrudan PPS fazı değildir. İlk
|
||||
çözümde seçilen ve mevcut kaldığı sürece korunan referans kanal için yaklaşık
|
||||
ilişki:
|
||||
|
||||
```text
|
||||
b/c = STARTOFFSET - signal_flight_time_min
|
||||
b/c = STARTOFFSET - signal_flight_time_ref
|
||||
```
|
||||
|
||||
Bu nedenle doğru GPS referansı:
|
||||
@@ -105,7 +118,7 @@ Bu nedenle doğru GPS referansı:
|
||||
GPS_TIME_AT_CAPTURE = transmit_epoch_TOW + STARTOFFSET - b/c
|
||||
```
|
||||
|
||||
ile kurulur ve minimum kanala ait `tracking_tick_125` ile eşlenir. Referans
|
||||
ile kurulur ve referans kanala ait `tracking_tick_125` ile eşlenir. Referans
|
||||
uydu değiştiğinde raw `b/c` milisaniyeler seviyesinde basamak yapabilir; TOW ve
|
||||
eşlenmiş timestamp ile oluşturulan GPS zamanı ise sürekli kalır. Üretilen ilk
|
||||
grafik bu farkı açıkça gösterir.
|
||||
@@ -148,14 +161,16 @@ bu üç etkiyi ayrı gösterir.
|
||||
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.
|
||||
3. İlk coherent WLS çözümünde minimum corrected-ABS kanalının uçuş süresi
|
||||
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.
|
||||
4. Bu receive GPS zamanı aynı kanalın yakalanmış 125 MHz timestamp'ıyla
|
||||
eşlenerek PPS accumulator coarse-align edilir.
|
||||
5. Sonraki çözümler aynı eşleşmeden residual phase error üretip PI'yi besler.
|
||||
|
||||
Minimum kanal yalnız zaman datum'unu seçer; `b` bütün WLS uydularıyla beraber
|
||||
çözülür. İstenirse çözüm sonrası her uydu için
|
||||
Referans kanal yalnız zaman datum'unu seçer; `b` bütün WLS uydularıyla beraber
|
||||
çözülür. Referans kaybolursa yeni en erken kanal seçilir ve filtre bilinçli
|
||||
coarse re-anchor edilir. İstenirse çözüm sonrası her uydu için
|
||||
`(rho + iono + tropo) / c - satellite_clock` hesaplanıp kendi timestamp'ına
|
||||
bağlanabilir. Doğru hizalamada bunların hepsi aynı receiver time mapping'ini
|
||||
vermelidir; bu ayrıca güçlü bir coherency testi olur.
|
||||
@@ -198,6 +213,56 @@ Bunlar Q2.46 donanım katsayılarının ilk çalışma değerleridir. Karttan cl
|
||||
serisi ve oscillator drift ölçüldükten sonra Allan deviation/phase-noise
|
||||
davranışına göre yeniden ayarlanmalıdır.
|
||||
|
||||
Donanım loop filter'ı bu PI yapısının kendisidir. Ayrı bir phase-IIR veya
|
||||
derivative/D kolu yoktur. Küçük yazılım filtresi yalnız WLS clock-bias
|
||||
ölçümünün residual gürültüsünü Doppler clock-drift prediction sonrasında
|
||||
sınırlar; PI dinamiğinin yerine geçmez. Acquisition sırasında `0.12 Hz`,
|
||||
precision modunda `0.035 Hz`, zayıf geometride robust `0.012 Hz` doğal frekans
|
||||
kullanılır. `Kp`, `Ki`, `20 ppm` slew limiti ve lock politikası çalışma anında
|
||||
profil ile değişir; integrator normal profil geçişlerinde korunur. Valid lock
|
||||
için beş ardışık residual'ın 125 MHz saatinde sekiz tick (`64 ns`) içinde
|
||||
olması gerekir. Üretim profilleri tek seyrek WLS sıçramasında fiziksel pulse
|
||||
kaçırmamak için lock'u ancak `128 tick = 1.024 us` üstünde bırakır; watchdog,
|
||||
quality holdover ve slew clamp daha büyük arızaları ayrıca sınırlar.
|
||||
|
||||
## Adaptif PPS senaryo matrisi
|
||||
|
||||
Tam fixed-point/holdover matrisi:
|
||||
|
||||
```bash
|
||||
python3 -m pps_python.analyze_pps_scenarios
|
||||
```
|
||||
|
||||
Model, 125 MHz çıkış grid'ini de PPS kenarına quantize eder. 2026-07-25
|
||||
regresyonunda ayrı donanım IIR olmadan:
|
||||
|
||||
| Senaryo | Lock | İlk lock p95 | Edge jitter p95 |
|
||||
|---|---:|---:|---:|
|
||||
| temiz precision | 30/30 | 10.80 s | 5.50 ns |
|
||||
| 320 ns kart noise + outlier | 50/50 | 12.31 s | 13.62 ns |
|
||||
| 650 ns zayıf geometri | 30/30 | 13.77 s | 12.31 ns |
|
||||
| başlangıç 1.1 us noise, sonra temiz | 30/30 | 16.93 s | 8.45 ns |
|
||||
| aralıklı noise burst, sonra recovery | 30/30 | 12.00 s | 10.82 ns |
|
||||
| iki uydu-kaybi aralığı ve recovery | 30/30 | 11.60 s | 12.57 ns |
|
||||
| Doppler kaybı ve recovery | 30/30 | 11.60 s | 12.41 ns |
|
||||
| +18 ppm, slew sınırına yakın | 30/30 | 18.20 s | 12.01 ns |
|
||||
| +25 ppm, 20 ppm sınırının dışında | 0/10 | lock yok | geçerli edge yok |
|
||||
|
||||
Ek olarak kısa/uzun WLS kesintisi, quality profile geçişi, multipath basamağı
|
||||
ve hiç Doppler olmayan fallback yolu test edilir. Linux
|
||||
`test_gps_pps_adaptive_loop.c` aynı geçişli ortamları C implementasyonu ve
|
||||
Q32/Q46 aritmetiğiyle çoklu seed altında tekrarlar. `test_gps_pps_quality.c`
|
||||
NaN, dört uydunun altına düşme, eşik sınırları ve histerezisi;
|
||||
`test_gps_pps_time_filter.c` drift prediction, gap/re-anchor ve hard-outlier
|
||||
reddini sınar.
|
||||
|
||||
İki zaman eşiği bilinçli olarak ayrıdır: iki saniyelik watchdog gözlem yokken
|
||||
PPS validliğini holdover'a indirir; aynı referans SVID için Doppler-predicted
|
||||
clock-bias ve PI state'i 30 saniyeye kadar korunur. Modelde 15 saniyelik WLS
|
||||
ve uydu kaybı boşlukları ek coarse commit/unlock üretmeden geri döner.
|
||||
30 saniyeyi aşan boşluk veya referans SVID değişimi güvenli coarse re-anchor
|
||||
yapar.
|
||||
|
||||
## C kodu analizi sırasında görülen önemli noktalar
|
||||
|
||||
1. `leastSquaresSTD()` artık aynı başarılı çözümün `x/y/z/b` vektörünü çağırana
|
||||
@@ -210,9 +275,10 @@ davranışına göre yeniden ayarlanmalıdır.
|
||||
3. Pseudorange her epoch'ta `minimum` ile yeniden normalize edildiği için RF
|
||||
counter'ın mutlak fazı WLS observation'da iptal olur. RF frequency error ise
|
||||
uyduların arrival-time farklarını ölçeklediği için tamamen iptal olmaz.
|
||||
4. PPS tarafı array'in ilk elemanını referans kabul etmiyor; minimum corrected
|
||||
ABS kanalının TOW'u ve aynı nav-solve olayındaki C125 capture'ı birlikte
|
||||
taşınıyor.
|
||||
4. PPS tarafı array'in ilk elemanını referans kabul etmiyor; ilk geçerli
|
||||
çö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
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
"""Bit-accurate event model for the adaptive PPS control path.
|
||||
|
||||
The WLS/navigation model in :mod:`pps_model` validates timestamp ownership.
|
||||
This module focuses on the control corner cases seen on the board: noisy or
|
||||
missing WLS time observations, quality-driven coefficient changes, fixed-point
|
||||
rounding, lock acquisition/release and holdover/re-acquisition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import math
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
TRACKING_HZ = 125_000_000
|
||||
SPEED_OF_LIGHT_MPS = 299_792_458.0
|
||||
REFERENCE_WATCHDOG_SEC = 2.0
|
||||
MAX_PREDICTION_GAP_SEC = 30.0
|
||||
Q32 = 1 << 32
|
||||
Q46 = 1 << 46
|
||||
MAX_SIGNED_64 = (1 << 63) - 1
|
||||
MIN_SIGNED_64 = -(1 << 63)
|
||||
|
||||
|
||||
class Profile(IntEnum):
|
||||
HOLDOVER = 0
|
||||
ROBUST = 1
|
||||
PRECISION = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityMetrics:
|
||||
tdop: float
|
||||
gdop: float
|
||||
clock_bias_sigma_m: float
|
||||
mean_cn0_dbhz: float
|
||||
minimum_cn0_dbhz: float
|
||||
chi_square_ratio: float
|
||||
velocity_residual_rms_mps: float
|
||||
used_satellites: int
|
||||
solution_valid: bool = True
|
||||
residual_test_valid: bool = True
|
||||
velocity_valid: bool = True
|
||||
fde_applied: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopProfile:
|
||||
natural_frequency_hz: float
|
||||
damping: float
|
||||
max_slew_ppm: float
|
||||
lock_acquire_ticks: int
|
||||
lock_release_ticks: int
|
||||
lock_acquire_updates: int
|
||||
software_bias_gain: float
|
||||
software_settle_updates: int
|
||||
|
||||
def fixed_coefficients(self, update_period_s: float) -> tuple[int, int, int]:
|
||||
omega = 2.0 * math.pi * self.natural_frequency_hz
|
||||
kp_q46 = round((2.0 * self.damping * omega / TRACKING_HZ) * Q46)
|
||||
ki_q46 = round(
|
||||
(omega * omega * update_period_s / TRACKING_HZ) * Q46
|
||||
)
|
||||
max_slew_q32 = round(self.max_slew_ppm * 1.0e-6 * Q32)
|
||||
return kp_q46, ki_q46, max_slew_q32
|
||||
|
||||
|
||||
PROFILES = {
|
||||
Profile.HOLDOVER: LoopProfile(
|
||||
0.0, 1.0, 20.0, 8, 128, 8, 1.0 / 32.0, 16
|
||||
),
|
||||
Profile.ROBUST: LoopProfile(
|
||||
0.012, 1.0, 20.0, 8, 128, 8, 1.0 / 32.0, 16
|
||||
),
|
||||
Profile.PRECISION: LoopProfile(
|
||||
0.035, 0.9, 20.0, 8, 128, 5, 1.0 / 32.0, 12
|
||||
),
|
||||
}
|
||||
|
||||
ACQUIRE_PROFILE = LoopProfile(
|
||||
0.12, 0.9, 20.0, 8, 128, 5, 1.0 / 16.0, 12
|
||||
)
|
||||
|
||||
|
||||
def _finite_metrics(metrics: QualityMetrics) -> bool:
|
||||
return all(
|
||||
math.isfinite(value)
|
||||
for value in (
|
||||
metrics.tdop,
|
||||
metrics.gdop,
|
||||
metrics.clock_bias_sigma_m,
|
||||
metrics.mean_cn0_dbhz,
|
||||
metrics.minimum_cn0_dbhz,
|
||||
metrics.velocity_residual_rms_mps,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def classify_quality(metrics: QualityMetrics) -> Profile:
|
||||
invalid = (
|
||||
not _finite_metrics(metrics)
|
||||
or not metrics.solution_valid
|
||||
or metrics.used_satellites < 4
|
||||
or metrics.tdop < 0.0
|
||||
or metrics.gdop < 0.0
|
||||
or metrics.clock_bias_sigma_m < 0.0
|
||||
)
|
||||
if invalid:
|
||||
return Profile.HOLDOVER
|
||||
|
||||
residual_ok = (
|
||||
metrics.residual_test_valid
|
||||
and math.isfinite(metrics.chi_square_ratio)
|
||||
and metrics.chi_square_ratio <= 1.0
|
||||
) or (not metrics.residual_test_valid and metrics.used_satellites == 4)
|
||||
robust = (
|
||||
residual_ok
|
||||
and metrics.tdop <= 8.0
|
||||
and metrics.gdop <= 12.0
|
||||
and metrics.clock_bias_sigma_m <= 100.0
|
||||
and metrics.mean_cn0_dbhz >= 22.0
|
||||
and metrics.minimum_cn0_dbhz >= 14.0
|
||||
and (
|
||||
not metrics.velocity_valid
|
||||
or metrics.velocity_residual_rms_mps <= 3.0
|
||||
)
|
||||
)
|
||||
if not robust:
|
||||
return Profile.HOLDOVER
|
||||
|
||||
precision = (
|
||||
metrics.used_satellites >= 6
|
||||
and metrics.residual_test_valid
|
||||
and not metrics.fde_applied
|
||||
and metrics.tdop <= 3.0
|
||||
and metrics.gdop <= 6.0
|
||||
and metrics.clock_bias_sigma_m <= 35.0
|
||||
and metrics.mean_cn0_dbhz >= 30.0
|
||||
and metrics.minimum_cn0_dbhz >= 20.0
|
||||
and metrics.chi_square_ratio <= 0.75
|
||||
and metrics.velocity_valid
|
||||
and metrics.velocity_residual_rms_mps <= 1.0
|
||||
)
|
||||
return Profile.PRECISION if precision else Profile.ROBUST
|
||||
|
||||
|
||||
class QualitySelector:
|
||||
"""Three-profile selector with asymmetric transition hysteresis."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.profile = Profile.HOLDOVER
|
||||
self.candidate = Profile.HOLDOVER
|
||||
self.candidate_updates = 0
|
||||
|
||||
def update(self, metrics: QualityMetrics) -> tuple[Profile, bool]:
|
||||
target = classify_quality(metrics)
|
||||
if target == self.profile:
|
||||
self.candidate = target
|
||||
self.candidate_updates = 0
|
||||
return self.profile, False
|
||||
if target != self.candidate:
|
||||
self.candidate = target
|
||||
self.candidate_updates = 1
|
||||
else:
|
||||
self.candidate_updates += 1
|
||||
required = 8 if target > self.profile else 3
|
||||
if self.candidate_updates >= required:
|
||||
self.profile = target
|
||||
self.candidate_updates = 0
|
||||
return self.profile, True
|
||||
return self.profile, False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Observation:
|
||||
time_s: float
|
||||
timing_error_s: float
|
||||
clock_drift_error_mps: float
|
||||
quality: QualityMetrics
|
||||
clock_drift_valid: bool = True
|
||||
reference_range_rate_mps: float = 0.0
|
||||
reference_id: int = 1
|
||||
reference_datum_offset_m: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdaptiveTrace:
|
||||
time_s: list[float]
|
||||
phase_error_s: list[float]
|
||||
raw_measurement_error_s: list[float]
|
||||
software_filtered_error_s: list[float]
|
||||
raw_loop_error_s: list[float]
|
||||
control_loop_error_s: list[float]
|
||||
frequency_command_ppm: list[float]
|
||||
profile: list[Profile]
|
||||
locked: list[bool]
|
||||
coarse_commits: int
|
||||
normal_commits: int
|
||||
rejected_observations: int
|
||||
lock_events: int
|
||||
unlock_events: int
|
||||
|
||||
def locked_fraction(self) -> float:
|
||||
return sum(self.locked) / len(self.locked) if self.locked else 0.0
|
||||
|
||||
def first_lock_time(self) -> float | None:
|
||||
for time_s, locked in zip(self.time_s, self.locked):
|
||||
if locked:
|
||||
return time_s
|
||||
return None
|
||||
|
||||
def one_second_jitter_rms(self, *, after_s: float = 0.0) -> float:
|
||||
selected = [
|
||||
(time_s, phase_s)
|
||||
for time_s, phase_s, locked in zip(
|
||||
self.time_s, self.phase_error_s, self.locked
|
||||
)
|
||||
if locked and time_s >= after_s
|
||||
]
|
||||
if len(selected) < 2:
|
||||
return math.nan
|
||||
differences = []
|
||||
previous_index = 0
|
||||
for index, (time_s, phase_s) in enumerate(selected):
|
||||
while (
|
||||
previous_index < index
|
||||
and selected[previous_index][0] < time_s - 1.0 - 1.0e-9
|
||||
):
|
||||
previous_index += 1
|
||||
if (
|
||||
previous_index < index
|
||||
and abs(selected[previous_index][0] - (time_s - 1.0)) < 1.0e-9
|
||||
):
|
||||
differences.append(phase_s - selected[previous_index][1])
|
||||
if not differences:
|
||||
return math.nan
|
||||
return math.sqrt(
|
||||
sum(value * value for value in differences) / len(differences)
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
differences = [
|
||||
edge_error - selected[round(time_s - 1.0, 9)]
|
||||
for time_s, edge_error in selected.items()
|
||||
if round(time_s - 1.0, 9) in selected
|
||||
]
|
||||
if not differences:
|
||||
return math.nan
|
||||
return math.sqrt(
|
||||
sum(value * value for value in differences) / len(differences)
|
||||
)
|
||||
|
||||
def phase_rms(self, *, after_s: float = 0.0) -> float:
|
||||
values = [
|
||||
phase_s
|
||||
for time_s, phase_s, locked in zip(
|
||||
self.time_s, self.phase_error_s, self.locked
|
||||
)
|
||||
if locked and time_s >= after_s
|
||||
]
|
||||
if not values:
|
||||
return math.nan
|
||||
return math.sqrt(sum(value * value for value in values) / len(values))
|
||||
|
||||
def settling_time(
|
||||
self,
|
||||
*,
|
||||
rms_limit_s: float,
|
||||
window_s: float = 10.0,
|
||||
) -> float | None:
|
||||
"""First all-locked window whose phase RMS meets ``rms_limit_s``."""
|
||||
for end_index, end_time_s in enumerate(self.time_s):
|
||||
start_time_s = end_time_s - window_s
|
||||
if start_time_s < 0.0:
|
||||
continue
|
||||
window = [
|
||||
(phase_s, locked)
|
||||
for time_s, phase_s, locked in zip(
|
||||
self.time_s[: end_index + 1],
|
||||
self.phase_error_s[: end_index + 1],
|
||||
self.locked[: end_index + 1],
|
||||
)
|
||||
if time_s >= start_time_s - 1.0e-9
|
||||
]
|
||||
if not window or not all(locked for _, locked in window):
|
||||
continue
|
||||
rms = math.sqrt(
|
||||
sum(phase_s * phase_s for phase_s, _ in window)
|
||||
/ len(window)
|
||||
)
|
||||
if rms <= rms_limit_s:
|
||||
return end_time_s
|
||||
return None
|
||||
|
||||
|
||||
class FixedPointAdaptivePps:
|
||||
"""Event-driven model of the Q32/Q46 RTL plus the small clock-bias filter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
update_period_s: float = 0.2,
|
||||
oscillator_ppm: float = 1.65,
|
||||
adaptive: bool = True,
|
||||
software_filter: bool = True,
|
||||
receiver_clock_bias_m: float = 1200.0,
|
||||
receiver_clock_drift_mps: float = 475.0,
|
||||
) -> None:
|
||||
self.update_period_s = update_period_s
|
||||
self.oscillator_fraction = oscillator_ppm * 1.0e-6
|
||||
self.adaptive = adaptive
|
||||
self.software_filter = software_filter
|
||||
self.receiver_clock_bias_m = receiver_clock_bias_m
|
||||
self.receiver_clock_drift_mps = receiver_clock_drift_mps
|
||||
self.selector = QualitySelector()
|
||||
self.profile = Profile.HOLDOVER if adaptive else Profile.PRECISION
|
||||
self.parameters = PROFILES[self.profile]
|
||||
self.kp_q46, self.ki_q46, self.max_slew_q32 = (
|
||||
self.parameters.fixed_coefficients(update_period_s)
|
||||
)
|
||||
|
||||
self.current_time_s = 0.0
|
||||
self.raw_tick_remainder = 0.0
|
||||
self.phase_q32 = 0
|
||||
self.rate_word_q32 = 0
|
||||
self.integrator_q32 = 0
|
||||
self.aligned = False
|
||||
self.locked = False
|
||||
self.lock_good_updates = 0
|
||||
self.last_observation_time_s: float | None = None
|
||||
self.software_error_s = 0.0
|
||||
self.software_bias_m = 0.0
|
||||
self.software_clock_drift_mps = 0.0
|
||||
self.software_previous_measurement_m = 0.0
|
||||
self.software_drift_anchor_time_s = 0.0
|
||||
self.software_drift_anchor_measurement_m = 0.0
|
||||
self.software_drift_initialized = False
|
||||
self.software_updates = 0
|
||||
self.software_reference_id: int | None = None
|
||||
self.reference_watchdog_holdover = True
|
||||
|
||||
self.trace = AdaptiveTrace(
|
||||
[], [], [], [], [], [], [], [], [], 0, 0, 0, 0, 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp(value: int, limit: int) -> int:
|
||||
return max(-limit, min(limit, value))
|
||||
|
||||
@staticmethod
|
||||
def _seconds_to_phase_q32(seconds: float) -> int:
|
||||
value = round(seconds * TRACKING_HZ * Q32)
|
||||
return max(MIN_SIGNED_64, min(MAX_SIGNED_64, value))
|
||||
|
||||
@staticmethod
|
||||
def _phase_q32_to_seconds(value: int) -> float:
|
||||
return value / (TRACKING_HZ * Q32)
|
||||
|
||||
def _apply_profile(self, profile: Profile) -> None:
|
||||
self.profile = profile
|
||||
self._select_loop_parameters()
|
||||
|
||||
def _select_loop_parameters(self) -> None:
|
||||
parameters = (
|
||||
ACQUIRE_PROFILE
|
||||
if self.adaptive and
|
||||
self.profile != Profile.HOLDOVER and not self.locked
|
||||
else PROFILES[self.profile]
|
||||
)
|
||||
if parameters == self.parameters:
|
||||
return
|
||||
self.parameters = parameters
|
||||
self.kp_q46, self.ki_q46, self.max_slew_q32 = (
|
||||
self.parameters.fixed_coefficients(self.update_period_s)
|
||||
)
|
||||
|
||||
def _advance(self, time_s: float) -> None:
|
||||
elapsed_s = time_s - self.current_time_s
|
||||
if elapsed_s < -1.0e-15:
|
||||
raise ValueError("observations are not time ordered")
|
||||
if elapsed_s <= 0.0:
|
||||
return
|
||||
true_ticks = elapsed_s * TRACKING_HZ
|
||||
raw_ticks_float = (
|
||||
true_ticks * (1.0 + self.oscillator_fraction)
|
||||
+ self.raw_tick_remainder
|
||||
)
|
||||
raw_ticks = int(math.floor(raw_ticks_float))
|
||||
self.raw_tick_remainder = raw_ticks_float - raw_ticks
|
||||
self.phase_q32 += (
|
||||
raw_ticks * (Q32 + self.rate_word_q32)
|
||||
- round(true_ticks * Q32)
|
||||
)
|
||||
self.phase_q32 = max(
|
||||
MIN_SIGNED_64, min(MAX_SIGNED_64, self.phase_q32)
|
||||
)
|
||||
self.current_time_s = time_s
|
||||
|
||||
def _software_filter_update(self, observation: Observation) -> tuple[bool, bool]:
|
||||
if (
|
||||
not math.isfinite(observation.timing_error_s)
|
||||
or (
|
||||
observation.clock_drift_valid
|
||||
and not math.isfinite(observation.clock_drift_error_mps)
|
||||
)
|
||||
):
|
||||
self.trace.rejected_observations += 1
|
||||
return False, False
|
||||
|
||||
if self.software_reference_id != observation.reference_id:
|
||||
# WLS bias has a reference-dependent datum. A satellite change is
|
||||
# a deliberate coarse re-anchor, never an outlier to interpolate.
|
||||
self.last_observation_time_s = None
|
||||
self.software_updates = 0
|
||||
self.software_drift_initialized = False
|
||||
self.software_reference_id = observation.reference_id
|
||||
|
||||
true_bias_m = (
|
||||
self.receiver_clock_bias_m
|
||||
+ observation.reference_datum_offset_m
|
||||
- observation.reference_range_rate_mps * observation.time_s
|
||||
)
|
||||
measured_bias_m = (
|
||||
true_bias_m - observation.timing_error_s * SPEED_OF_LIGHT_MPS
|
||||
)
|
||||
measured_drift_mps = (
|
||||
-observation.reference_range_rate_mps
|
||||
+ observation.clock_drift_error_mps
|
||||
)
|
||||
if self.last_observation_time_s is None:
|
||||
self.software_bias_m = measured_bias_m
|
||||
self.software_clock_drift_mps = (
|
||||
measured_drift_mps
|
||||
if observation.clock_drift_valid
|
||||
else 0.0
|
||||
)
|
||||
self.software_previous_measurement_m = measured_bias_m
|
||||
self.software_drift_anchor_time_s = observation.time_s
|
||||
self.software_drift_anchor_measurement_m = measured_bias_m
|
||||
self.software_drift_initialized = observation.clock_drift_valid
|
||||
self.software_error_s = observation.timing_error_s
|
||||
self.software_updates = 1
|
||||
self.last_observation_time_s = observation.time_s
|
||||
return True, True
|
||||
interval_s = observation.time_s - self.last_observation_time_s
|
||||
if interval_s < 0.04:
|
||||
self.trace.rejected_observations += 1
|
||||
return False, False
|
||||
# The two-second reference watchdog controls PPS validity, not filter
|
||||
# state lifetime. Keeping the Doppler-predicted bias and the hardware
|
||||
# PI state across a bounded WLS scheduling/signal gap avoids a coarse
|
||||
# phase step when the same datum satellite returns. A reference
|
||||
# change is handled above and a genuinely long gap still re-anchors.
|
||||
if interval_s > MAX_PREDICTION_GAP_SEC:
|
||||
self.software_bias_m = measured_bias_m
|
||||
self.software_clock_drift_mps = (
|
||||
measured_drift_mps
|
||||
if observation.clock_drift_valid
|
||||
else 0.0
|
||||
)
|
||||
self.software_previous_measurement_m = measured_bias_m
|
||||
self.software_drift_anchor_time_s = observation.time_s
|
||||
self.software_drift_anchor_measurement_m = measured_bias_m
|
||||
self.software_drift_initialized = observation.clock_drift_valid
|
||||
self.software_error_s = observation.timing_error_s
|
||||
self.software_updates = 1
|
||||
self.last_observation_time_s = observation.time_s
|
||||
return True, True
|
||||
|
||||
if not self.software_filter:
|
||||
self.software_bias_m = measured_bias_m
|
||||
self.software_clock_drift_mps = (
|
||||
measured_drift_mps
|
||||
if observation.clock_drift_valid
|
||||
else self.software_clock_drift_mps
|
||||
)
|
||||
self.software_previous_measurement_m = measured_bias_m
|
||||
self.software_drift_initialized |= observation.clock_drift_valid
|
||||
self.software_error_s = observation.timing_error_s
|
||||
self.software_updates += 1
|
||||
self.last_observation_time_s = observation.time_s
|
||||
return True, False
|
||||
|
||||
measurement_delta_m = (
|
||||
measured_bias_m - self.software_previous_measurement_m
|
||||
)
|
||||
if (
|
||||
not observation.clock_drift_valid
|
||||
and abs(measurement_delta_m)
|
||||
> 10_000.0 * interval_s + 1500.0
|
||||
):
|
||||
self.trace.rejected_observations += 1
|
||||
return False, False
|
||||
|
||||
anchor_interval_s = (
|
||||
observation.time_s - self.software_drift_anchor_time_s
|
||||
)
|
||||
fallback_drift_mps = max(
|
||||
-10_000.0,
|
||||
min(
|
||||
10_000.0,
|
||||
(
|
||||
measured_bias_m
|
||||
- self.software_drift_anchor_measurement_m
|
||||
)
|
||||
/ anchor_interval_s
|
||||
if anchor_interval_s > 0.0
|
||||
else measurement_delta_m / interval_s,
|
||||
),
|
||||
)
|
||||
if not self.software_drift_initialized:
|
||||
self.software_clock_drift_mps = fallback_drift_mps
|
||||
self.software_drift_initialized = True
|
||||
|
||||
predicted_bias_m = (
|
||||
self.software_bias_m
|
||||
+ self.software_clock_drift_mps * interval_s
|
||||
)
|
||||
innovation_m = measured_bias_m - predicted_bias_m
|
||||
if abs(innovation_m) > 1500.0:
|
||||
self.trace.rejected_observations += 1
|
||||
return False, False
|
||||
|
||||
innovation_m = max(-300.0, min(300.0, innovation_m))
|
||||
self.software_bias_m = (
|
||||
predicted_bias_m
|
||||
+ self.parameters.software_bias_gain * innovation_m
|
||||
)
|
||||
if observation.clock_drift_valid:
|
||||
self.software_clock_drift_mps += 0.25 * (
|
||||
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_previous_measurement_m = measured_bias_m
|
||||
self.software_error_s = -(
|
||||
self.software_bias_m - true_bias_m
|
||||
) / SPEED_OF_LIGHT_MPS
|
||||
self.software_updates += 1
|
||||
self.last_observation_time_s = observation.time_s
|
||||
coarse = (
|
||||
self.software_filter
|
||||
and self.software_updates < self.parameters.software_settle_updates
|
||||
)
|
||||
return True, coarse
|
||||
|
||||
def _normal_commit(self, measurement_error_q32: int) -> tuple[int, int]:
|
||||
raw_error_q32 = measurement_error_q32 - self.phase_q32
|
||||
raw_error_q32 = max(
|
||||
MIN_SIGNED_64, min(MAX_SIGNED_64, raw_error_q32)
|
||||
)
|
||||
|
||||
proportional = (raw_error_q32 * self.kp_q46) >> 46
|
||||
integrator_candidate = (
|
||||
self.integrator_q32
|
||||
+ ((raw_error_q32 * self.ki_q46) >> 46)
|
||||
)
|
||||
self.integrator_q32 = self._clamp(
|
||||
integrator_candidate, self.max_slew_q32
|
||||
)
|
||||
self.rate_word_q32 = self._clamp(
|
||||
self.integrator_q32 + proportional, self.max_slew_q32
|
||||
)
|
||||
|
||||
acquire = self.parameters.lock_acquire_ticks * Q32
|
||||
release = self.parameters.lock_release_ticks * Q32
|
||||
was_locked = self.locked
|
||||
if abs(raw_error_q32) > release:
|
||||
self.locked = False
|
||||
self.lock_good_updates = 0
|
||||
elif not self.locked:
|
||||
if abs(raw_error_q32) <= acquire:
|
||||
self.lock_good_updates += 1
|
||||
if (
|
||||
self.lock_good_updates
|
||||
>= self.parameters.lock_acquire_updates
|
||||
):
|
||||
self.locked = True
|
||||
self.lock_good_updates = 0
|
||||
else:
|
||||
self.lock_good_updates = 0
|
||||
if self.locked and not was_locked:
|
||||
self.trace.lock_events += 1
|
||||
elif was_locked and not self.locked:
|
||||
self.trace.unlock_events += 1
|
||||
return raw_error_q32, raw_error_q32
|
||||
|
||||
def update(self, observation: Observation) -> None:
|
||||
self._advance(observation.time_s)
|
||||
if self.adaptive and observation.quality.solution_valid:
|
||||
selected, changed = self.selector.update(observation.quality)
|
||||
if changed or selected != self.profile:
|
||||
self._apply_profile(selected)
|
||||
if not observation.quality.solution_valid:
|
||||
if (
|
||||
self.last_observation_time_s is None
|
||||
or observation.time_s - self.last_observation_time_s
|
||||
> REFERENCE_WATCHDOG_SEC
|
||||
):
|
||||
self.reference_watchdog_holdover = True
|
||||
self._record(observation, 0, 0)
|
||||
return
|
||||
if self.profile == Profile.HOLDOVER:
|
||||
self.reference_watchdog_holdover = True
|
||||
self._record(observation, 0, 0)
|
||||
return
|
||||
self._select_loop_parameters()
|
||||
|
||||
accepted, coarse = self._software_filter_update(observation)
|
||||
if not accepted:
|
||||
if (
|
||||
self.last_observation_time_s is None
|
||||
or observation.time_s - self.last_observation_time_s
|
||||
> REFERENCE_WATCHDOG_SEC
|
||||
):
|
||||
self.reference_watchdog_holdover = True
|
||||
self._record(observation, 0, 0)
|
||||
return
|
||||
measurement_error_q32 = self._seconds_to_phase_q32(
|
||||
self.software_error_s
|
||||
)
|
||||
if coarse or not self.aligned:
|
||||
was_locked = self.locked
|
||||
self.phase_q32 = measurement_error_q32
|
||||
self.rate_word_q32 = 0
|
||||
self.integrator_q32 = 0
|
||||
self.aligned = True
|
||||
self.locked = False
|
||||
self.lock_good_updates = 0
|
||||
self.trace.coarse_commits += 1
|
||||
if was_locked:
|
||||
self.trace.unlock_events += 1
|
||||
raw_error_q32 = 0
|
||||
control_error_q32 = 0
|
||||
self.reference_watchdog_holdover = True
|
||||
else:
|
||||
raw_error_q32, control_error_q32 = self._normal_commit(
|
||||
measurement_error_q32
|
||||
)
|
||||
self.trace.normal_commits += 1
|
||||
self.reference_watchdog_holdover = False
|
||||
self._record(observation, raw_error_q32, control_error_q32)
|
||||
|
||||
def _record(
|
||||
self,
|
||||
observation: Observation,
|
||||
raw_error_q32: int,
|
||||
control_error_q32: int,
|
||||
) -> None:
|
||||
self.trace.time_s.append(observation.time_s)
|
||||
self.trace.phase_error_s.append(
|
||||
self._phase_q32_to_seconds(self.phase_q32)
|
||||
)
|
||||
self.trace.raw_measurement_error_s.append(observation.timing_error_s)
|
||||
self.trace.software_filtered_error_s.append(self.software_error_s)
|
||||
self.trace.raw_loop_error_s.append(
|
||||
self._phase_q32_to_seconds(raw_error_q32)
|
||||
)
|
||||
self.trace.control_loop_error_s.append(
|
||||
self._phase_q32_to_seconds(control_error_q32)
|
||||
)
|
||||
self.trace.frequency_command_ppm.append(
|
||||
self.rate_word_q32 / Q32 * 1.0e6
|
||||
)
|
||||
self.trace.profile.append(self.profile)
|
||||
# STATUS.LOCKED is retained internally during holdover, but external
|
||||
# PPS validity is deliberately low. Scenario metrics use the valid
|
||||
# lock contract rather than counting a held stale state as usable.
|
||||
self.trace.locked.append(
|
||||
self.locked
|
||||
and self.profile != Profile.HOLDOVER
|
||||
and not self.reference_watchdog_holdover
|
||||
)
|
||||
|
||||
def finish(self, time_s: float) -> AdaptiveTrace:
|
||||
self._advance(time_s)
|
||||
return self.trace
|
||||
|
||||
|
||||
def run_observations(
|
||||
observations: Iterable[Observation],
|
||||
*,
|
||||
finish_time_s: float,
|
||||
oscillator_ppm: float = 1.65,
|
||||
adaptive: bool = True,
|
||||
software_filter: bool = True,
|
||||
receiver_clock_bias_m: float = 1200.0,
|
||||
receiver_clock_drift_mps: float = 475.0,
|
||||
) -> AdaptiveTrace:
|
||||
model = FixedPointAdaptivePps(
|
||||
oscillator_ppm=oscillator_ppm,
|
||||
adaptive=adaptive,
|
||||
software_filter=software_filter,
|
||||
receiver_clock_bias_m=receiver_clock_bias_m,
|
||||
receiver_clock_drift_mps=receiver_clock_drift_mps,
|
||||
)
|
||||
for observation in observations:
|
||||
model.update(observation)
|
||||
return model.finish(finish_time_s)
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Embedded-oriented PPS closed-loop scenario and Monte-Carlo analysis.
|
||||
|
||||
This is intentionally deterministic and dependency-free. It exercises the
|
||||
same Q32/Q46 controller, quality hysteresis, Doppler-aided software pre-filter,
|
||||
runtime profile changes, lock detector and 125 MHz edge quantization used by
|
||||
the implementation plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
import math
|
||||
import random
|
||||
import statistics
|
||||
from typing import Callable
|
||||
|
||||
try:
|
||||
from .adaptive_pps import (
|
||||
Observation,
|
||||
Profile,
|
||||
QualityMetrics,
|
||||
run_observations,
|
||||
)
|
||||
except ImportError:
|
||||
from adaptive_pps import (
|
||||
Observation,
|
||||
Profile,
|
||||
QualityMetrics,
|
||||
run_observations,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scenario:
|
||||
name: str
|
||||
duration_s: float
|
||||
seeds: int
|
||||
oscillator_ppm: float
|
||||
noise_sigma: Callable[[float], float]
|
||||
quality_profile: Callable[[float], Profile]
|
||||
gaps: tuple[tuple[float, float], ...] = ()
|
||||
timing_step: Callable[[float], float] = lambda _time_s: 0.0
|
||||
outlier_period: int = 0
|
||||
outlier_size_s: float = 0.0
|
||||
drift_sigma_mps: float = 0.25
|
||||
drift_valid: bool = True
|
||||
drift_valid_profile: Callable[[float], bool] | None = None
|
||||
drop_period: int = 0
|
||||
invalid_intervals: tuple[tuple[float, float], ...] = ()
|
||||
reference_range_rate: Callable[[float], float] = lambda _time_s: 0.0
|
||||
reference_id: Callable[[float], int] = lambda _time_s: 1
|
||||
reference_datum_offset: Callable[[float], float] = (
|
||||
lambda _time_s: 0.0
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScenarioResult:
|
||||
name: str
|
||||
lock_success: int
|
||||
runs: int
|
||||
first_lock_p95_s: float
|
||||
settle_p95_s: float
|
||||
phase_rms_median_ns: float
|
||||
edge_jitter_median_ns: float
|
||||
edge_jitter_p95_ns: float
|
||||
unlock_total: int
|
||||
coarse_total: int
|
||||
rejected_total: int
|
||||
|
||||
|
||||
def quality(profile: Profile) -> QualityMetrics:
|
||||
if profile == Profile.PRECISION:
|
||||
return QualityMetrics(
|
||||
1.7, 3.2, 18.0, 38.0, 27.0, 0.35, 0.25, 7
|
||||
)
|
||||
if profile == Profile.ROBUST:
|
||||
return QualityMetrics(
|
||||
5.0, 8.0, 65.0, 27.0, 18.0, 0.85, 1.8, 5
|
||||
)
|
||||
return QualityMetrics(
|
||||
12.0, 18.0, 180.0, 18.0, 10.0, 2.0, 4.0, 4
|
||||
)
|
||||
|
||||
|
||||
def _percentile(values: list[float], percentile: float) -> float:
|
||||
if not values:
|
||||
return math.nan
|
||||
ordered = sorted(values)
|
||||
position = (len(ordered) - 1) * percentile
|
||||
lower = math.floor(position)
|
||||
upper = math.ceil(position)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
fraction = position - lower
|
||||
return ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction
|
||||
|
||||
|
||||
def observations(scenario: Scenario, seed: int) -> list[Observation]:
|
||||
rng = random.Random(seed)
|
||||
result: list[Observation] = []
|
||||
updates = round(scenario.duration_s / 0.2)
|
||||
for update in range(updates):
|
||||
time_s = update * 0.2
|
||||
if any(start <= time_s < stop for start, stop in scenario.gaps):
|
||||
continue
|
||||
if scenario.drop_period and update % scenario.drop_period == 0:
|
||||
continue
|
||||
timing_error_s = (
|
||||
rng.gauss(0.0, scenario.noise_sigma(time_s))
|
||||
+ scenario.timing_step(time_s)
|
||||
)
|
||||
if scenario.outlier_period and update % scenario.outlier_period == 0:
|
||||
timing_error_s += rng.choice((-1.0, 1.0)) * scenario.outlier_size_s
|
||||
quality_metrics = quality(scenario.quality_profile(time_s))
|
||||
solution_valid = not any(
|
||||
start <= time_s < stop
|
||||
for start, stop in scenario.invalid_intervals
|
||||
)
|
||||
if not solution_valid:
|
||||
quality_metrics = replace(
|
||||
quality_metrics,
|
||||
solution_valid=False,
|
||||
used_satellites=2,
|
||||
velocity_valid=False,
|
||||
)
|
||||
drift_valid = (
|
||||
scenario.drift_valid_profile(time_s)
|
||||
if scenario.drift_valid_profile is not None
|
||||
else scenario.drift_valid
|
||||
)
|
||||
if not drift_valid:
|
||||
quality_metrics = replace(
|
||||
quality_metrics,
|
||||
velocity_valid=False,
|
||||
velocity_residual_rms_mps=0.0,
|
||||
)
|
||||
result.append(
|
||||
Observation(
|
||||
time_s=time_s,
|
||||
timing_error_s=timing_error_s,
|
||||
clock_drift_error_mps=rng.gauss(
|
||||
0.0, scenario.drift_sigma_mps
|
||||
),
|
||||
quality=quality_metrics,
|
||||
clock_drift_valid=drift_valid,
|
||||
reference_range_rate_mps=scenario.reference_range_rate(
|
||||
time_s
|
||||
),
|
||||
reference_id=scenario.reference_id(time_s),
|
||||
reference_datum_offset_m=scenario.reference_datum_offset(
|
||||
time_s
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run_scenario(scenario: Scenario) -> ScenarioResult:
|
||||
traces = [
|
||||
run_observations(
|
||||
observations(scenario, seed),
|
||||
finish_time_s=scenario.duration_s,
|
||||
oscillator_ppm=scenario.oscillator_ppm,
|
||||
)
|
||||
for seed in range(1, scenario.seeds + 1)
|
||||
]
|
||||
first_locks = [
|
||||
value
|
||||
for trace in traces
|
||||
if (value := trace.first_lock_time()) is not None
|
||||
]
|
||||
settle_times = [
|
||||
value
|
||||
for trace in traces
|
||||
if (
|
||||
value := trace.settling_time(
|
||||
rms_limit_s=100.0e-9, window_s=10.0
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
analysis_start_s = max(30.0, scenario.duration_s * 0.70)
|
||||
phase_rms = [
|
||||
value * 1.0e9
|
||||
for trace in traces
|
||||
if math.isfinite(
|
||||
value := trace.phase_rms(after_s=analysis_start_s)
|
||||
)
|
||||
]
|
||||
edge_jitter = [
|
||||
value * 1.0e9
|
||||
for trace in traces
|
||||
if math.isfinite(
|
||||
value := trace.one_second_edge_jitter_rms(
|
||||
after_s=analysis_start_s
|
||||
)
|
||||
)
|
||||
]
|
||||
return ScenarioResult(
|
||||
name=scenario.name,
|
||||
lock_success=len(first_locks),
|
||||
runs=len(traces),
|
||||
first_lock_p95_s=_percentile(first_locks, 0.95),
|
||||
settle_p95_s=_percentile(settle_times, 0.95),
|
||||
phase_rms_median_ns=(
|
||||
statistics.median(phase_rms) if phase_rms else math.nan
|
||||
),
|
||||
edge_jitter_median_ns=(
|
||||
statistics.median(edge_jitter) if edge_jitter else math.nan
|
||||
),
|
||||
edge_jitter_p95_ns=_percentile(edge_jitter, 0.95),
|
||||
unlock_total=sum(trace.unlock_events for trace in traces),
|
||||
coarse_total=sum(trace.coarse_commits for trace in traces),
|
||||
rejected_total=sum(trace.rejected_observations for trace in traces),
|
||||
)
|
||||
|
||||
|
||||
def scenario_matrix() -> tuple[Scenario, ...]:
|
||||
precision = lambda _time_s: Profile.PRECISION
|
||||
robust = lambda _time_s: Profile.ROBUST
|
||||
return (
|
||||
Scenario(
|
||||
"clean_precision", 120.0, 30, 1.65,
|
||||
lambda _time_s: 50.0e-9, precision,
|
||||
),
|
||||
Scenario(
|
||||
"board_noise_outliers", 180.0, 50, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
outlier_period=41, outlier_size_s=900.0e-9,
|
||||
),
|
||||
Scenario(
|
||||
"weak_geometry_robust", 240.0, 30, 1.65,
|
||||
lambda _time_s: 650.0e-9, robust,
|
||||
outlier_period=37, outlier_size_s=1.2e-6,
|
||||
drift_sigma_mps=0.5,
|
||||
),
|
||||
Scenario(
|
||||
"short_dropouts", 180.0, 30, -3.0,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
drop_period=7,
|
||||
),
|
||||
Scenario(
|
||||
"long_wls_gap", 180.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
gaps=((60.0, 75.0),),
|
||||
),
|
||||
Scenario(
|
||||
"quality_transitions", 240.0, 30, 2.5,
|
||||
lambda time_s: (
|
||||
320.0e-9
|
||||
if time_s < 70.0 or time_s >= 150.0
|
||||
else 650.0e-9
|
||||
),
|
||||
lambda time_s: (
|
||||
Profile.PRECISION
|
||||
if time_s < 70.0 or time_s >= 150.0
|
||||
else (
|
||||
Profile.ROBUST
|
||||
if time_s < 120.0
|
||||
else Profile.HOLDOVER
|
||||
)
|
||||
),
|
||||
outlier_period=43, outlier_size_s=900.0e-9,
|
||||
),
|
||||
Scenario(
|
||||
"startup_noisy_then_clean", 240.0, 30, 1.65,
|
||||
lambda time_s: 1.1e-6 if time_s < 55.0 else 120.0e-9,
|
||||
lambda time_s: (
|
||||
Profile.ROBUST if time_s < 55.0 else Profile.PRECISION
|
||||
),
|
||||
outlier_period=47, outlier_size_s=1.4e-6,
|
||||
drift_sigma_mps=0.5,
|
||||
),
|
||||
Scenario(
|
||||
"noise_bursts_then_recover", 300.0, 30, -2.5,
|
||||
lambda time_s: (
|
||||
900.0e-9
|
||||
if (
|
||||
55.0 <= time_s < 78.0
|
||||
or 135.0 <= time_s < 151.0
|
||||
or 205.0 <= time_s < 214.0
|
||||
)
|
||||
else 150.0e-9
|
||||
),
|
||||
lambda time_s: (
|
||||
Profile.ROBUST
|
||||
if (
|
||||
55.0 <= time_s < 78.0
|
||||
or 135.0 <= time_s < 151.0
|
||||
or 205.0 <= time_s < 214.0
|
||||
)
|
||||
else Profile.PRECISION
|
||||
),
|
||||
outlier_period=53, outlier_size_s=1.2e-6,
|
||||
),
|
||||
Scenario(
|
||||
"satellite_loss_recovery", 260.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
invalid_intervals=((55.0, 78.0), (145.0, 151.0)),
|
||||
),
|
||||
Scenario(
|
||||
"doppler_loss_recovery", 240.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
drift_valid_profile=lambda time_s: not (
|
||||
65.0 <= time_s < 105.0
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
"reference_range_rate", 180.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
reference_range_rate=lambda _time_s: 350.0,
|
||||
),
|
||||
Scenario(
|
||||
"reference_switch", 220.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
reference_range_rate=lambda time_s: (
|
||||
180.0 if time_s < 70.0 else -90.0
|
||||
),
|
||||
reference_id=lambda time_s: 3 if time_s < 70.0 else 8,
|
||||
reference_datum_offset=lambda time_s: (
|
||||
0.0 if time_s < 70.0 else 38000.0
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
"multipath_step", 240.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
timing_step=lambda time_s: (
|
||||
300.0e-9 if 70.0 <= time_s < 120.0 else 0.0
|
||||
),
|
||||
),
|
||||
Scenario(
|
||||
"no_doppler_drift", 180.0, 30, 1.65,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
drift_valid=False,
|
||||
),
|
||||
Scenario(
|
||||
"near_slew_limit", 240.0, 30, 18.0,
|
||||
lambda _time_s: 320.0e-9, precision,
|
||||
),
|
||||
Scenario(
|
||||
"beyond_slew_limit", 120.0, 10, 25.0,
|
||||
lambda _time_s: 50.0e-9, precision,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate(results: list[ScenarioResult]) -> None:
|
||||
by_name = {result.name: result for result in results}
|
||||
for name in (
|
||||
"clean_precision",
|
||||
"board_noise_outliers",
|
||||
"weak_geometry_robust",
|
||||
"short_dropouts",
|
||||
"long_wls_gap",
|
||||
"quality_transitions",
|
||||
"startup_noisy_then_clean",
|
||||
"noise_bursts_then_recover",
|
||||
"satellite_loss_recovery",
|
||||
"doppler_loss_recovery",
|
||||
"reference_range_rate",
|
||||
"reference_switch",
|
||||
"multipath_step",
|
||||
"no_doppler_drift",
|
||||
"near_slew_limit",
|
||||
):
|
||||
result = by_name[name]
|
||||
if result.lock_success != result.runs:
|
||||
raise AssertionError(f"{name}: not every run acquired lock")
|
||||
if by_name["clean_precision"].first_lock_p95_s >= 15.0:
|
||||
raise AssertionError("clean acquisition exceeded 15 s")
|
||||
if by_name["board_noise_outliers"].edge_jitter_p95_ns >= 20.0:
|
||||
raise AssertionError("board-noise p95 edge jitter exceeded 20 ns")
|
||||
if by_name["near_slew_limit"].settle_p95_s >= 120.0:
|
||||
raise AssertionError("near-limit oscillator did not settle in 120 s")
|
||||
if by_name["beyond_slew_limit"].lock_success != 0:
|
||||
raise AssertionError("out-of-range oscillator falsely claimed lock")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
results = [run_scenario(scenario) for scenario in scenario_matrix()]
|
||||
print(
|
||||
"scenario lock lock_p95 settle_p95 "
|
||||
"phase_rms edge_jit_med edge_jit_p95 unlock coarse reject"
|
||||
)
|
||||
for result in results:
|
||||
print(
|
||||
f"{result.name:27s} "
|
||||
f"{result.lock_success:2d}/{result.runs:<2d} "
|
||||
f"{result.first_lock_p95_s:8.2f}s "
|
||||
f"{result.settle_p95_s:8.2f}s "
|
||||
f"{result.phase_rms_median_ns:8.2f}ns "
|
||||
f"{result.edge_jitter_median_ns:8.2f}ns "
|
||||
f"{result.edge_jitter_p95_ns:8.2f}ns "
|
||||
f"{result.unlock_total:6d} "
|
||||
f"{result.coarse_total:6d} "
|
||||
f"{result.rejected_total:6d}"
|
||||
)
|
||||
validate(results)
|
||||
print("PPS scenario matrix passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -23,9 +23,9 @@ import numpy as np
|
||||
|
||||
|
||||
C = 299_792_458.0
|
||||
RF_HZ = 16_368_000.0
|
||||
RF_HZ = 16_367_981.0
|
||||
TRACKING_HZ = 125_000_000.0
|
||||
SAMPLES_PER_CODE = 16_368.0 # samples per millisecond in the C code
|
||||
SAMPLES_PER_CODE = 16_367.981 # physical RF samples/ms used by the C code
|
||||
CODE_FREQ_BASIS = 1.023e6
|
||||
START_OFFSET_MS = 68.802
|
||||
START_OFFSET_S = START_OFFSET_MS / 1000.0
|
||||
@@ -139,6 +139,7 @@ class EpochResult:
|
||||
transmit_true_s: float
|
||||
reference_arrival_true_s: float
|
||||
snapshot_ready_true_s: float
|
||||
reference_tracking_tick_raw: int
|
||||
reference_tracking_tick: float
|
||||
reference_abs_sample: float
|
||||
reference_svid: int
|
||||
@@ -642,10 +643,12 @@ class ReceiverEpochSimulator:
|
||||
tracking_tick_continuous = float(self.tracking_clock.ticks_at(raw_event_true_s))
|
||||
tracking_tick_raw = int(np.floor(tracking_tick_continuous))
|
||||
# The software correction refers to the code boundary before the raw
|
||||
# RF edge. Apply the same time displacement to the tracking timestamp.
|
||||
tracking_tick_corrected = tracking_tick_raw - (
|
||||
rem / CODE_FREQ_BASIS
|
||||
) * (TRACKING_HZ / RF_HZ)
|
||||
# RF edge. The mailbox accepts an integer 125 MHz epoch counter, so
|
||||
# mirror the C implementation's nearest-tick rounding.
|
||||
capture_correction_ticks = round(
|
||||
(rem / CODE_FREQ_BASIS) * (TRACKING_HZ / RF_HZ)
|
||||
)
|
||||
tracking_tick_corrected = tracking_tick_raw - capture_correction_ticks
|
||||
return ChannelMeasurement(
|
||||
satellite=satellite,
|
||||
satellite_position_m=satellite.ecef_m.copy(),
|
||||
@@ -725,6 +728,7 @@ class ReceiverEpochSimulator:
|
||||
transmit_true_s=transmit_true_s,
|
||||
reference_arrival_true_s=reference.arrival_true_s,
|
||||
snapshot_ready_true_s=snapshot_ready,
|
||||
reference_tracking_tick_raw=reference.tracking_tick_raw,
|
||||
reference_tracking_tick=reference.tracking_tick_corrected,
|
||||
reference_abs_sample=reference.corrected_abs_unwrapped,
|
||||
reference_svid=reference.satellite.svid,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Monte-Carlo sweep for noisy/missing WLS adaptive PPS scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import statistics
|
||||
|
||||
try:
|
||||
from .adaptive_pps import Observation, Profile, QualityMetrics, run_observations
|
||||
except ImportError:
|
||||
from adaptive_pps import Observation, Profile, QualityMetrics, run_observations
|
||||
|
||||
|
||||
def metrics(profile: Profile) -> QualityMetrics:
|
||||
if profile == Profile.PRECISION:
|
||||
return QualityMetrics(1.7, 3.2, 18.0, 38.0, 27.0, 0.35, 0.25, 7)
|
||||
if profile == Profile.ROBUST:
|
||||
return QualityMetrics(5.0, 8.0, 65.0, 27.0, 18.0, 0.85, 1.8, 5)
|
||||
return QualityMetrics(12.0, 18.0, 180.0, 18.0, 10.0, 2.0, 4.0, 4)
|
||||
|
||||
|
||||
def scenario(seed: int, duration_s: float = 180.0) -> list[Observation]:
|
||||
rng = random.Random(seed)
|
||||
result = []
|
||||
for update in range(round(duration_s / 0.2)):
|
||||
time_s = update * 0.2
|
||||
if 70.0 <= time_s < 82.0:
|
||||
continue
|
||||
profile = Profile.PRECISION
|
||||
if 35.0 <= time_s < 45.0 or 110.0 <= time_s < 125.0:
|
||||
profile = Profile.ROBUST
|
||||
noise_sigma = 320.0e-9 if profile == Profile.PRECISION else 650.0e-9
|
||||
noise_s = rng.gauss(0.0, noise_sigma)
|
||||
if update % 41 == 0:
|
||||
noise_s += rng.choice((-1.0, 1.0)) * 900.0e-9
|
||||
result.append(
|
||||
Observation(
|
||||
time_s,
|
||||
noise_s,
|
||||
rng.gauss(0.0, 0.3),
|
||||
metrics(profile),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def summarize(name: str, traces) -> None:
|
||||
first_locks = [
|
||||
trace.first_lock_time()
|
||||
for trace in traces
|
||||
if trace.first_lock_time() is not None
|
||||
]
|
||||
tail_rms_ns = []
|
||||
jitter_rms_ns = []
|
||||
for trace in traces:
|
||||
tail = trace.phase_error_s[len(trace.phase_error_s) // 2 :]
|
||||
if tail:
|
||||
tail_rms_ns.append(
|
||||
math.sqrt(sum(value * value for value in tail) / len(tail))
|
||||
* 1.0e9
|
||||
)
|
||||
jitter = trace.one_second_jitter_rms(after_s=90.0)
|
||||
if math.isfinite(jitter):
|
||||
jitter_rms_ns.append(jitter * 1.0e9)
|
||||
first_lock_p95 = (
|
||||
statistics.quantiles(first_locks, n=20)[18]
|
||||
if len(first_locks) >= 2
|
||||
else (first_locks[0] if first_locks else math.nan)
|
||||
)
|
||||
tail_rms_median = (
|
||||
statistics.median(tail_rms_ns) if tail_rms_ns else math.nan
|
||||
)
|
||||
jitter_rms_median = (
|
||||
statistics.median(jitter_rms_ns) if jitter_rms_ns else math.nan
|
||||
)
|
||||
print(
|
||||
f"{name}: lock_success={len(first_locks)}/{len(traces)} "
|
||||
f"first_lock_p95={first_lock_p95:.2f}s "
|
||||
f"tail_rms_median={tail_rms_median:.2f}ns "
|
||||
f"one_second_jitter_median={jitter_rms_median:.2f}ns "
|
||||
f"unlock_total={sum(trace.unlock_events for trace in traces)}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
seeds = range(1, 51)
|
||||
raw = [
|
||||
run_observations(
|
||||
scenario(seed),
|
||||
finish_time_s=180.0,
|
||||
adaptive=False,
|
||||
software_filter=False,
|
||||
)
|
||||
for seed in seeds
|
||||
]
|
||||
adaptive = [
|
||||
run_observations(scenario(seed), finish_time_s=180.0)
|
||||
for seed in seeds
|
||||
]
|
||||
summarize("raw fixed PI", raw)
|
||||
summarize("adaptive fixed-point PPS", adaptive)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from pps_python.adaptive_pps import (
|
||||
ACQUIRE_PROFILE,
|
||||
Observation,
|
||||
Profile,
|
||||
PROFILES,
|
||||
QualityMetrics,
|
||||
QualitySelector,
|
||||
run_observations,
|
||||
)
|
||||
|
||||
|
||||
def quality(profile: Profile) -> QualityMetrics:
|
||||
if profile == Profile.PRECISION:
|
||||
return QualityMetrics(
|
||||
1.7, 3.2, 18.0, 38.0, 27.0, 0.35, 0.25, 7
|
||||
)
|
||||
if profile == Profile.ROBUST:
|
||||
return QualityMetrics(
|
||||
5.0, 8.0, 65.0, 27.0, 18.0, 0.85, 1.8, 5
|
||||
)
|
||||
return QualityMetrics(
|
||||
12.0, 18.0, 180.0, 18.0, 10.0, 2.0, 4.0, 4
|
||||
)
|
||||
|
||||
|
||||
def noisy_observations(
|
||||
duration_s: float,
|
||||
*,
|
||||
seed: int,
|
||||
missing: tuple[float, float] | None = None,
|
||||
quality_profile: Profile = Profile.PRECISION,
|
||||
):
|
||||
rng = random.Random(seed)
|
||||
observations = []
|
||||
update = 0
|
||||
while update * 0.2 < duration_s:
|
||||
time_s = update * 0.2
|
||||
update += 1
|
||||
if missing is not None and missing[0] <= time_s < missing[1]:
|
||||
continue
|
||||
noise_s = rng.gauss(0.0, 320.0e-9)
|
||||
if update % 37 == 0:
|
||||
noise_s += rng.choice((-1.0, 1.0)) * 900.0e-9
|
||||
observations.append(
|
||||
Observation(
|
||||
time_s,
|
||||
noise_s,
|
||||
rng.gauss(0.0, 0.25),
|
||||
quality(quality_profile),
|
||||
)
|
||||
)
|
||||
return observations
|
||||
|
||||
|
||||
class AdaptivePpsTests(unittest.TestCase):
|
||||
def test_quality_hysteresis_rejects_metric_chatter(self):
|
||||
selector = QualitySelector()
|
||||
for _ in range(7):
|
||||
self.assertEqual(selector.update(quality(Profile.PRECISION))[0],
|
||||
Profile.HOLDOVER)
|
||||
self.assertEqual(selector.update(quality(Profile.PRECISION))[0],
|
||||
Profile.PRECISION)
|
||||
|
||||
# A one-epoch DOP/CN0 jump must not switch coefficients.
|
||||
self.assertEqual(selector.update(quality(Profile.ROBUST))[0],
|
||||
Profile.PRECISION)
|
||||
self.assertEqual(selector.update(quality(Profile.PRECISION))[0],
|
||||
Profile.PRECISION)
|
||||
|
||||
for _ in range(2):
|
||||
self.assertEqual(selector.update(quality(Profile.ROBUST))[0],
|
||||
Profile.PRECISION)
|
||||
self.assertEqual(selector.update(quality(Profile.ROBUST))[0],
|
||||
Profile.ROBUST)
|
||||
|
||||
invalid = quality(Profile.PRECISION)
|
||||
invalid = QualityMetrics(**{
|
||||
**invalid.__dict__, "solution_valid": False
|
||||
})
|
||||
for _ in range(2):
|
||||
self.assertEqual(selector.update(invalid)[0], Profile.ROBUST)
|
||||
self.assertEqual(selector.update(invalid)[0], Profile.HOLDOVER)
|
||||
|
||||
def test_unfiltered_board_noise_does_not_meet_lock_contract(self):
|
||||
observations = noisy_observations(90.0, seed=17)
|
||||
trace = run_observations(
|
||||
observations,
|
||||
finish_time_s=90.0,
|
||||
adaptive=False,
|
||||
software_filter=False,
|
||||
)
|
||||
self.assertLess(trace.locked_fraction(), 0.25)
|
||||
self.assertIsNone(trace.first_lock_time())
|
||||
|
||||
def test_adaptive_filters_lock_noisy_wls(self):
|
||||
observations = noisy_observations(120.0, seed=17)
|
||||
trace = run_observations(observations, finish_time_s=120.0)
|
||||
first_lock = trace.first_lock_time()
|
||||
self.assertIsNotNone(first_lock)
|
||||
self.assertLess(first_lock, 20.0)
|
||||
tail = trace.phase_error_s[len(trace.phase_error_s) // 2 :]
|
||||
rms = math.sqrt(sum(value * value for value in tail) / len(tail))
|
||||
self.assertLess(rms, 120.0e-9)
|
||||
self.assertLess(
|
||||
trace.one_second_jitter_rms(after_s=60.0),
|
||||
20.0e-9,
|
||||
)
|
||||
self.assertGreater(trace.locked_fraction(), 0.80)
|
||||
self.assertLessEqual(trace.unlock_events, 1)
|
||||
|
||||
def test_bounded_wls_gap_holds_then_resumes_without_coarse_step(self):
|
||||
observations = noisy_observations(
|
||||
100.0, seed=23, missing=(35.0, 50.0)
|
||||
)
|
||||
trace = run_observations(observations, finish_time_s=100.0)
|
||||
baseline = run_observations(
|
||||
noisy_observations(100.0, seed=23),
|
||||
finish_time_s=100.0,
|
||||
)
|
||||
before_gap = [
|
||||
locked for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if 30.0 <= time_s < 35.0
|
||||
]
|
||||
self.assertTrue(before_gap and all(before_gap))
|
||||
# The two-second watchdog suppresses PPS validity during the gap, but
|
||||
# a bounded outage with the same datum retains the Doppler/PI states.
|
||||
# Recovery therefore needs no extra coarse commit or phase step.
|
||||
just_after_gap = [
|
||||
locked for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if 50.0 <= time_s < 51.0
|
||||
]
|
||||
self.assertTrue(just_after_gap and all(just_after_gap))
|
||||
late = [
|
||||
locked for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if time_s >= 70.0
|
||||
]
|
||||
self.assertTrue(late and sum(late) / len(late) > 0.9)
|
||||
self.assertEqual(trace.coarse_commits, baseline.coarse_commits)
|
||||
|
||||
def test_very_long_wls_gap_reanchors_coarsely(self):
|
||||
observations = noisy_observations(
|
||||
130.0, seed=29, missing=(35.0, 70.0)
|
||||
)
|
||||
trace = run_observations(observations, finish_time_s=130.0)
|
||||
after_gap = [
|
||||
locked for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if 70.0 <= time_s < 75.0
|
||||
]
|
||||
late = [
|
||||
locked for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if time_s >= 90.0
|
||||
]
|
||||
self.assertTrue(after_gap and not any(after_gap))
|
||||
self.assertTrue(late and sum(late) / len(late) > 0.9)
|
||||
self.assertGreaterEqual(trace.coarse_commits, 2)
|
||||
|
||||
def test_no_wls_never_claims_lock(self):
|
||||
trace = run_observations([], finish_time_s=60.0)
|
||||
self.assertIsNone(trace.first_lock_time())
|
||||
self.assertEqual(trace.lock_events, 0)
|
||||
|
||||
def test_satellite_loss_invalidates_then_resumes_predicted_state(self):
|
||||
observations = noisy_observations(110.0, seed=41)
|
||||
invalid_quality = QualityMetrics(
|
||||
**{
|
||||
**quality(Profile.PRECISION).__dict__,
|
||||
"solution_valid": False,
|
||||
"used_satellites": 2,
|
||||
"velocity_valid": False,
|
||||
}
|
||||
)
|
||||
observations = [
|
||||
Observation(
|
||||
item.time_s,
|
||||
item.timing_error_s,
|
||||
item.clock_drift_error_mps,
|
||||
(
|
||||
invalid_quality
|
||||
if 40.0 <= item.time_s < 55.0
|
||||
else item.quality
|
||||
),
|
||||
item.clock_drift_valid,
|
||||
)
|
||||
for item in observations
|
||||
]
|
||||
trace = run_observations(observations, finish_time_s=110.0)
|
||||
baseline = run_observations(
|
||||
noisy_observations(110.0, seed=41),
|
||||
finish_time_s=110.0,
|
||||
)
|
||||
during_loss = [
|
||||
valid
|
||||
for time_s, valid in zip(trace.time_s, trace.locked)
|
||||
if 42.2 <= time_s < 55.0
|
||||
]
|
||||
after_recovery = [
|
||||
valid
|
||||
for time_s, valid in zip(trace.time_s, trace.locked)
|
||||
if time_s >= 75.0
|
||||
]
|
||||
self.assertTrue(during_loss and not any(during_loss))
|
||||
self.assertTrue(after_recovery and sum(after_recovery) / len(after_recovery) > 0.9)
|
||||
self.assertEqual(trace.coarse_commits, baseline.coarse_commits)
|
||||
|
||||
def test_wls_bias_drift_is_negative_reference_range_rate(self):
|
||||
observations = [
|
||||
Observation(
|
||||
item.time_s,
|
||||
item.timing_error_s,
|
||||
item.clock_drift_error_mps,
|
||||
item.quality,
|
||||
item.clock_drift_valid,
|
||||
350.0,
|
||||
)
|
||||
for item in noisy_observations(120.0, seed=53)
|
||||
]
|
||||
trace = run_observations(observations, finish_time_s=120.0)
|
||||
self.assertIsNotNone(trace.first_lock_time())
|
||||
self.assertLess(trace.first_lock_time(), 20.0)
|
||||
self.assertLess(
|
||||
trace.one_second_jitter_rms(after_s=60.0),
|
||||
20.0e-9,
|
||||
)
|
||||
|
||||
def test_reference_switch_reanchors_instead_of_rejecting_datum_jump(self):
|
||||
observations = [
|
||||
Observation(
|
||||
item.time_s,
|
||||
item.timing_error_s,
|
||||
item.clock_drift_error_mps,
|
||||
item.quality,
|
||||
item.clock_drift_valid,
|
||||
180.0 if item.time_s < 50.0 else -90.0,
|
||||
3 if item.time_s < 50.0 else 8,
|
||||
0.0 if item.time_s < 50.0 else 38000.0,
|
||||
)
|
||||
for item in noisy_observations(120.0, seed=59)
|
||||
]
|
||||
trace = run_observations(observations, finish_time_s=120.0)
|
||||
just_after_switch = [
|
||||
locked
|
||||
for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if 50.0 <= time_s < 54.0
|
||||
]
|
||||
recovered = [
|
||||
locked
|
||||
for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if time_s >= 75.0
|
||||
]
|
||||
self.assertTrue(just_after_switch and not any(just_after_switch))
|
||||
self.assertTrue(recovered and sum(recovered) / len(recovered) > 0.9)
|
||||
self.assertGreaterEqual(trace.coarse_commits, 2)
|
||||
self.assertEqual(trace.rejected_observations, 0)
|
||||
|
||||
def test_isolated_invalid_wls_does_not_reset_loop(self):
|
||||
observations = noisy_observations(100.0, seed=61)
|
||||
baseline = run_observations(observations, finish_time_s=100.0)
|
||||
invalid = QualityMetrics(**{
|
||||
**quality(Profile.PRECISION).__dict__,
|
||||
"solution_valid": False,
|
||||
"used_satellites": 2,
|
||||
"velocity_valid": False,
|
||||
})
|
||||
observations = [
|
||||
Observation(
|
||||
item.time_s,
|
||||
item.timing_error_s,
|
||||
item.clock_drift_error_mps,
|
||||
invalid if abs(item.time_s - 50.0) < 1.0e-9 else item.quality,
|
||||
item.clock_drift_valid,
|
||||
)
|
||||
for item in observations
|
||||
]
|
||||
trace = run_observations(observations, finish_time_s=100.0)
|
||||
around_reject = [
|
||||
locked
|
||||
for time_s, locked in zip(trace.time_s, trace.locked)
|
||||
if 49.0 <= time_s <= 52.0 and abs(time_s - 50.0) > 1.0e-9
|
||||
]
|
||||
self.assertTrue(around_reject and all(around_reject))
|
||||
self.assertEqual(trace.coarse_commits, baseline.coarse_commits)
|
||||
|
||||
def test_receiver_clock_drift_is_not_part_of_relative_wls_datum(self):
|
||||
observations = noisy_observations(80.0, seed=67)
|
||||
slow_clock = run_observations(
|
||||
observations,
|
||||
finish_time_s=80.0,
|
||||
receiver_clock_drift_mps=0.0,
|
||||
)
|
||||
fast_clock = run_observations(
|
||||
observations,
|
||||
finish_time_s=80.0,
|
||||
receiver_clock_drift_mps=900.0,
|
||||
)
|
||||
self.assertEqual(slow_clock.phase_error_s, fast_clock.phase_error_s)
|
||||
self.assertEqual(
|
||||
slow_clock.frequency_command_ppm,
|
||||
fast_clock.frequency_command_ppm,
|
||||
)
|
||||
|
||||
def test_fixed_point_profiles_fit_register_widths(self):
|
||||
for profile in PROFILES.values():
|
||||
kp, ki, slew = profile.fixed_coefficients(0.2)
|
||||
self.assertLess(abs(kp), 1 << 47)
|
||||
self.assertLess(abs(ki), 1 << 47)
|
||||
self.assertLess(slew, 1 << 32)
|
||||
self.assertLessEqual(profile.lock_release_ticks, 255)
|
||||
self.assertLessEqual(profile.lock_acquire_updates, 255)
|
||||
kp, ki, slew = ACQUIRE_PROFILE.fixed_coefficients(0.2)
|
||||
self.assertLess(abs(kp), 1 << 47)
|
||||
self.assertLess(abs(ki), 1 << 47)
|
||||
self.assertLess(slew, 1 << 32)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,6 +79,27 @@ class PpsModelTests(unittest.TestCase):
|
||||
1000.0 * np.median(np.abs(timing_error)),
|
||||
)
|
||||
|
||||
def test_fractional_code_phase_is_applied_to_capture_counter(self):
|
||||
_, _, epochs = make_simulation(70.0)
|
||||
mature = [epoch for epoch in epochs if epoch.transmit_true_s >= 50.0]
|
||||
applied_s = np.asarray(
|
||||
[
|
||||
(
|
||||
epoch.reference_tracking_tick_raw
|
||||
- epoch.reference_tracking_tick
|
||||
)
|
||||
/ TRACKING_HZ
|
||||
for epoch in mature
|
||||
]
|
||||
)
|
||||
self.assertGreater(np.max(applied_s), 40.0e-9)
|
||||
self.assertTrue(np.all(applied_s >= 0.0))
|
||||
self.assertTrue(np.all(applied_s <= 64.0e-9))
|
||||
# The integer mailbox can represent the correction only on the 8 ns
|
||||
# PPS grid; the model must use the same nearest-tick quantization.
|
||||
applied_ticks = applied_s * TRACKING_HZ
|
||||
self.assertLess(np.max(np.abs(applied_ticks - np.rint(applied_ticks))), 1.0e-9)
|
||||
|
||||
def test_timestamped_pi_rejects_apb_latency_as_phase(self):
|
||||
_, tracking, epochs = make_simulation(70.0)
|
||||
config = PIConfig(natural_frequency_hz=0.035, damping=0.9, max_slew_ppm=20.0)
|
||||
|
||||
Reference in New Issue
Block a user