Files
RFP_Infinity-Vis/Infinity_Vis_1/infinity_vis_1/engine.py

197 lines
7.0 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
import time
from .models import FrameSnapshot, MappingSpec, SceneState
from .output.ddp import DDPClient
from .renderer import FastRenderer
@dataclass(slots=True)
class OutputSettings:
output_fps: float = 40.0
preview_fps: float = 12.0
keepalive_seconds: float = 0.35
delta_sending: bool = True
preview_enabled: bool = True
@dataclass(slots=True)
class RuntimeDiagnostics:
tile_count: int
controller_count: int
total_leds: int
output_frames: int = 0
preview_frames: int = 0
packets_built: int = 0
packets_sent: int = 0
changed_controller_frames: int = 0
keepalive_controller_frames: int = 0
stale_output_frames: int = 0
last_render_ms: float = 0.0
last_tick_at: float = 0.0
last_output_at: float = 0.0
last_preview_at: float = 0.0
@dataclass(slots=True)
class TickResult:
timestamp: float
output_due: bool
preview_due: bool
output_snapshot: FrameSnapshot | None = None
preview_snapshot: FrameSnapshot | None = None
changed_payloads: dict[str, bytes] = field(default_factory=dict)
packet_count: int = 0
class RealtimeEngine:
def __init__(
self,
mapping: MappingSpec,
scene: SceneState | None = None,
settings: OutputSettings | None = None,
ddp_client: DDPClient | None = None,
) -> None:
self.mapping = mapping
self.scene = scene or SceneState()
self.settings = settings or OutputSettings()
self.renderer = FastRenderer(mapping)
self.ddp_client = ddp_client or DDPClient()
renderer_stats = self.renderer.stats()
self.diagnostics = RuntimeDiagnostics(
tile_count=renderer_stats.tile_count,
controller_count=renderer_stats.controller_count,
total_leds=renderer_stats.total_leds,
)
self._last_output_at: float | None = None
self._last_preview_at: float | None = None
self._last_sent_payloads: dict[str, bytes] = {}
self._last_sent_at: dict[str, float] = {}
self._latest_snapshot: FrameSnapshot | None = None
self._force_output = True
self._force_preview = True
def set_scene(self, scene: SceneState) -> None:
self.scene = scene
self._force_output = True
self._force_preview = True
def request_preview_refresh(self) -> None:
self._force_preview = True
def latest_snapshot(self) -> FrameSnapshot | None:
return self._latest_snapshot
def tick(self, now: float | None = None) -> TickResult:
timestamp = time.perf_counter() if now is None else float(now)
output_due = self._is_due(self._last_output_at, self._interval(self.settings.output_fps), timestamp, self._force_output)
preview_due = self.settings.preview_enabled and self._is_due(
self._last_preview_at,
self._interval(self.settings.preview_fps),
timestamp,
self._force_preview,
)
if not output_due and not preview_due:
return TickResult(timestamp=timestamp, output_due=False, preview_due=False)
render_started = time.perf_counter()
snapshot = self.renderer.render(self.scene, timestamp=timestamp)
self.diagnostics.last_render_ms = (time.perf_counter() - render_started) * 1000.0
self.diagnostics.last_tick_at = timestamp
self._latest_snapshot = snapshot
changed_payloads: dict[str, bytes] = {}
packet_count = 0
output_snapshot: FrameSnapshot | None = None
preview_snapshot: FrameSnapshot | None = None
if output_due:
changed_payloads, changed_count, keepalive_count = self._select_output_payloads(
snapshot.controller_payloads,
timestamp,
)
output_snapshot = snapshot
self._last_output_at = timestamp
self._force_output = False
self.diagnostics.output_frames += 1
self.diagnostics.changed_controller_frames += changed_count
self.diagnostics.keepalive_controller_frames += keepalive_count
self.diagnostics.last_output_at = timestamp
if changed_payloads:
packet_count = self.ddp_client.count_packets(changed_payloads)
self.diagnostics.packets_built += packet_count
else:
self.diagnostics.stale_output_frames += 1
if preview_due:
preview_snapshot = snapshot
self._last_preview_at = timestamp
self._force_preview = False
self.diagnostics.preview_frames += 1
self.diagnostics.last_preview_at = timestamp
return TickResult(
timestamp=timestamp,
output_due=output_due,
preview_due=preview_due,
output_snapshot=output_snapshot,
preview_snapshot=preview_snapshot,
changed_payloads=changed_payloads,
packet_count=packet_count,
)
def send_due(self, now: float | None = None) -> TickResult:
result = self.tick(now=now)
if result.changed_payloads:
sent = self.ddp_client.send(result.changed_payloads)
self.diagnostics.packets_sent += sent
return result
def _interval(self, fps: float) -> float:
if fps <= 0:
return 0.0
return 1.0 / float(fps)
def _is_due(self, last_at: float | None, interval: float, now: float, forced: bool) -> bool:
if forced or last_at is None:
return True
if interval <= 0:
return True
return (now - last_at) >= max(0.0, interval - 1e-9)
def _select_output_payloads(self, payloads: dict[str, bytes], timestamp: float) -> tuple[dict[str, bytes], int, int]:
if not self.settings.delta_sending:
for host, payload in payloads.items():
self._last_sent_payloads[host] = payload
self._last_sent_at[host] = timestamp
return dict(payloads), len(payloads), 0
changed_payloads: dict[str, bytes] = {}
changed_count = 0
keepalive_count = 0
keepalive_seconds = max(0.0, float(self.settings.keepalive_seconds))
for host, payload in payloads.items():
previous = self._last_sent_payloads.get(host)
last_sent_at = self._last_sent_at.get(host)
unchanged = previous == payload
keepalive_due = (
keepalive_seconds > 0.0
and unchanged
and last_sent_at is not None
and (timestamp - last_sent_at) >= keepalive_seconds
)
if previous is None or not unchanged or keepalive_due:
changed_payloads[host] = payload
self._last_sent_payloads[host] = payload
self._last_sent_at[host] = timestamp
if keepalive_due and unchanged:
keepalive_count += 1
else:
changed_count += 1
return changed_payloads, changed_count, keepalive_count