Files
hms-mediaengine/packages/cluster/hms_cluster/clock.py
T
HMS MediaEngine Agent cc119d3751 Phase 2: State-Sync-Fundament – State-Store, Gruppen, Clock, Aktivierung
- ProjectStateStore (§6.4, §24.2): monotone State-/Projekt-Revisionen,
  Snapshot nach Verbindung, Delta mit neu/geaendert/geloescht,
  Szenenabruf als Zielzustand (Uebergang macht der Renderer, §18.1),
  Projekt-/Livezustand strikt getrennt
- GroupRouter (§6.3, §10.1): Ziele All/Node/Output/ServerGroup,
  Regeln selected/tag_query/all, Commit-Vorschau (§17.5)
- ClockEstimator (§6.4): RTT-Min-Filter (<=2x Min gegen Jitter),
  Offset-/Drift-Schaetzung (Drift erst ab 1 s Fenster belastbar),
  Showzeit -> lokale Node-Zeit
- ActivationCoordinator (§6.5): zeitgestempelte Preset-Aktivierung,
  200 ms Vorlauf, Arm/Execute/Ack-Kette, FAILED bei fehlender
  Arm-Bestaetigung (kein stilles Ueberbruecken)
- 35 neue Unit-Tests; Gesamtsuite 293 gruen, Ruff gruen
2026-09-11 01:31:26 +02:00

118 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()