Files
gps/soc/software/pps_python/analyze_pps_scenarios.py
T

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