407 lines
13 KiB
Python
407 lines
13 KiB
Python
#!/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())
|