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