118 lines
4.0 KiB
Python
118 lines
4.0 KiB
Python
|
|
"""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()
|