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
@@ -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()