0922cc1d68
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33).
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""Parameter-Engine: Prioritäten, Übernahme, Frame-Snapshot (PLAN.md §11).
|
|
|
|
Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über
|
|
diese Engine; direkte Renderer-Zugriffe sind verboten (§11, §33).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
|
|
from hms_parameter.paths import validate_parameter_path
|
|
|
|
|
|
class ControlSource(enum.IntEnum):
|
|
"""Steuerquellen in Prioritätsordnung (§11.2)."""
|
|
|
|
SAFETY = 1 # Not-Aus/Blackout, überstimmt alles
|
|
OPERATOR = 2 # expliziter manueller Override
|
|
CONSOLE = 3 # freigegebenes Lichtpult (Art-Net)
|
|
WEB = 4 # Browser-Livebedienung
|
|
TIMELINE = 5 # reserviert
|
|
AUDIO = 6 # reserviert (Modulatoren)
|
|
AI = 7 # reserviert, niedrigste Priorität
|
|
|
|
|
|
class MergeMode(enum.Enum):
|
|
"""Übernahmeverfahren (§11.3)."""
|
|
|
|
LTP = "ltp" # letzte Änderung gewinnt (Standard)
|
|
HTP = "htp" # höchster Wert gewinnt (optional für Intensität)
|
|
|
|
|
|
@dataclass
|
|
class _Binding:
|
|
value: float
|
|
last_change_ns: int
|
|
|
|
|
|
class ParameterFrame:
|
|
"""Unveränderlicher Snapshot aller Parameter für genau einen Frame (§11.4)."""
|
|
|
|
__slots__ = ("_values", "revision", "created_ns")
|
|
|
|
def __init__(self, values: dict[str, float], revision: int) -> None:
|
|
object.__setattr__(self, "_values", dict(values))
|
|
object.__setattr__(self, "revision", revision)
|
|
object.__setattr__(self, "created_ns", time.monotonic_ns())
|
|
|
|
def get(self, path: str, default: float = 0.0) -> float:
|
|
return self._values.get(path, default)
|
|
|
|
def as_dict(self) -> dict[str, float]:
|
|
return dict(self._values)
|
|
|
|
def __contains__(self, path: str) -> bool:
|
|
return path in self._values
|
|
|
|
|
|
class RevisionConflict(Exception):
|
|
"""Erwartete Revision stimmt nicht (optimistische Sperre, §23.2)."""
|
|
|
|
def __init__(self, current: int, expected: int) -> None:
|
|
self.current = current
|
|
self.expected = expected
|
|
super().__init__(f"revision conflict: current={current}, expected={expected}")
|
|
|
|
|
|
@dataclass
|
|
class ParameterEngine:
|
|
"""Autoritative Parameter-Instanz des Control Core.
|
|
|
|
- set_value: Override einer Quelle mit Prioritätsprüfung
|
|
- release: Rückgabe an nächstniedrigere Quelle (§11.3)
|
|
- snapshot: atomarer Frame-Snapshot (§11.4)
|
|
"""
|
|
|
|
revision: int = 0
|
|
default: float = 0.0
|
|
merge_mode: MergeMode = MergeMode.LTP
|
|
_bindings: dict[str, dict[ControlSource, _Binding]] = field(
|
|
default_factory=dict, repr=False
|
|
)
|
|
_defaults: dict[str, float] = field(default_factory=dict, repr=False)
|
|
|
|
def set_value(
|
|
self,
|
|
path: str,
|
|
value: float,
|
|
source: ControlSource,
|
|
expected_revision: int | None = None,
|
|
) -> int:
|
|
"""Setzt einen Override; gibt die neue Revision zurück."""
|
|
if not validate_parameter_path(path):
|
|
raise ValueError(f"invalid parameter path: {path!r}")
|
|
value = float(value)
|
|
if value != value or value in (float("inf"), float("-inf")):
|
|
raise ValueError(f"value must be finite, got {value}")
|
|
if expected_revision is not None and expected_revision != self.revision:
|
|
raise RevisionConflict(self.revision, expected_revision)
|
|
|
|
per_source = self._bindings.setdefault(path, {})
|
|
# Priorität: eine niedrigere Quelle kann eine höhere Quelle nicht
|
|
# verdrängen, aber ihre eigene Bindung jederzeit aktualisieren.
|
|
existing = per_source.get(source)
|
|
now = time.monotonic_ns()
|
|
if existing is None:
|
|
per_source[source] = _Binding(value, now)
|
|
self.revision += 1
|
|
elif self.merge_mode is MergeMode.LTP:
|
|
# LTP: jede Übernahme aktualisiert Bindung und Revision (§11.3)
|
|
per_source[source] = _Binding(value, now)
|
|
self.revision += 1
|
|
elif value > existing.value:
|
|
# HTP: nur ein höherer Wert übernimmt; Maximum bleibt (§11.3)
|
|
per_source[source] = _Binding(value, now)
|
|
self.revision += 1
|
|
return self.revision
|
|
|
|
def effective_value(self, path: str) -> float:
|
|
"""Wirksamer Wert: höchste Priorität gewinnt; sonst Default (§11.1)."""
|
|
per_source = self._bindings.get(path)
|
|
if not per_source:
|
|
return self._defaults.get(path, self.default)
|
|
source = min(per_source) # kleinster IntEnum-Wert = höchste Priorität
|
|
return per_source[source].value
|
|
|
|
def current_source(self, path: str) -> ControlSource | None:
|
|
per_source = self._bindings.get(path)
|
|
if not per_source:
|
|
return None
|
|
return min(per_source)
|
|
|
|
def release(self, path: str, source: ControlSource) -> int:
|
|
"""Gibt den Override zurück; nächstniedrigere Quelle übernimmt (§11.3)."""
|
|
per_source = self._bindings.get(path)
|
|
if per_source and source in per_source:
|
|
del per_source[source]
|
|
if not per_source:
|
|
self._bindings.pop(path, None)
|
|
self.revision += 1
|
|
return self.revision
|
|
|
|
def snapshot(self) -> ParameterFrame:
|
|
"""Atomarer Snapshot aller wirksamen Werte für einen Frame (§11.4)."""
|
|
values = {p: self._defaults[p] for p in self._defaults}
|
|
for path, per_source in self._bindings.items():
|
|
if per_source:
|
|
values[path] = per_source[min(per_source)].value
|
|
return ParameterFrame(values, self.revision)
|
|
|
|
def set_default(self, path: str, value: float) -> None:
|
|
if not validate_parameter_path(path):
|
|
raise ValueError(f"invalid parameter path: {path!r}")
|
|
self._defaults[path] = float(value)
|