"""Small, dependency-light helpers for mapping independent sensor clocks.""" from __future__ import annotations from dataclasses import asdict, dataclass import numpy as np @dataclass(frozen=True) class AffineClockModel: """Numerically stable affine map ``y = y_ref + scale * (x - x_ref)``.""" x_ref: float y_ref: float scale: float sample_count: int inlier_count: int residual_std_s: float residual_p95_s: float def map(self, value: float | np.ndarray) -> float | np.ndarray: array = np.asarray(value, dtype=np.float64) mapped = self.y_ref + self.scale * (array - self.x_ref) return float(mapped) if array.ndim == 0 else mapped def inverse(self, value: float | np.ndarray) -> float | np.ndarray: if abs(self.scale) < 1e-12: raise ValueError("clock model scale is zero") array = np.asarray(value, dtype=np.float64) mapped = self.x_ref + (array - self.y_ref) / self.scale return float(mapped) if array.ndim == 0 else mapped def to_dict(self) -> dict[str, float | int]: return asdict(self) def fit_affine_clock( x: np.ndarray, y: np.ndarray, *, max_iterations: int = 4, min_residual_gate_s: float = 5e-4, ) -> AffineClockModel: """Robustly fit an affine clock map while rejecting receive-time spikes. ``x`` and ``y`` may have large, unrelated epochs. Centering around their medians avoids losing precision when host UTC is around 1e9 seconds. """ x_values = np.asarray(x, dtype=np.float64).reshape(-1) y_values = np.asarray(y, dtype=np.float64).reshape(-1) finite = np.isfinite(x_values) & np.isfinite(y_values) x_values = x_values[finite] y_values = y_values[finite] if x_values.size < 2: raise ValueError("need at least two finite clock samples") x_ref = float(np.median(x_values)) y_ref = float(np.median(y_values)) dx = x_values - x_ref dy = y_values - y_ref inliers = np.ones(x_values.size, dtype=bool) scale = 1.0 offset = 0.0 for _ in range(max_iterations): local_x = dx[inliers] local_y = dy[inliers] denom = float(local_x @ local_x) if denom < 1e-18: raise ValueError("clock samples do not span enough time") scale = float(local_x @ local_y / denom) offset = float(np.median(local_y - scale * local_x)) residual = dy - (offset + scale * dx) center = float(np.median(residual[inliers])) mad = float(np.median(np.abs(residual[inliers] - center))) sigma = 1.4826 * mad gate = max(float(min_residual_gate_s), 6.0 * sigma) updated = np.abs(residual - center) <= gate if np.count_nonzero(updated) < 2 or np.array_equal(updated, inliers): break inliers = updated # Fold the small centered intercept into y_ref so map/inverse stay simple. y_ref += offset residual = y_values - (y_ref + scale * (x_values - x_ref)) residual_inliers = residual[inliers] return AffineClockModel( x_ref=x_ref, y_ref=y_ref, scale=scale, sample_count=int(x_values.size), inlier_count=int(np.count_nonzero(inliers)), residual_std_s=float(np.std(residual_inliers)), residual_p95_s=float(np.percentile(np.abs(residual_inliers), 95.0)), )