Files
hms-mediaengine/packages/cluster/hms_cluster/clock.py
T

118 lines
4.0 KiB
Python
Raw Normal View History

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