1073 lines
41 KiB
Python
1073 lines
41 KiB
Python
#!/usr/bin/env python3
|
|
"""Parse live firmware PPS telemetry and replay it through the Python loop.
|
|
|
|
The firmware status record is an observation of the already-running board. It
|
|
contains the WLS clock-bias path, Doppler datum predictor, quality selector and
|
|
RTL PI diagnostics, but it intentionally does not contain a trusted external
|
|
PPS edge. This module therefore reports two separate results:
|
|
|
|
* direct statistics of the observed firmware/RTL state; and
|
|
* a telemetry-conditioned replay of the bit-accurate fixed-point loop model,
|
|
initialized from the captured locked PI/filter state.
|
|
|
|
When ``GPS_PPS_STATUS_LOG_INTERVAL`` is greater than one, missing 200 ms solve
|
|
epochs are linearly interpolated. The replay reconstructs a self-consistent
|
|
reference trajectory from the logged filtered bias, PI residual and rate word.
|
|
That preserves measured low-frequency drift but is a lower bound for
|
|
unobserved high-frequency measurement noise. It is not an independent trusted
|
|
PPS measurement.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from dataclasses import asdict, dataclass, replace
|
|
import math
|
|
from pathlib import Path
|
|
import re
|
|
import statistics
|
|
from typing import Iterable
|
|
|
|
from pps_python.adaptive_pps import (
|
|
FixedPointAdaptivePps,
|
|
LoopProfile,
|
|
Observation,
|
|
PROFILES,
|
|
Profile,
|
|
Q32,
|
|
QualityMetrics,
|
|
SPEED_OF_LIGHT_MPS,
|
|
TRACKING_HZ,
|
|
)
|
|
|
|
|
|
UPDATE_PERIOD_S = 0.2
|
|
|
|
|
|
PPS_RE = re.compile(
|
|
r"PPS profile=(?P<profile>\w+) mode=(?P<mode>\w+) "
|
|
r"locked=(?P<locked>\d+) tdop=(?P<tdop>[-+\d.]+) "
|
|
r"gdop=(?P<gdop>[-+\d.]+) "
|
|
r"clock_sigma=(?P<clock_sigma_m>[-+\d.]+) "
|
|
r"cn0=(?P<cn0_mean_dbhz>[-+\d.]+)/(?P<cn0_min_dbhz>[-+\d.]+) "
|
|
r"innovation=(?P<innovation_m>[-+\d.]+)m "
|
|
r"raw_bias=(?P<raw_bias_m>[-+\d.]+)m "
|
|
r"filtered_bias=(?P<filtered_bias_m>[-+\d.]+)m "
|
|
r"clock_drift=(?P<clock_drift_mps>[-+\d.]+)mps "
|
|
r"bias_drift=(?P<bias_drift_mps>[-+\d.]+)mps "
|
|
r"predictor=(?P<predictor>\d+) "
|
|
r"eskf_alpha=(?P<eskf_alpha>[-+\d.]+) "
|
|
r"eskf_v=(?P<eskf_speed_mps>[-+\d.]+)/"
|
|
r"(?P<eskf_velocity_rms_mps>[-+\d.]+)/0x(?P<eskf_fde_mask>[0-9a-fA-F]+) "
|
|
r"pps_v=(?P<pps_speed_mps>[-+\d.]+)/"
|
|
r"(?P<pps_velocity_rms_mps>[-+\d.]+)/0x(?P<pps_fde_mask>[0-9a-fA-F]+) "
|
|
r"ref_svid=(?P<reference_svid>\d+) "
|
|
r"capture_corr=(?P<capture_correction_ns>[-+\d.]+)ns "
|
|
r"hw_phase=(?P<hardware_phase_ns>[-+\d.]+)ns "
|
|
r"rate_cmd=(?P<rate_command_ppm>[-+\d.]+)ppm "
|
|
r"integ=(?P<integrator_ppm>[-+\d.]+)ppm "
|
|
r"pulse=(?P<pulse_count>\d+) seq=(?P<sequence>\d+) "
|
|
r"status=0x(?P<status>[0-9a-fA-F]+)"
|
|
)
|
|
|
|
LOCATION_RE = re.compile(
|
|
r"source=gps .*?gdop=(?P<gdop>[-+\d.]+) "
|
|
r"pdop=(?P<pdop>[-+\d.]+) hdop=(?P<hdop>[-+\d.]+) "
|
|
r"vdop=(?P<vdop>[-+\d.]+) tdop=(?P<tdop>[-+\d.]+) "
|
|
r"tracked=(?P<tracked>\d+) used=(?P<used>\d+) "
|
|
r"velocity_valid=(?P<velocity_valid>\d+) "
|
|
r"velocity_fde=(?P<velocity_fde>\d+) "
|
|
r"speed=(?P<speed_mps>[-+\d.]+) "
|
|
r"velocity_rms=(?P<velocity_rms_mps>[-+\d.]+)"
|
|
)
|
|
|
|
TRACKED_RE = re.compile(r"tracked satellites=(?P<tracked>\d+)")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GpsContext:
|
|
host_monotonic_s: float
|
|
tracked_satellites: int
|
|
used_satellites: int
|
|
hdop: float
|
|
vdop: float
|
|
velocity_valid: bool
|
|
velocity_fde_applied: bool
|
|
velocity_residual_rms_mps: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FirmwarePpsSample:
|
|
host_monotonic_s: float
|
|
host_unix_s: float
|
|
time_s: float
|
|
profile: str
|
|
mode: str
|
|
locked: bool
|
|
tdop: float
|
|
gdop: float
|
|
hdop: float
|
|
vdop: float
|
|
clock_sigma_m: float
|
|
cn0_mean_dbhz: float
|
|
cn0_min_dbhz: float
|
|
tracked_satellites: int
|
|
used_satellites: int
|
|
innovation_m: float
|
|
raw_bias_m: float
|
|
filtered_bias_m: float
|
|
clock_drift_mps: float
|
|
bias_drift_mps: float
|
|
predictor_enabled: bool
|
|
eskf_alpha: float
|
|
eskf_speed_mps: float
|
|
eskf_velocity_rms_mps: float
|
|
eskf_fde_mask: int
|
|
pps_speed_mps: float
|
|
pps_velocity_rms_mps: float
|
|
pps_fde_mask: int
|
|
reference_svid: int
|
|
capture_correction_ns: float
|
|
hardware_phase_ns: float
|
|
rate_command_ppm: float
|
|
integrator_ppm: float
|
|
pulse_count: int
|
|
sequence: int
|
|
status: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ObservedMetrics:
|
|
samples: int
|
|
duration_s: float
|
|
status_interval_median_s: float
|
|
sequence_step_median: float
|
|
locked_fraction: float
|
|
profile_transitions: int
|
|
reference_changes: int
|
|
hardware_phase_mean_ns: float
|
|
hardware_phase_centered_rms_ns: float
|
|
hardware_phase_centered_p95_ns: float
|
|
hardware_phase_centered_max_ns: float
|
|
hardware_phase_peak_to_peak_ns: float
|
|
hardware_phase_10s_wander_peak_to_peak_ns: float
|
|
sampled_phase_step_rms_ns: float
|
|
sampled_phase_step_p95_ns: float
|
|
sampled_phase_step_max_ns: float
|
|
hardware_phase_slope_ns_per_s: float
|
|
rate_command_median_ppm: float
|
|
rate_command_span_ppm: float
|
|
integrator_median_ppm: float
|
|
inferred_oscillator_ppm: float
|
|
inferred_active_phase_rms_ns: float
|
|
inferred_active_phase_peak_to_peak_ns: float
|
|
phase_rate_correlation: float
|
|
phase_innovation_correlation: float
|
|
pi_rate_reproduction_rms_ppb: float
|
|
pi_rate_reproduction_p95_ppb: float
|
|
pi_rate_reproduction_max_ppb: float
|
|
innovation_rms_m: float
|
|
innovation_p95_m: float
|
|
raw_bias_slope_mps: float
|
|
filtered_bias_slope_mps: float
|
|
tdop_median: float
|
|
gdop_median: float
|
|
clock_sigma_median_m: float
|
|
cn0_mean_median_dbhz: float
|
|
cn0_min_median_dbhz: float
|
|
tracked_median: float
|
|
used_median: float
|
|
pps_velocity_rms_median_mps: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReplayPoint:
|
|
time_s: float
|
|
source_sequence: int
|
|
interpolated: bool
|
|
raw_bias_m: float
|
|
observed_filtered_bias_m: float
|
|
modeled_filtered_bias_m: float
|
|
reconstructed_active_phase_ns: float
|
|
modeled_active_phase_ns: float
|
|
observed_hardware_phase_ns: float
|
|
modeled_loop_error_ns: float
|
|
observed_rate_command_ppm: float
|
|
modeled_rate_command_ppm: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReplayMetrics:
|
|
tuning: str
|
|
expanded_updates: int
|
|
interpolation_factor: float
|
|
first_lock_s: float
|
|
locked_fraction_after_warmup: float
|
|
unlocks: int
|
|
inferred_oscillator_ppm: float
|
|
modeled_edge_centered_rms_ns: float
|
|
modeled_edge_centered_p95_ns: float
|
|
modeled_edge_peak_to_peak_ns: float
|
|
modeled_period_rms_ns: float
|
|
modeled_period_p95_ns: float
|
|
modeled_period_max_ns: float
|
|
modeled_loop_residual_centered_rms_ns: float
|
|
modeled_loop_residual_centered_p95_ns: float
|
|
modeled_loop_residual_peak_to_peak_ns: float
|
|
logged_residual_reproduction_rms_ns: float
|
|
logged_residual_reproduction_p95_ns: float
|
|
filtered_bias_reproduction_rms_m: float
|
|
filtered_bias_reproduction_p95_m: float
|
|
|
|
|
|
def _percentile(values: Iterable[float], fraction: float) -> float:
|
|
ordered = sorted(values)
|
|
if not ordered:
|
|
return math.nan
|
|
position = (len(ordered) - 1) * fraction
|
|
lower = math.floor(position)
|
|
upper = math.ceil(position)
|
|
if lower == upper:
|
|
return ordered[lower]
|
|
weight = position - lower
|
|
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
|
|
|
|
|
def _rms(values: Iterable[float]) -> float:
|
|
items = list(values)
|
|
return (
|
|
math.sqrt(statistics.mean(value * value for value in items))
|
|
if items
|
|
else math.nan
|
|
)
|
|
|
|
|
|
def _linear_fit(x: list[float], y: list[float]) -> tuple[float, float]:
|
|
if len(x) != len(y) or not x:
|
|
raise ValueError("linear fit requires equal non-empty inputs")
|
|
if len(x) == 1:
|
|
return y[0], 0.0
|
|
mean_x = statistics.mean(x)
|
|
mean_y = statistics.mean(y)
|
|
denominator = sum((value - mean_x) ** 2 for value in x)
|
|
if denominator == 0.0:
|
|
return mean_y, 0.0
|
|
slope = sum(
|
|
(x_value - mean_x) * (y_value - mean_y)
|
|
for x_value, y_value in zip(x, y)
|
|
) / denominator
|
|
return mean_y - slope * mean_x, slope
|
|
|
|
|
|
def _correlation(first: list[float], second: list[float]) -> float:
|
|
if len(first) != len(second) or len(first) < 2:
|
|
return math.nan
|
|
first_mean = statistics.mean(first)
|
|
second_mean = statistics.mean(second)
|
|
first_centered = [value - first_mean for value in first]
|
|
second_centered = [value - second_mean for value in second]
|
|
denominator = math.sqrt(
|
|
sum(value * value for value in first_centered)
|
|
* sum(value * value for value in second_centered)
|
|
)
|
|
if denominator == 0.0:
|
|
return math.nan
|
|
return sum(
|
|
first_value * second_value
|
|
for first_value, second_value in zip(first_centered, second_centered)
|
|
) / denominator
|
|
|
|
|
|
def _profile(sample: FirmwarePpsSample) -> Profile:
|
|
try:
|
|
return Profile[sample.profile.upper()]
|
|
except KeyError:
|
|
return Profile.HOLDOVER
|
|
|
|
|
|
def _pi_rate_prediction_ppm(sample: FirmwarePpsSample) -> float:
|
|
"""Recreate RTL ``integrator + Kp * phase`` from diagnostic fields."""
|
|
profile = _profile(sample)
|
|
if profile == Profile.HOLDOVER or sample.mode != "tracking":
|
|
return math.nan
|
|
kp_q46, _, max_slew_q32 = PROFILES[profile].fixed_coefficients(
|
|
UPDATE_PERIOD_S
|
|
)
|
|
phase_q32 = round(
|
|
sample.hardware_phase_ns * 1.0e-9 * TRACKING_HZ * Q32
|
|
)
|
|
integrator_q32 = round(sample.integrator_ppm * 1.0e-6 * Q32)
|
|
proportional_q32 = (phase_q32 * kp_q46) >> 46
|
|
rate_q32 = max(
|
|
-max_slew_q32,
|
|
min(max_slew_q32, integrator_q32 + proportional_q32),
|
|
)
|
|
return rate_q32 / Q32 * 1.0e6
|
|
|
|
|
|
def _reconstructed_active_phase_ns(
|
|
samples: list[FirmwarePpsSample], oscillator_ppm: float
|
|
) -> list[float]:
|
|
"""Integrate observed rate under a constant-oscillator assumption."""
|
|
phase_ns = [0.0]
|
|
for first, second in zip(samples, samples[1:]):
|
|
elapsed_s = second.time_s - first.time_s
|
|
mean_command_ppm = 0.5 * (
|
|
first.rate_command_ppm + second.rate_command_ppm
|
|
)
|
|
phase_ns.append(
|
|
phase_ns[-1]
|
|
+ (oscillator_ppm + mean_command_ppm) * elapsed_s * 1.0e3
|
|
)
|
|
return phase_ns
|
|
|
|
|
|
def _parse_float(group: dict[str, str], key: str) -> float:
|
|
return float(group[key])
|
|
|
|
|
|
def _parse_int(group: dict[str, str], key: str) -> int:
|
|
return int(group[key])
|
|
|
|
|
|
def load_firmware_telemetry(path: Path) -> list[FirmwarePpsSample]:
|
|
"""Load host-timestamped UART CSV produced by ``serial_capture.py``."""
|
|
pending: list[dict[str, object]] = []
|
|
latest_location: GpsContext | None = None
|
|
latest_tracked = 0
|
|
with path.open("r", encoding="utf-8", errors="replace", newline="") as source:
|
|
for row in csv.DictReader(source):
|
|
try:
|
|
host_monotonic_s = float(row["host_monotonic_s"])
|
|
host_unix_s = float(row["host_unix_s"])
|
|
line = row["uart_line"]
|
|
except (KeyError, TypeError, ValueError):
|
|
continue
|
|
|
|
tracked_match = TRACKED_RE.search(line)
|
|
if tracked_match:
|
|
latest_tracked = int(tracked_match.group("tracked"))
|
|
|
|
location_match = LOCATION_RE.search(line)
|
|
if location_match:
|
|
groups = location_match.groupdict()
|
|
latest_location = GpsContext(
|
|
host_monotonic_s,
|
|
int(groups["tracked"]),
|
|
int(groups["used"]),
|
|
float(groups["hdop"]),
|
|
float(groups["vdop"]),
|
|
bool(int(groups["velocity_valid"])),
|
|
bool(int(groups["velocity_fde"])),
|
|
float(groups["velocity_rms_mps"]),
|
|
)
|
|
|
|
pps_match = PPS_RE.search(line)
|
|
if not pps_match:
|
|
continue
|
|
groups = pps_match.groupdict()
|
|
context = latest_location
|
|
context_fresh = (
|
|
context is not None
|
|
and host_monotonic_s - context.host_monotonic_s <= 1.0
|
|
)
|
|
pending.append(
|
|
{
|
|
"host_monotonic_s": host_monotonic_s,
|
|
"host_unix_s": host_unix_s,
|
|
"profile": groups["profile"],
|
|
"mode": groups["mode"],
|
|
"locked": bool(_parse_int(groups, "locked")),
|
|
"tdop": _parse_float(groups, "tdop"),
|
|
"gdop": _parse_float(groups, "gdop"),
|
|
"hdop": context.hdop if context_fresh else math.nan,
|
|
"vdop": context.vdop if context_fresh else math.nan,
|
|
"clock_sigma_m": _parse_float(groups, "clock_sigma_m"),
|
|
"cn0_mean_dbhz": _parse_float(groups, "cn0_mean_dbhz"),
|
|
"cn0_min_dbhz": _parse_float(groups, "cn0_min_dbhz"),
|
|
"tracked_satellites": (
|
|
context.tracked_satellites
|
|
if context_fresh
|
|
else latest_tracked
|
|
),
|
|
"used_satellites": (
|
|
context.used_satellites if context_fresh else 0
|
|
),
|
|
"innovation_m": _parse_float(groups, "innovation_m"),
|
|
"raw_bias_m": _parse_float(groups, "raw_bias_m"),
|
|
"filtered_bias_m": _parse_float(groups, "filtered_bias_m"),
|
|
"clock_drift_mps": _parse_float(groups, "clock_drift_mps"),
|
|
"bias_drift_mps": _parse_float(groups, "bias_drift_mps"),
|
|
"predictor_enabled": bool(_parse_int(groups, "predictor")),
|
|
"eskf_alpha": _parse_float(groups, "eskf_alpha"),
|
|
"eskf_speed_mps": _parse_float(groups, "eskf_speed_mps"),
|
|
"eskf_velocity_rms_mps": _parse_float(
|
|
groups, "eskf_velocity_rms_mps"
|
|
),
|
|
"eskf_fde_mask": int(groups["eskf_fde_mask"], 16),
|
|
"pps_speed_mps": _parse_float(groups, "pps_speed_mps"),
|
|
"pps_velocity_rms_mps": _parse_float(
|
|
groups, "pps_velocity_rms_mps"
|
|
),
|
|
"pps_fde_mask": int(groups["pps_fde_mask"], 16),
|
|
"reference_svid": _parse_int(groups, "reference_svid"),
|
|
"capture_correction_ns": _parse_float(
|
|
groups, "capture_correction_ns"
|
|
),
|
|
"hardware_phase_ns": _parse_float(
|
|
groups, "hardware_phase_ns"
|
|
),
|
|
"rate_command_ppm": _parse_float(groups, "rate_command_ppm"),
|
|
"integrator_ppm": _parse_float(groups, "integrator_ppm"),
|
|
"pulse_count": _parse_int(groups, "pulse_count"),
|
|
"sequence": _parse_int(groups, "sequence"),
|
|
"status": int(groups["status"], 16),
|
|
}
|
|
)
|
|
|
|
if not pending:
|
|
return []
|
|
first_sequence = int(pending[0]["sequence"])
|
|
samples: list[FirmwarePpsSample] = []
|
|
for fields in pending:
|
|
sequence = int(fields["sequence"])
|
|
sequence_delta = (sequence - first_sequence) & 0xFFFF_FFFF
|
|
fields["time_s"] = sequence_delta * UPDATE_PERIOD_S
|
|
samples.append(FirmwarePpsSample(**fields))
|
|
return samples
|
|
|
|
|
|
def observed_metrics(samples: list[FirmwarePpsSample]) -> ObservedMetrics:
|
|
if len(samples) < 2:
|
|
raise ValueError("at least two firmware PPS samples are required")
|
|
phase = [sample.hardware_phase_ns for sample in samples]
|
|
phase_center = statistics.median(phase)
|
|
centered_phase = [value - phase_center for value in phase]
|
|
phase_steps = [
|
|
phase[index] - phase[index - 1] for index in range(1, len(phase))
|
|
]
|
|
host_intervals = [
|
|
samples[index].host_monotonic_s - samples[index - 1].host_monotonic_s
|
|
for index in range(1, len(samples))
|
|
]
|
|
sequence_steps = [
|
|
(samples[index].sequence - samples[index - 1].sequence) & 0xFFFF_FFFF
|
|
for index in range(1, len(samples))
|
|
]
|
|
_, phase_slope = _linear_fit(
|
|
[sample.time_s for sample in samples], phase
|
|
)
|
|
_, raw_slope = _linear_fit(
|
|
[sample.time_s for sample in samples],
|
|
[sample.raw_bias_m for sample in samples],
|
|
)
|
|
_, filtered_slope = _linear_fit(
|
|
[sample.time_s for sample in samples],
|
|
[sample.filtered_bias_m for sample in samples],
|
|
)
|
|
profiles = [sample.profile for sample in samples]
|
|
references = [sample.reference_svid for sample in samples]
|
|
innovations = [sample.innovation_m for sample in samples]
|
|
rate = [sample.rate_command_ppm for sample in samples]
|
|
status_interval = statistics.median(host_intervals)
|
|
wander_window = max(1, round(10.0 / status_interval))
|
|
phase_10s_means = [
|
|
statistics.mean(centered_phase[index - wander_window + 1:index + 1])
|
|
for index in range(wander_window - 1, len(centered_phase))
|
|
]
|
|
inferred_oscillator_ppm = -statistics.mean(rate)
|
|
inferred_active_phase = _reconstructed_active_phase_ns(
|
|
samples, inferred_oscillator_ppm
|
|
)
|
|
active_center = statistics.median(inferred_active_phase)
|
|
centered_active = [
|
|
value - active_center for value in inferred_active_phase
|
|
]
|
|
pi_rate_errors_ppb = [
|
|
(prediction - sample.rate_command_ppm) * 1.0e3
|
|
for sample in samples
|
|
if math.isfinite(
|
|
prediction := _pi_rate_prediction_ppm(sample)
|
|
)
|
|
]
|
|
return ObservedMetrics(
|
|
len(samples),
|
|
samples[-1].time_s - samples[0].time_s,
|
|
statistics.median(host_intervals),
|
|
statistics.median(sequence_steps),
|
|
statistics.mean(sample.locked for sample in samples),
|
|
sum(
|
|
profiles[index] != profiles[index - 1]
|
|
for index in range(1, len(profiles))
|
|
),
|
|
sum(
|
|
references[index] != references[index - 1]
|
|
for index in range(1, len(references))
|
|
),
|
|
statistics.mean(phase),
|
|
_rms(centered_phase),
|
|
_percentile((abs(value) for value in centered_phase), 0.95),
|
|
max(abs(value) for value in centered_phase),
|
|
max(phase) - min(phase),
|
|
(
|
|
max(phase_10s_means) - min(phase_10s_means)
|
|
if phase_10s_means else math.nan
|
|
),
|
|
_rms(phase_steps),
|
|
_percentile((abs(value) for value in phase_steps), 0.95),
|
|
max(abs(value) for value in phase_steps),
|
|
phase_slope,
|
|
statistics.median(rate),
|
|
max(rate) - min(rate),
|
|
statistics.median(sample.integrator_ppm for sample in samples),
|
|
inferred_oscillator_ppm,
|
|
_rms(centered_active),
|
|
max(inferred_active_phase) - min(inferred_active_phase),
|
|
_correlation(phase, rate),
|
|
_correlation(phase, innovations),
|
|
_rms(pi_rate_errors_ppb),
|
|
_percentile((abs(value) for value in pi_rate_errors_ppb), 0.95),
|
|
max((abs(value) for value in pi_rate_errors_ppb), default=math.nan),
|
|
_rms(innovations),
|
|
_percentile((abs(value) for value in innovations), 0.95),
|
|
raw_slope,
|
|
filtered_slope,
|
|
statistics.median(sample.tdop for sample in samples),
|
|
statistics.median(sample.gdop for sample in samples),
|
|
statistics.median(sample.clock_sigma_m for sample in samples),
|
|
statistics.median(sample.cn0_mean_dbhz for sample in samples),
|
|
statistics.median(sample.cn0_min_dbhz for sample in samples),
|
|
statistics.median(sample.tracked_satellites for sample in samples),
|
|
statistics.median(sample.used_satellites for sample in samples),
|
|
statistics.median(sample.pps_velocity_rms_mps for sample in samples),
|
|
)
|
|
|
|
|
|
def _lerp(first: float, second: float, fraction: float) -> float:
|
|
return first + (second - first) * fraction
|
|
|
|
|
|
def _quality(sample: FirmwarePpsSample) -> QualityMetrics:
|
|
# chi-square is not present in the level-1 status record. A finite value
|
|
# within the observed profile's accepted band preserves the firmware's
|
|
# already-reported robust/precision classification without pretending it
|
|
# was measured by this parser.
|
|
inferred_chi_square = 0.5
|
|
solution_valid = sample.profile != "holdover" and sample.used_satellites >= 4
|
|
return QualityMetrics(
|
|
sample.tdop,
|
|
sample.gdop,
|
|
sample.clock_sigma_m,
|
|
sample.cn0_mean_dbhz,
|
|
sample.cn0_min_dbhz,
|
|
inferred_chi_square,
|
|
sample.pps_velocity_rms_mps,
|
|
sample.used_satellites,
|
|
solution_valid=solution_valid,
|
|
residual_test_valid=solution_valid,
|
|
velocity_valid=solution_valid,
|
|
fde_applied=sample.pps_fde_mask != 0,
|
|
)
|
|
|
|
|
|
def _interpolated_sample(
|
|
first: FirmwarePpsSample,
|
|
second: FirmwarePpsSample,
|
|
step: int,
|
|
total_steps: int,
|
|
) -> FirmwarePpsSample:
|
|
fraction = step / total_steps
|
|
fields = asdict(first)
|
|
linear_fields = (
|
|
"host_monotonic_s", "host_unix_s", "tdop", "gdop", "hdop", "vdop",
|
|
"clock_sigma_m", "cn0_mean_dbhz", "cn0_min_dbhz", "innovation_m",
|
|
"raw_bias_m", "filtered_bias_m", "clock_drift_mps", "bias_drift_mps",
|
|
"eskf_alpha", "eskf_speed_mps", "eskf_velocity_rms_mps",
|
|
"pps_speed_mps", "pps_velocity_rms_mps", "capture_correction_ns",
|
|
"hardware_phase_ns", "rate_command_ppm", "integrator_ppm",
|
|
)
|
|
for name in linear_fields:
|
|
fields[name] = _lerp(
|
|
float(getattr(first, name)), float(getattr(second, name)), fraction
|
|
)
|
|
fields["sequence"] = (first.sequence + step) & 0xFFFF_FFFF
|
|
fields["time_s"] = first.time_s + step * UPDATE_PERIOD_S
|
|
fields["pulse_count"] = round(
|
|
_lerp(first.pulse_count, second.pulse_count, fraction)
|
|
)
|
|
if fraction >= 0.5:
|
|
for name in (
|
|
"profile", "mode", "locked", "tracked_satellites",
|
|
"used_satellites", "predictor_enabled", "eskf_fde_mask",
|
|
"pps_fde_mask", "reference_svid", "status",
|
|
):
|
|
fields[name] = getattr(second, name)
|
|
return FirmwarePpsSample(**fields)
|
|
|
|
|
|
def expand_samples(samples: list[FirmwarePpsSample]) -> list[tuple[FirmwarePpsSample, bool]]:
|
|
if not samples:
|
|
return []
|
|
expanded: list[tuple[FirmwarePpsSample, bool]] = [(samples[0], False)]
|
|
for first, second in zip(samples, samples[1:]):
|
|
steps = (second.sequence - first.sequence) & 0xFFFF_FFFF
|
|
if steps == 0 or steps > 10_000:
|
|
continue
|
|
for step in range(1, steps + 1):
|
|
expanded.append(
|
|
(
|
|
second if step == steps else _interpolated_sample(
|
|
first, second, step, steps
|
|
),
|
|
step != steps,
|
|
)
|
|
)
|
|
return expanded
|
|
|
|
|
|
def replay_firmware_telemetry(
|
|
samples: list[FirmwarePpsSample],
|
|
*,
|
|
warmup_s: float = 20.0,
|
|
tuning: str = "current",
|
|
profiles: dict[Profile, LoopProfile] | None = None,
|
|
software_drift_gain: float = 1.0 / 4.0,
|
|
) -> tuple[list[ReplayPoint], ReplayMetrics]:
|
|
if len(samples) < 2:
|
|
raise ValueError("at least two firmware PPS samples are required")
|
|
expanded = expand_samples(samples)
|
|
first = expanded[0][0]
|
|
first_profile = _profile(first)
|
|
if not first.locked or first_profile == Profile.HOLDOVER:
|
|
raise ValueError(
|
|
"hardware replay requires an initially locked tracking sample"
|
|
)
|
|
|
|
# The UART does not contain a direct oscillator measurement. Choosing
|
|
# the negative mean command is the unique constant-oscillator convention
|
|
# that gives zero net frequency over the capture. Integrating the logged
|
|
# command then reconstructs the board's relative active-phase trajectory.
|
|
oscillator_ppm = -statistics.mean(
|
|
sample.rate_command_ppm for sample in samples
|
|
)
|
|
expanded_samples = [sample for sample, _ in expanded]
|
|
active_phase_ns = _reconstructed_active_phase_ns(
|
|
expanded_samples, oscillator_ppm
|
|
)
|
|
reference_timing_s = [
|
|
phase_ns * 1.0e-9 + sample.hardware_phase_ns * 1.0e-9
|
|
for phase_ns, sample in zip(active_phase_ns, expanded_samples)
|
|
]
|
|
# Firmware's filtered bias is the value used to form the PPS reference.
|
|
# Reversing that operation gives a self-consistent datum trajectory while
|
|
# retaining raw WLS bias as the actual software-filter input.
|
|
true_bias_m = [
|
|
sample.filtered_bias_m + timing_s * SPEED_OF_LIGHT_MPS
|
|
for sample, timing_s in zip(expanded_samples, reference_timing_s)
|
|
]
|
|
model = FixedPointAdaptivePps(
|
|
update_period_s=UPDATE_PERIOD_S,
|
|
oscillator_ppm=oscillator_ppm,
|
|
receiver_clock_bias_m=true_bias_m[0],
|
|
receiver_clock_drift_mps=0.0,
|
|
profiles=profiles,
|
|
software_drift_gain=software_drift_gain,
|
|
)
|
|
model.seed_tracking_state(
|
|
time_s=first.time_s,
|
|
profile=first_profile,
|
|
phase_error_s=active_phase_ns[0] * 1.0e-9,
|
|
rate_command_ppm=first.rate_command_ppm,
|
|
integrator_ppm=first.integrator_ppm,
|
|
software_error_s=reference_timing_s[0],
|
|
software_bias_m=first.filtered_bias_m,
|
|
software_clock_drift_mps=first.bias_drift_mps,
|
|
software_previous_measurement_m=first.raw_bias_m,
|
|
reference_id=first.reference_svid,
|
|
)
|
|
|
|
points: list[ReplayPoint] = [
|
|
ReplayPoint(
|
|
first.time_s,
|
|
first.sequence,
|
|
False,
|
|
first.raw_bias_m,
|
|
first.filtered_bias_m,
|
|
first.filtered_bias_m,
|
|
active_phase_ns[0],
|
|
active_phase_ns[0],
|
|
first.hardware_phase_ns,
|
|
first.hardware_phase_ns,
|
|
first.rate_command_ppm,
|
|
first.rate_command_ppm,
|
|
)
|
|
]
|
|
original_sequences = {sample.sequence for sample in samples}
|
|
observed_filtered = {sample.sequence: sample.filtered_bias_m for sample in samples}
|
|
for index, (sample, interpolated) in enumerate(expanded[1:], start=1):
|
|
observation = Observation(
|
|
sample.time_s,
|
|
(true_bias_m[index] - sample.raw_bias_m) / SPEED_OF_LIGHT_MPS,
|
|
sample.bias_drift_mps,
|
|
_quality(sample),
|
|
clock_drift_valid=sample.predictor_enabled,
|
|
reference_range_rate_mps=0.0,
|
|
reference_id=sample.reference_svid,
|
|
reference_datum_offset_m=(
|
|
true_bias_m[index] - true_bias_m[0]
|
|
),
|
|
)
|
|
model.update(observation)
|
|
modeled_loop_error_ns = model.trace.raw_loop_error_s[-1] * 1.0e9
|
|
points.append(
|
|
ReplayPoint(
|
|
sample.time_s,
|
|
sample.sequence,
|
|
interpolated,
|
|
sample.raw_bias_m,
|
|
observed_filtered.get(sample.sequence, math.nan),
|
|
model.software_bias_m,
|
|
active_phase_ns[index],
|
|
model.trace.phase_error_s[-1] * 1.0e9,
|
|
sample.hardware_phase_ns if sample.sequence in original_sequences else math.nan,
|
|
modeled_loop_error_ns,
|
|
sample.rate_command_ppm if sample.sequence in original_sequences else math.nan,
|
|
model.trace.frequency_command_ppm[-1],
|
|
)
|
|
)
|
|
trace = model.finish(expanded[-1][0].time_s + UPDATE_PERIOD_S)
|
|
|
|
edge_errors_ns = [
|
|
error * 1.0e9
|
|
for time_s, error in trace.integer_second_edge_errors(after_s=warmup_s)
|
|
]
|
|
edge_center = (
|
|
statistics.median(edge_errors_ns) if edge_errors_ns else math.nan
|
|
)
|
|
centered_edges = [value - edge_center for value in edge_errors_ns]
|
|
periods = [
|
|
edge_errors_ns[index] - edge_errors_ns[index - 1]
|
|
for index in range(1, len(edge_errors_ns))
|
|
]
|
|
locked_after_warmup = [
|
|
locked
|
|
for time_s, locked in zip(trace.time_s, trace.locked)
|
|
if time_s >= warmup_s
|
|
]
|
|
bias_errors = [
|
|
point.modeled_filtered_bias_m - point.observed_filtered_bias_m
|
|
for point in points
|
|
if not point.interpolated
|
|
and point.time_s >= warmup_s
|
|
and math.isfinite(point.observed_filtered_bias_m)
|
|
]
|
|
modeled_residuals = [
|
|
point.modeled_loop_error_ns
|
|
for point in points
|
|
if point.time_s >= warmup_s
|
|
]
|
|
modeled_residual_center = (
|
|
statistics.median(modeled_residuals)
|
|
if modeled_residuals else math.nan
|
|
)
|
|
centered_modeled_residuals = [
|
|
value - modeled_residual_center for value in modeled_residuals
|
|
]
|
|
residual_reproduction_errors = [
|
|
point.modeled_loop_error_ns - point.observed_hardware_phase_ns
|
|
for point in points
|
|
if not point.interpolated
|
|
and point.time_s >= warmup_s
|
|
and math.isfinite(point.observed_hardware_phase_ns)
|
|
]
|
|
metrics = ReplayMetrics(
|
|
tuning,
|
|
len(expanded),
|
|
len(expanded) / len(samples),
|
|
0.0,
|
|
statistics.mean(locked_after_warmup) if locked_after_warmup else 0.0,
|
|
trace.unlock_events,
|
|
oscillator_ppm,
|
|
_rms(centered_edges),
|
|
_percentile((abs(value) for value in centered_edges), 0.95),
|
|
max(centered_edges) - min(centered_edges) if centered_edges else math.nan,
|
|
_rms(periods),
|
|
_percentile((abs(value) for value in periods), 0.95),
|
|
max((abs(value) for value in periods), default=math.nan),
|
|
_rms(centered_modeled_residuals),
|
|
_percentile(
|
|
(abs(value) for value in centered_modeled_residuals), 0.95
|
|
),
|
|
(
|
|
max(centered_modeled_residuals)
|
|
- min(centered_modeled_residuals)
|
|
if centered_modeled_residuals else math.nan
|
|
),
|
|
_rms(residual_reproduction_errors),
|
|
_percentile(
|
|
(abs(value) for value in residual_reproduction_errors), 0.95
|
|
),
|
|
_rms(bias_errors),
|
|
_percentile((abs(value) for value in bias_errors), 0.95),
|
|
)
|
|
return points, metrics
|
|
|
|
|
|
def replay_tuning_sensitivity(
|
|
samples: list[FirmwarePpsSample], *, warmup_s: float = 20.0
|
|
) -> list[ReplayMetrics]:
|
|
"""Run small, explicitly non-production tuning counterfactuals."""
|
|
_, current = replay_firmware_telemetry(
|
|
samples, warmup_s=warmup_s, tuning="current"
|
|
)
|
|
guarded_profiles = dict(PROFILES)
|
|
guarded_profiles[Profile.ROBUST] = replace(
|
|
guarded_profiles[Profile.ROBUST], software_bias_gain=1.0 / 64.0
|
|
)
|
|
_, guarded = replay_firmware_telemetry(
|
|
samples,
|
|
warmup_s=warmup_s,
|
|
tuning="robust_bias_1_64",
|
|
profiles=guarded_profiles,
|
|
)
|
|
_, faster_drift = replay_firmware_telemetry(
|
|
samples,
|
|
warmup_s=warmup_s,
|
|
tuning="drift_gain_1_2",
|
|
software_drift_gain=1.0 / 2.0,
|
|
)
|
|
return [current, guarded, faster_drift]
|
|
|
|
|
|
def write_samples(path: Path, samples: list[FirmwarePpsSample]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8", newline="") as output:
|
|
writer = csv.DictWriter(
|
|
output,
|
|
fieldnames=list(FirmwarePpsSample.__annotations__),
|
|
lineterminator="\n",
|
|
)
|
|
writer.writeheader()
|
|
for sample in samples:
|
|
writer.writerow(asdict(sample))
|
|
|
|
|
|
def write_replay(path: Path, points: list[ReplayPoint]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with path.open("w", encoding="utf-8", newline="") as output:
|
|
writer = csv.DictWriter(
|
|
output,
|
|
fieldnames=list(ReplayPoint.__annotations__),
|
|
lineterminator="\n",
|
|
)
|
|
writer.writeheader()
|
|
for point in points:
|
|
writer.writerow(asdict(point))
|
|
|
|
|
|
def write_report(
|
|
path: Path,
|
|
source: Path,
|
|
samples: list[FirmwarePpsSample],
|
|
observed: ObservedMetrics,
|
|
replay: ReplayMetrics,
|
|
sensitivity: list[ReplayMetrics],
|
|
) -> None:
|
|
profiles = sorted({sample.profile for sample in samples})
|
|
references = sorted({sample.reference_svid for sample in samples})
|
|
lines = [
|
|
"# Genesys2 live PPS telemetry replay",
|
|
"",
|
|
f"Source: `{source}`",
|
|
"",
|
|
"## Direct firmware/RTL observations",
|
|
"",
|
|
f"- Samples/duration: {observed.samples} / {observed.duration_s:.1f} s.",
|
|
f"- Status interval: {observed.status_interval_median_s:.3f} s; "
|
|
f"median sequence step {observed.sequence_step_median:.0f} "
|
|
f"({observed.sequence_step_median * UPDATE_PERIOD_S:.1f} s of 200 ms solves).",
|
|
f"- Profiles: {', '.join(profiles)}; locked fraction "
|
|
f"{observed.locked_fraction:.3f}; transitions "
|
|
f"{observed.profile_transitions}; reference SVIDs {references}.",
|
|
f"- TDOP/GDOP medians: {observed.tdop_median:.3f} / "
|
|
f"{observed.gdop_median:.3f}; clock sigma median "
|
|
f"{observed.clock_sigma_median_m:.2f} m.",
|
|
f"- C/N0 mean/min medians: {observed.cn0_mean_median_dbhz:.1f} / "
|
|
f"{observed.cn0_min_median_dbhz:.1f} dB-Hz; tracked/used medians "
|
|
f"{observed.tracked_median:.0f}/{observed.used_median:.0f}.",
|
|
f"- WLS innovation RMS/p95-absolute: {observed.innovation_rms_m:.2f} / "
|
|
f"{observed.innovation_p95_m:.2f} m.",
|
|
f"- Raw/filtered bias slopes: {observed.raw_bias_slope_mps:.3f} / "
|
|
f"{observed.filtered_bias_slope_mps:.3f} m/s; PPS velocity residual "
|
|
f"median {observed.pps_velocity_rms_median_mps:.3f} m/s.",
|
|
f"- Internal hardware phase mean: {observed.hardware_phase_mean_ns:.2f} ns; "
|
|
f"median-centered RMS/p95/max/p-p: "
|
|
f"{observed.hardware_phase_centered_rms_ns:.2f} / "
|
|
f"{observed.hardware_phase_centered_p95_ns:.2f} / "
|
|
f"{observed.hardware_phase_centered_max_ns:.2f} / "
|
|
f"{observed.hardware_phase_peak_to_peak_ns:.2f} ns.",
|
|
f"- Ten-second averaged phase wander p-p: "
|
|
f"{observed.hardware_phase_10s_wander_peak_to_peak_ns:.2f} ns.",
|
|
f"- Sampled phase-step RMS/p95/max: "
|
|
f"{observed.sampled_phase_step_rms_ns:.2f} / "
|
|
f"{observed.sampled_phase_step_p95_ns:.2f} / "
|
|
f"{observed.sampled_phase_step_max_ns:.2f} ns. These are at the status "
|
|
"interval, not consecutive physical 1 PPS edges.",
|
|
f"- Rate command median/span: {observed.rate_command_median_ppm:.4f} / "
|
|
f"{observed.rate_command_span_ppm:.4f} ppm; integrator median "
|
|
f"{observed.integrator_median_ppm:.4f} ppm.",
|
|
f"- Fixed-point PI rate reproduction RMS/p95/max: "
|
|
f"{observed.pi_rate_reproduction_rms_ppb:.3f} / "
|
|
f"{observed.pi_rate_reproduction_p95_ppb:.3f} / "
|
|
f"{observed.pi_rate_reproduction_max_ppb:.3f} ppb. This is the "
|
|
"difference between logged `rate_cmd` and `integrator + Kp*hw_phase`; "
|
|
"the remaining error is dominated by three-decimal UART rounding.",
|
|
f"- Phase correlation with rate command / WLS innovation: "
|
|
f"{observed.phase_rate_correlation:.3f} / "
|
|
f"{observed.phase_innovation_correlation:.3f}.",
|
|
f"- A constant oscillator inferred from zero net frequency is "
|
|
f"{observed.inferred_oscillator_ppm:.6f} ppm. Integrating the logged "
|
|
f"command under that assumption gives centered phase RMS/p-p "
|
|
f"{observed.inferred_active_phase_rms_ns:.2f} / "
|
|
f"{observed.inferred_active_phase_peak_to_peak_ns:.2f} ns.",
|
|
"",
|
|
"`hw_phase` is the RTL PI residual captured over APB. It is not a "
|
|
"trusted-generator time-interval measurement of the output pin, so "
|
|
"these values cannot certify physical PPS jitter or absolute offset.",
|
|
"",
|
|
"## Telemetry-conditioned end-to-end loop replay",
|
|
"",
|
|
"The replay imports the first locked phase/rate/integrator and software "
|
|
"filter snapshot instead of incorrectly resetting the board to an "
|
|
"acquisition state. It uses raw/filtered clock bias, Doppler datum "
|
|
"drift, TDOP/GDOP, clock sigma, C/N0, satellite count, FDE, reference "
|
|
"SVID and the production fixed-point coefficients. The reference "
|
|
"trajectory is reconstructed from filtered bias, the logged PI "
|
|
"residual and the integrated rate command.",
|
|
"",
|
|
f"- Expanded updates: {replay.expanded_updates}; interpolation factor "
|
|
f"{replay.interpolation_factor:.2f}.",
|
|
f"- Snapshot starts locked at {replay.first_lock_s:.2f} s; post-warmup locked "
|
|
f"fraction {replay.locked_fraction_after_warmup:.3f}; unlocks "
|
|
f"{replay.unlocks}.",
|
|
f"- Constant-oscillator convention: "
|
|
f"{replay.inferred_oscillator_ppm:.6f} ppm.",
|
|
f"- Modeled physical-grid edge centered RMS/p95/p-p: "
|
|
f"{replay.modeled_edge_centered_rms_ns:.2f} / "
|
|
f"{replay.modeled_edge_centered_p95_ns:.2f} / "
|
|
f"{replay.modeled_edge_peak_to_peak_ns:.2f} ns.",
|
|
f"- Modeled one-second period RMS/p95/max: "
|
|
f"{replay.modeled_period_rms_ns:.2f} / "
|
|
f"{replay.modeled_period_p95_ns:.2f} / "
|
|
f"{replay.modeled_period_max_ns:.2f} ns.",
|
|
f"- Modeled PI residual centered RMS/p95/p-p: "
|
|
f"{replay.modeled_loop_residual_centered_rms_ns:.2f} / "
|
|
f"{replay.modeled_loop_residual_centered_p95_ns:.2f} / "
|
|
f"{replay.modeled_loop_residual_peak_to_peak_ns:.2f} ns.",
|
|
f"- Logged-epoch PI residual reproduction RMS/p95 error: "
|
|
f"{replay.logged_residual_reproduction_rms_ns:.2f} / "
|
|
f"{replay.logged_residual_reproduction_p95_ns:.2f} ns.",
|
|
f"- Logged-epoch filtered-bias reproduction RMS/p95: "
|
|
f"{replay.filtered_bias_reproduction_rms_m:.2f} / "
|
|
f"{replay.filtered_bias_reproduction_p95_m:.2f} m.",
|
|
"",
|
|
"## Tuning sensitivity on the same trace",
|
|
"",
|
|
"| tuning | edge RMS (ns) | edge p-p (ns) | period max (ns) | "
|
|
"residual reproduction RMS (ns) | unlocks |",
|
|
"|---|---:|---:|---:|---:|---:|",
|
|
]
|
|
for item in sensitivity:
|
|
lines.append(
|
|
f"| {item.tuning} | {item.modeled_edge_centered_rms_ns:.2f} | "
|
|
f"{item.modeled_edge_peak_to_peak_ns:.2f} | "
|
|
f"{item.modeled_period_max_ns:.2f} | "
|
|
f"{item.logged_residual_reproduction_rms_ns:.2f} | "
|
|
f"{item.unlocks} |"
|
|
)
|
|
lines.extend([
|
|
"",
|
|
"These counterfactuals reuse a reference trajectory reconstructed "
|
|
"from the current filter, and nine of every ten solve epochs are "
|
|
"interpolated. They are useful as a rejection test, not as evidence "
|
|
"to deploy new gains. On this capture neither `robust_bias_1_64` nor "
|
|
"`drift_gain_1_2` improves the current loop's absolute wander.",
|
|
"",
|
|
"## Limits and next measurement",
|
|
"",
|
|
"- Missing status epochs are linearly interpolated when sequence step "
|
|
"is greater than one. This preserves low-frequency wander but "
|
|
"understates unlogged 200 ms noise; it is not an exact firmware replay.",
|
|
"- The inferred oscillator and active phase assume one constant crystal "
|
|
"frequency chosen to make net phase slope zero over this capture. "
|
|
"Temperature telemetry was unavailable, so slow crystal motion is not "
|
|
"separable from the reconstructed reference.",
|
|
"- Level-1 telemetry does not expose ABS_SAMPLE, REM_CODE_PHASE, full "
|
|
"capture counter, chi-square ratio, pseudorange residual vector, board "
|
|
"temperature, or a trusted PPS edge timestamp. The bit/subframe part "
|
|
"of the end-to-end model remains synthetic for those fields.",
|
|
"- Set `GPS_PPS_STATUS_LOG_INTERVAL=1` for exact 200 ms input cadence. "
|
|
"For a max-20 ns pin claim, capture DUT and trusted PPS with a "
|
|
"common-clock TIC/scope after cable/output-delay calibration.",
|
|
"",
|
|
])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text("\n".join(lines), encoding="utf-8")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--input", type=Path, required=True)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parent / "output/hardware_replay",
|
|
)
|
|
parser.add_argument("--warmup", type=float, default=20.0)
|
|
parser.add_argument("--no-write", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
samples = load_firmware_telemetry(args.input)
|
|
if len(samples) < 2:
|
|
raise SystemExit("input does not contain at least two firmware PPS records")
|
|
observed = observed_metrics(samples)
|
|
points, replay = replay_firmware_telemetry(samples, warmup_s=args.warmup)
|
|
sensitivity = replay_tuning_sensitivity(
|
|
samples, warmup_s=args.warmup
|
|
)
|
|
print(
|
|
f"samples={observed.samples} duration={observed.duration_s:.1f}s "
|
|
f"locked={observed.locked_fraction:.3f} "
|
|
f"hw_phase_centered_rms={observed.hardware_phase_centered_rms_ns:.2f}ns "
|
|
f"hw_phase_p95={observed.hardware_phase_centered_p95_ns:.2f}ns "
|
|
f"hw_phase_pp={observed.hardware_phase_peak_to_peak_ns:.2f}ns"
|
|
)
|
|
print(
|
|
f"replay_updates={replay.expanded_updates} "
|
|
f"model_edge_rms={replay.modeled_edge_centered_rms_ns:.2f}ns "
|
|
f"model_period_rms={replay.modeled_period_rms_ns:.2f}ns "
|
|
f"model_period_max={replay.modeled_period_max_ns:.2f}ns "
|
|
f"unlocks={replay.unlocks}"
|
|
)
|
|
if not args.no_write:
|
|
write_samples(args.output_dir / "firmware_telemetry.csv", samples)
|
|
write_replay(args.output_dir / "model_replay.csv", points)
|
|
write_report(
|
|
args.output_dir / "hardware_replay_report.md",
|
|
args.input,
|
|
samples,
|
|
observed,
|
|
replay,
|
|
sensitivity,
|
|
)
|
|
print(f"wrote {args.output_dir}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|