"""CsvTelemetryLogger — writes telemetry snapshots to a CSV file with throttling. Design decisions: - Does NOT use the logging module — writes directly via csv.writer so the file is readable independently of the text log level. - Flushes after every row so the file is intact even on crash or force-quit. - Throttle: only one row per TELEMETRY_CSV_INTERVAL_S seconds, even if metrics_updated fires every 500 ms. This keeps the file manageable for long sessions (8 h @ 5 s interval = 5 760 rows). """ from __future__ import annotations import csv import time from datetime import datetime from pathlib import Path from app.config import TELEMETRY_CSV_INTERVAL_S from app.telemetry.telemetry_collector import TelemetrySnapshot _CSV_HEADER = [ "timestamp", "fps_got", "fps_req", "frame_time_ms", "dropped_frames", "cpu_sys_pct", "cpu_core_pct", "mem_mb", ] class CsvTelemetryLogger: """ Receives TelemetrySnapshot objects and writes throttled rows to a CSV file. Usage: logger = CsvTelemetryLogger(path) telemetry_collector.metrics_updated.connect(logger.on_metrics_updated) # call logger.close() on application exit """ def __init__( self, path: Path, interval_s: float = TELEMETRY_CSV_INTERVAL_S, ) -> None: self._interval_s = interval_s self._last_write_time: float = 0.0 self._rows_written: int = 0 path.parent.mkdir(parents=True, exist_ok=True) self._file = path.open("w", newline="", encoding="utf-8") self._writer = csv.writer(self._file) self._writer.writerow(_CSV_HEADER) self._file.flush() # ------------------------------------------------------------------ # Slot — connect to TelemetryCollector.metrics_updated # ------------------------------------------------------------------ def on_metrics_updated(self, snapshot: TelemetrySnapshot) -> None: """Write a row if the throttle interval has elapsed.""" now = time.monotonic() if now - self._last_write_time < self._interval_s: return self._last_write_time = now self._write_row(snapshot) # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def close(self) -> None: """Flush and close the CSV file. Call on application shutdown.""" try: self._file.flush() self._file.close() except OSError: pass @property def rows_written(self) -> int: return self._rows_written # ------------------------------------------------------------------ # Private # ------------------------------------------------------------------ def _write_row(self, snap: TelemetrySnapshot) -> None: ts = datetime.now().strftime("%H:%M:%S.%f")[:-3] # HH:MM:SS.mmm self._writer.writerow([ ts, f"{snap.fps:.1f}", f"{snap.target_fps:.1f}" if snap.target_fps is not None else "", f"{snap.frame_time_ms:.2f}", snap.dropped_frames, f"{snap.cpu_percent_sys:.1f}", f"{snap.cpu_percent_core:.1f}", f"{snap.memory_mb:.1f}" if snap.memory_mb is not None else "", ]) self._file.flush() self._rows_written += 1