feat(pps): preserve loop dynamics across nav periods
This commit is contained in:
@@ -9,7 +9,8 @@ davranışlarını taklit eder:
|
||||
|
||||
- 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`.
|
||||
- Varsayılan navigation solve periyodu: `NAV_SOLVE_TIMER=10`, yani `200 ms`;
|
||||
period-aware loop doğrulaması ayrıca 500 ve 1000 ms'yi kapsar.
|
||||
- Ortak navigation bit transmit epoch'u ve uyduların farklı propagation
|
||||
delay'lerle art arda tracking olayı üretmesi.
|
||||
- Firmware'deki corrected sample denklemi:
|
||||
@@ -239,7 +240,7 @@ PI, raw 125 MHz sayacı değiştirmez. Ayrı GPS time accumulator'ın rate word'
|
||||
ayarlar:
|
||||
|
||||
```text
|
||||
integrator += Ki * phase_error * update_period
|
||||
integrator += Ki_discrete * phase_error
|
||||
rate_command = integrator + Kp * phase_error
|
||||
```
|
||||
|
||||
@@ -267,6 +268,64 @@ olması gerekir. Üretim profilleri tek seyrek WLS sıçramasında fiziksel puls
|
||||
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.
|
||||
|
||||
### NAV solve periyodu değiştiğinde loop dinamiği
|
||||
|
||||
`Ki` zaten ayrık-zaman katsayısıdır; aynı sayıyı 200, 500 ve 1000 ms'de
|
||||
kullanmak aynı loop değildir. Basitçe `Ki`'yi periyotla çarpmak da uzun
|
||||
periyotlarda mevcut RTL'nin gerçek kutuplarını yeterince doğru korumaz.
|
||||
|
||||
Firmware ve Python model şu uyumlu yöntemi kullanır:
|
||||
|
||||
1. Kartta doğrulanmış 200 ms Q2.46 katsayılarından RTL'nin iki ayrık kutbu
|
||||
çıkarılır.
|
||||
2. Yeni `T` periyodu için her kutup `T / 0.2` kuvvetine yükseltilir.
|
||||
3. Hedef kutuplardan, RTL'nin önce-integral-sonra-proportional commit sırasına
|
||||
uygun yeni `Kp` ve `Ki` geri hesaplanır.
|
||||
|
||||
Bu yöntem 200 ms'de eski katsayıları bit düzeyinde aynen üretir; 500 ve
|
||||
1000 ms'de aynı gerçek-zaman damping ve settling davranışını korur:
|
||||
|
||||
| NAV periyodu | profil | Kp Q2.46 | Ki Q2.46 |
|
||||
|---:|---|---:|---:|
|
||||
| 200 ms | acquire / precision / robust | 764018 / 222838 / 84891 | 64006 / 5445 / 640 |
|
||||
| 500 ms | acquire / precision / robust | 615780 / 209783 / 82980 | 127492 / 12804 / 1564 |
|
||||
| 1000 ms | acquire / precision / robust | 447388 / 190239 / 79923 | 178038 / 23149 / 3011 |
|
||||
|
||||
Toplam döngü yalnız RTL PI'den oluşmadığı için yazılım clock-bias, Doppler
|
||||
drift ve fallback drift kazançları da gerçek geçen süreyle
|
||||
`alpha_T = 1 - (1 - alpha_200ms)^(T/0.2)` biçiminde uygulanır. Quality/profile
|
||||
histerezisi, lock ve filter-settle sayaçları saniye cinsinden ölçeklenir;
|
||||
bağımsız örnek güvenliği için sırasıyla en az 2/2/3 update korunur. Reference
|
||||
watchdog `max(2 s, 3*T)` olur.
|
||||
|
||||
Desteklenen build değeri `GPS_NAV_SOLVE_TIMER_TICKS=N` için N, 300 bitlik
|
||||
LNAV subframe'i bölmelidir. Doğrulanan örnekler N=10, 25 ve 50'dir. N=40,
|
||||
HOW anchor yenilendiğinde timer fazını kaydırdığı için compile-time reddedilir.
|
||||
`POSTPONE_TOW=180 ms` NAV solve periyodundan bağımsızdır ve değişmez.
|
||||
|
||||
Örnek firmware build'leri:
|
||||
|
||||
```bash
|
||||
make -C gps_system RISCV_TOOLCHAIN=/tools/toolchains/rvtc-medany \
|
||||
GPS_NAV_SOLVE_TIMER_TICKS=10 all # 200 ms, varsayılan
|
||||
make -C gps_system RISCV_TOOLCHAIN=/tools/toolchains/rvtc-medany \
|
||||
GPS_NAV_SOLVE_TIMER_TICKS=25 all # 500 ms
|
||||
make -C gps_system RISCV_TOOLCHAIN=/tools/toolchains/rvtc-medany \
|
||||
GPS_NAV_SOLVE_TIMER_TICKS=50 all # 1000 ms
|
||||
```
|
||||
|
||||
Linux stability sweep'i aynı periyotları doğrudan kabul eder:
|
||||
|
||||
```bash
|
||||
python3 -m pps_end_to_end_validation.stability_analysis \
|
||||
--update-period-ms 500
|
||||
```
|
||||
|
||||
Kutup eşlemesi deterministik loop dinamiğini korur; daha seyrek WLS ölçümü
|
||||
aynı gürültü performansını garanti etmez. Örnek sayısı azalınca aliasing,
|
||||
outlier yakalama gecikmesi ve mutlak faz wander'ı değişebilir. Bu nedenle her
|
||||
kart/periyot kombinasyonu trusted PPS ile ayrıca ölçülmelidir.
|
||||
|
||||
## Adaptif PPS senaryo matrisi
|
||||
|
||||
Tam fixed-point/holdover matrisi:
|
||||
|
||||
@@ -8,7 +8,7 @@ rounding, lock acquisition/release and holdover/re-acquisition.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import IntEnum
|
||||
import math
|
||||
from typing import Iterable, Mapping
|
||||
@@ -16,7 +16,9 @@ from typing import Iterable, Mapping
|
||||
|
||||
TRACKING_HZ = 125_000_000
|
||||
SPEED_OF_LIGHT_MPS = 299_792_458.0
|
||||
REFERENCE_WATCHDOG_SEC = 2.0
|
||||
BASELINE_UPDATE_PERIOD_S = 0.2
|
||||
MIN_REFERENCE_WATCHDOG_SEC = 2.0
|
||||
REFERENCE_WATCHDOG_PERIODS = 3.0
|
||||
MAX_PREDICTION_GAP_SEC = 30.0
|
||||
Q32 = 1 << 32
|
||||
Q46 = 1 << 46
|
||||
@@ -24,6 +26,27 @@ MAX_SIGNED_64 = (1 << 63) - 1
|
||||
MIN_SIGNED_64 = -(1 << 63)
|
||||
|
||||
|
||||
def _period_updates(
|
||||
baseline_updates: int,
|
||||
update_period_s: float,
|
||||
minimum_updates: int,
|
||||
) -> int:
|
||||
if not math.isfinite(update_period_s) or update_period_s <= 0.0:
|
||||
raise ValueError("update_period_s must be finite and positive")
|
||||
duration_s = baseline_updates * BASELINE_UPDATE_PERIOD_S
|
||||
return min(
|
||||
255,
|
||||
max(minimum_updates, math.ceil(duration_s / update_period_s - 1e-12)),
|
||||
)
|
||||
|
||||
|
||||
def _elapsed_gain(baseline_gain: float, elapsed_s: float) -> float:
|
||||
periods = elapsed_s / BASELINE_UPDATE_PERIOD_S
|
||||
if abs(periods - 1.0) <= 1e-12:
|
||||
return baseline_gain
|
||||
return -math.expm1(periods * math.log1p(-baseline_gain))
|
||||
|
||||
|
||||
class Profile(IntEnum):
|
||||
HOLDOVER = 0
|
||||
ROBUST = 1
|
||||
@@ -58,14 +81,74 @@ class LoopProfile:
|
||||
software_settle_updates: int
|
||||
|
||||
def fixed_coefficients(self, update_period_s: float) -> tuple[int, int, int]:
|
||||
"""Map the proven 200 ms discrete poles to ``update_period_s``.
|
||||
|
||||
The 200 ms result is exactly the legacy production coefficient set.
|
||||
At another NAV cadence each baseline pole is raised to T/0.2 before
|
||||
recovering Kp/Ki for the RTL's integrate-then-proportional ordering.
|
||||
"""
|
||||
if not math.isfinite(update_period_s) or update_period_s <= 0.0:
|
||||
raise ValueError("update_period_s must be finite and positive")
|
||||
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
|
||||
baseline_kp = math.floor(
|
||||
(2.0 * self.damping * omega / TRACKING_HZ) * Q46 + 0.5
|
||||
) / Q46
|
||||
baseline_ki = math.floor(
|
||||
(
|
||||
omega * omega * BASELINE_UPDATE_PERIOD_S / TRACKING_HZ
|
||||
) * Q46 + 0.5
|
||||
) / Q46
|
||||
baseline_ticks = TRACKING_HZ * BASELINE_UPDATE_PERIOD_S
|
||||
baseline_sum = 2.0 - baseline_ticks * (
|
||||
baseline_kp + baseline_ki
|
||||
)
|
||||
baseline_product = 1.0 - baseline_ticks * baseline_kp
|
||||
discriminant = baseline_sum * baseline_sum - 4.0 * baseline_product
|
||||
period_ratio = update_period_s / BASELINE_UPDATE_PERIOD_S
|
||||
if discriminant >= 0.0:
|
||||
root = math.sqrt(discriminant)
|
||||
pole_a = 0.5 * (baseline_sum + root)
|
||||
pole_b = 0.5 * (baseline_sum - root)
|
||||
if pole_a < 0.0 or pole_b < 0.0:
|
||||
raise ValueError("baseline loop has an unmappable negative pole")
|
||||
target_a = pole_a**period_ratio
|
||||
target_b = pole_b**period_ratio
|
||||
target_sum = target_a + target_b
|
||||
target_product = target_a * target_b
|
||||
else:
|
||||
if baseline_product <= 0.0:
|
||||
raise ValueError("baseline loop has invalid complex poles")
|
||||
radius = math.sqrt(baseline_product)
|
||||
cosine = max(-1.0, min(1.0, baseline_sum / (2.0 * radius)))
|
||||
angle = math.acos(cosine)
|
||||
target_radius = radius**period_ratio
|
||||
target_sum = 2.0 * target_radius * math.cos(
|
||||
angle * period_ratio
|
||||
)
|
||||
target_product = target_radius * target_radius
|
||||
target_ticks = TRACKING_HZ * update_period_s
|
||||
kp = (1.0 - target_product) / target_ticks
|
||||
ki = (1.0 - target_sum + target_product) / target_ticks
|
||||
if not math.isfinite(kp) or not math.isfinite(ki) or kp < 0 or ki < 0:
|
||||
raise ValueError("mapped loop coefficients are invalid")
|
||||
kp_q46 = math.floor(kp * Q46 + 0.5)
|
||||
ki_q46 = math.floor(ki * Q46 + 0.5)
|
||||
max_slew_q32 = math.floor(
|
||||
self.max_slew_ppm * 1.0e-6 * Q32 + 0.5
|
||||
)
|
||||
max_slew_q32 = round(self.max_slew_ppm * 1.0e-6 * Q32)
|
||||
return kp_q46, ki_q46, max_slew_q32
|
||||
|
||||
def for_update_period(self, update_period_s: float) -> LoopProfile:
|
||||
return replace(
|
||||
self,
|
||||
lock_acquire_updates=_period_updates(
|
||||
self.lock_acquire_updates, update_period_s, 2
|
||||
),
|
||||
software_settle_updates=_period_updates(
|
||||
self.software_settle_updates, update_period_s, 3
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PROFILES = {
|
||||
Profile.HOLDOVER: LoopProfile(
|
||||
@@ -149,10 +232,12 @@ def classify_quality(metrics: QualityMetrics) -> Profile:
|
||||
class QualitySelector:
|
||||
"""Three-profile selector with asymmetric transition hysteresis."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, update_period_s: float = BASELINE_UPDATE_PERIOD_S) -> None:
|
||||
self.profile = Profile.HOLDOVER
|
||||
self.candidate = Profile.HOLDOVER
|
||||
self.candidate_updates = 0
|
||||
self.upgrade_updates = _period_updates(8, update_period_s, 2)
|
||||
self.degrade_updates = _period_updates(3, update_period_s, 2)
|
||||
|
||||
def update(self, metrics: QualityMetrics) -> tuple[Profile, bool]:
|
||||
target = classify_quality(metrics)
|
||||
@@ -165,7 +250,11 @@ class QualitySelector:
|
||||
self.candidate_updates = 1
|
||||
else:
|
||||
self.candidate_updates += 1
|
||||
required = 8 if target > self.profile else 3
|
||||
required = (
|
||||
self.upgrade_updates
|
||||
if target > self.profile
|
||||
else self.degrade_updates
|
||||
)
|
||||
if self.candidate_updates >= required:
|
||||
self.profile = target
|
||||
self.candidate_updates = 0
|
||||
@@ -339,19 +428,28 @@ class FixedPointAdaptivePps:
|
||||
software_drift_gain: float = 1.0 / 4.0,
|
||||
software_fallback_drift_gain: float = 1.0 / 16.0,
|
||||
) -> None:
|
||||
if not math.isfinite(update_period_s) or update_period_s <= 0.0:
|
||||
raise ValueError("update_period_s must be finite and positive")
|
||||
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.profiles = dict(PROFILES if profiles is None else profiles)
|
||||
baseline_profiles = dict(PROFILES if profiles is None else profiles)
|
||||
self.profiles = {
|
||||
profile: parameters.for_update_period(update_period_s)
|
||||
for profile, parameters in baseline_profiles.items()
|
||||
}
|
||||
missing_profiles = set(Profile) - self.profiles.keys()
|
||||
if missing_profiles:
|
||||
raise ValueError(f"missing loop profiles: {sorted(missing_profiles)}")
|
||||
self.acquire_profile = (
|
||||
baseline_acquire_profile = (
|
||||
ACQUIRE_PROFILE if acquire_profile is None else acquire_profile
|
||||
)
|
||||
self.acquire_profile = baseline_acquire_profile.for_update_period(
|
||||
update_period_s
|
||||
)
|
||||
if not 0.0 < software_drift_gain <= 1.0:
|
||||
raise ValueError("software_drift_gain must be in (0, 1]")
|
||||
if not 0.0 < software_fallback_drift_gain <= 1.0:
|
||||
@@ -360,7 +458,11 @@ class FixedPointAdaptivePps:
|
||||
)
|
||||
self.software_drift_gain = software_drift_gain
|
||||
self.software_fallback_drift_gain = software_fallback_drift_gain
|
||||
self.selector = QualitySelector()
|
||||
self.selector = QualitySelector(update_period_s)
|
||||
self.reference_watchdog_sec = max(
|
||||
MIN_REFERENCE_WATCHDOG_SEC,
|
||||
REFERENCE_WATCHDOG_PERIODS * update_period_s,
|
||||
)
|
||||
self.profile = Profile.HOLDOVER if adaptive else Profile.PRECISION
|
||||
self.parameters = self.profiles[self.profile]
|
||||
self.kp_q46, self.ki_q46, self.max_slew_q32 = (
|
||||
@@ -652,12 +754,21 @@ class FixedPointAdaptivePps:
|
||||
return False, False
|
||||
|
||||
innovation_m = max(-300.0, min(300.0, innovation_m))
|
||||
effective_bias_gain = _elapsed_gain(
|
||||
self.parameters.software_bias_gain, interval_s
|
||||
)
|
||||
effective_drift_gain = _elapsed_gain(
|
||||
self.software_drift_gain, interval_s
|
||||
)
|
||||
effective_fallback_drift_gain = _elapsed_gain(
|
||||
self.software_fallback_drift_gain, interval_s
|
||||
)
|
||||
self.software_bias_m = (
|
||||
predicted_bias_m
|
||||
+ self.parameters.software_bias_gain * innovation_m
|
||||
+ effective_bias_gain * innovation_m
|
||||
)
|
||||
if observation.clock_drift_valid:
|
||||
self.software_clock_drift_mps += self.software_drift_gain * (
|
||||
self.software_clock_drift_mps += effective_drift_gain * (
|
||||
measured_drift_mps - self.software_clock_drift_mps
|
||||
)
|
||||
else:
|
||||
@@ -665,7 +776,7 @@ class FixedPointAdaptivePps:
|
||||
self.software_clock_drift_mps = fallback_drift_mps
|
||||
else:
|
||||
self.software_clock_drift_mps += (
|
||||
self.software_fallback_drift_gain *
|
||||
effective_fallback_drift_gain *
|
||||
(fallback_drift_mps - self.software_clock_drift_mps)
|
||||
)
|
||||
self.software_previous_measurement_m = measured_bias_m
|
||||
@@ -731,7 +842,7 @@ class FixedPointAdaptivePps:
|
||||
if (
|
||||
self.last_observation_time_s is None
|
||||
or observation.time_s - self.last_observation_time_s
|
||||
> REFERENCE_WATCHDOG_SEC
|
||||
> self.reference_watchdog_sec
|
||||
):
|
||||
self.reference_watchdog_holdover = True
|
||||
self._record(observation, 0, 0)
|
||||
@@ -747,7 +858,7 @@ class FixedPointAdaptivePps:
|
||||
if (
|
||||
self.last_observation_time_s is None
|
||||
or observation.time_s - self.last_observation_time_s
|
||||
> REFERENCE_WATCHDOG_SEC
|
||||
> self.reference_watchdog_sec
|
||||
):
|
||||
self.reference_watchdog_holdover = True
|
||||
self._record(observation, 0, 0)
|
||||
@@ -817,6 +928,7 @@ def run_observations(
|
||||
observations: Iterable[Observation],
|
||||
*,
|
||||
finish_time_s: float,
|
||||
update_period_s: float = BASELINE_UPDATE_PERIOD_S,
|
||||
oscillator_ppm: float = 1.65,
|
||||
adaptive: bool = True,
|
||||
software_filter: bool = True,
|
||||
@@ -828,6 +940,7 @@ def run_observations(
|
||||
software_fallback_drift_gain: float = 1.0 / 16.0,
|
||||
) -> AdaptiveTrace:
|
||||
model = FixedPointAdaptivePps(
|
||||
update_period_s=update_period_s,
|
||||
oscillator_ppm=oscillator_ppm,
|
||||
adaptive=adaptive,
|
||||
software_filter=software_filter,
|
||||
|
||||
@@ -6,6 +6,7 @@ import unittest
|
||||
|
||||
from pps_python.adaptive_pps import (
|
||||
ACQUIRE_PROFILE,
|
||||
FixedPointAdaptivePps,
|
||||
Observation,
|
||||
Profile,
|
||||
PROFILES,
|
||||
@@ -59,6 +60,61 @@ def noisy_observations(
|
||||
|
||||
|
||||
class AdaptivePpsTests(unittest.TestCase):
|
||||
def test_period_aware_coefficients_match_firmware(self):
|
||||
expected = {
|
||||
0.2: {
|
||||
"acquire": (764018, 64006),
|
||||
Profile.PRECISION: (222838, 5445),
|
||||
Profile.ROBUST: (84891, 640),
|
||||
},
|
||||
0.5: {
|
||||
"acquire": (615780, 127492),
|
||||
Profile.PRECISION: (209783, 12804),
|
||||
Profile.ROBUST: (82980, 1564),
|
||||
},
|
||||
1.0: {
|
||||
"acquire": (447388, 178038),
|
||||
Profile.PRECISION: (190239, 23149),
|
||||
Profile.ROBUST: (79923, 3011),
|
||||
},
|
||||
}
|
||||
for period_s, coefficients in expected.items():
|
||||
self.assertEqual(
|
||||
ACQUIRE_PROFILE.fixed_coefficients(period_s)[:2],
|
||||
coefficients["acquire"],
|
||||
)
|
||||
for profile in (Profile.PRECISION, Profile.ROBUST):
|
||||
self.assertEqual(
|
||||
PROFILES[profile].fixed_coefficients(period_s)[:2],
|
||||
coefficients[profile],
|
||||
)
|
||||
|
||||
def test_wall_clock_hysteresis_and_settling_scale_with_period(self):
|
||||
model_500ms = FixedPointAdaptivePps(update_period_s=0.5)
|
||||
self.assertEqual(model_500ms.selector.upgrade_updates, 4)
|
||||
self.assertEqual(model_500ms.selector.degrade_updates, 2)
|
||||
self.assertEqual(
|
||||
model_500ms.profiles[Profile.PRECISION].software_settle_updates,
|
||||
5,
|
||||
)
|
||||
self.assertEqual(
|
||||
model_500ms.profiles[Profile.ROBUST].lock_acquire_updates,
|
||||
4,
|
||||
)
|
||||
|
||||
model_1s = FixedPointAdaptivePps(update_period_s=1.0)
|
||||
self.assertEqual(model_1s.selector.upgrade_updates, 2)
|
||||
self.assertEqual(model_1s.selector.degrade_updates, 2)
|
||||
self.assertEqual(
|
||||
model_1s.profiles[Profile.PRECISION].software_settle_updates,
|
||||
3,
|
||||
)
|
||||
self.assertEqual(
|
||||
model_1s.profiles[Profile.ROBUST].lock_acquire_updates,
|
||||
2,
|
||||
)
|
||||
self.assertEqual(model_1s.reference_watchdog_sec, 3.0)
|
||||
|
||||
def test_board_calibrated_doppler_predictor_reduces_pps_error(self):
|
||||
"""Genesys2 WLS statistics: predictor-on must beat fallback-only."""
|
||||
quality_metrics = QualityMetrics(
|
||||
|
||||
Reference in New Issue
Block a user