feat(pps): add adaptive disciplined timing loop

This commit is contained in:
2026-07-25 17:56:41 +03:00
parent 0f6b3482c5
commit ecbb8d0b68
39 changed files with 4505 additions and 96 deletions
+78 -12
View File
@@ -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
+718
View File
@@ -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())
+10 -6
View File
@@ -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()
+21
View File
@@ -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)