"""Clock-Offset- und Drift-Messung (PLAN.md §6.4 Clock Sync). V1-Verfahren: PTP, wenn verfügbar; sonst gemessene Offset-/Drift- Schätzung gegen den Coordinator (§6.4). Das hier ist die softwareseitige Messung: Round-Trip-basierte Offset-Schätzung mit Min-Filterung (NTP-artig) und linearer Drift-Schätzung über Probenpaare. Grenzen (§6.4): messbare Softwarezeit, kein Genlock; p95 ≤ 10 ms für vorgepufferte Preset-/Command-Starts im verkabelten Referenz-LAN ist ein Abnahmeziel von Gate 2, keine Zusage für beliebige Netze. """ from __future__ import annotations import time from dataclasses import dataclass, field @dataclass(frozen=True) class ClockSample: """Eine RTT-Messprobe zwischen Coordinator und Node. t0/t1 in lokaler monotoner Zeit des Coordinators; node_time ist die vom Node zurückgemeldete eigene monotone Zeit (normiert). """ t0_ns: int t1_ns: int node_time_ns: int @property def rtt_ns(self) -> int: return self.t1_ns - self.t0_ns @property def offset_ns(self) -> int: """Min-RTT-Näherung: Offset = node_time - (t0 + rtt/2).""" return self.node_time_ns - (self.t0_ns + self.rtt_ns // 2) @dataclass class ClockEstimator: """Schätzt Offset und Drift aus RTT-Proben (§6.4). - feed(): neue Probe; behält die Proben mit kleinster RTT (Min-Filter, weil geringe RTT ≈ geringe Warteschlangen-Verzögerung) - offset: geglätteter Offset gegen den Coordinator - drift_ppm: Änderung des Offsets über die Zeit (μs/s) - Window begrenzt (kein unbeschränkter Zustand, §33) """ max_samples: int = 64 _samples: list[ClockSample] = field(default_factory=list) _best: list[ClockSample] = field(default_factory=list) # kleinste RTTs def feed(self, sample: ClockSample) -> None: if sample.rtt_ns < 0: raise ValueError("negative RTT unmöglich") self._samples.append(sample) if len(self._samples) > self.max_samples: self._samples.pop(0) # Min-RTT-Filter: nur Proben mit RTT ≤ 2× Minimum sind belastbar; # hohe RTT bedeutet Warteschlangen-Jitter, der den Offset verfälscht min_rtt = min(s.rtt_ns for s in self._samples) ranked = sorted( (s for s in self._samples if s.rtt_ns <= 2 * min_rtt), key=lambda s: s.rtt_ns, )[:8] self._best = ranked @property def offset_ns(self) -> int | None: """Aktueller Offset-Schätzer (Mittel über Best-Proben).""" if not self._best: return None return sum(s.offset_ns for s in self._best) // len(self._best) @property def rtt_ns(self) -> int | None: """Beste (kleinste) gemessene RTT.""" if not self._best: return None return min(s.rtt_ns for s in self._best) @property def drift_ppm(self) -> float | None: """Lineare Drift-Schätzung über die Best-Proben (μs/s). Offset-Änderung geteilt durch verstrichene RTT-Mittezeit; None bei weniger als zwei Best-Proben oder zu kurzem Fenster (< 1 s). """ if len(self._best) < 2: return None ordered = sorted(self._best, key=lambda s: s.t0_ns) first, last = ordered[0], ordered[-1] dt_ns = last.t0_ns - first.t0_ns if dt_ns < 1_000_000_000: # < 1 s: Drift nicht belastbar return None d_offset = last.offset_ns - first.offset_ns return (d_offset / dt_ns) * 1_000_000.0 def map_show_time(self, show_time_ns: int) -> int | None: """Bildet Coordinator-Showzeit auf lokale Node-Zeit ab (§6.4: Showzeit → lokale Monotonic). Voraussetzung: dieser Estimator läuft Node-seitig mit Proben, deren node_time die eigene Uhr ist. """ offset = self.offset_ns if offset is None: return None return show_time_ns + offset def now_monotonic_ns() -> int: """Gemeinsame monotone Zeitbasis (§12.2: Audio/Video gemeinsame Basis).""" return time.monotonic_ns()