"""Event-line emission and CSV writers. The ``emit_event`` output line is a contract with ``saqr.robot.bridge`` — its regex parses this exact format. Don't change the field order without updating the bridge. """ from __future__ import annotations import csv from datetime import datetime from pathlib import Path from typing import Dict, List from saqr.core.compliance import split_wearing_missing from saqr.core.detection import CLASS_ORDER def now_iso() -> str: return datetime.now().isoformat(timespec="seconds") class EventLogger: FIELDS = ["timestamp", "track_id", "event_type", "status", "wearing", "missing", "unknown", "photo", "path"] def __init__(self, path: Path): self.path = path self.path.parent.mkdir(parents=True, exist_ok=True) if not self.path.exists(): with open(self.path, "w", newline="", encoding="utf-8") as f: csv.DictWriter(f, fieldnames=self.FIELDS).writeheader() def append(self, row: Dict[str, str]) -> None: with open(self.path, "a", newline="", encoding="utf-8") as f: csv.DictWriter(f, fieldnames=self.FIELDS).writerow(row) def write_result_csv(tracks: List, output: Path) -> None: output.parent.mkdir(parents=True, exist_ok=True) fields = ["photo", "track_id", "status", "last_seen", "wearing", "missing", "unknown", *CLASS_ORDER, "path"] rows = [] for track in sorted(tracks, key=lambda t: t.track_id): wearing, missing, unknown = split_wearing_missing(track.items) row = { "photo": track.photo_path.name if track.photo_path else "", "track_id": track.track_id, "status": track.status, "last_seen": track.last_seen_iso, "wearing": ", ".join(wearing), "missing": ", ".join(missing), "unknown": ", ".join(unknown), "path": str(track.photo_path) if track.photo_path else "", } for cls in CLASS_ORDER: row[cls] = 1 if track.items.get(cls, 0.0) > 0 else 0 rows.append(row) with open(output, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=fields) w.writeheader() w.writerows(rows) def emit_event(track, event_logger: EventLogger, event_type: str = "STATUS_CHANGE", force: bool = False) -> None: if track.photo_path is None: return if not force and track.announced_status == track.status: return wearing, missing, unknown = split_wearing_missing(track.items) msg = ( f"ID {track.track_id:04d} | {event_type} | {track.status} | " f"wearing: {', '.join(wearing) or 'none'} | " f"missing: {', '.join(missing) or 'none'} | " f"unknown: {', '.join(unknown) or 'none'}" ) print(msg, flush=True) event_logger.append({ "timestamp": now_iso(), "track_id": str(track.track_id), "event_type": event_type, "status": track.status, "wearing": ", ".join(wearing), "missing": ", ".join(missing), "unknown": ", ".join(unknown), "photo": track.photo_path.name if track.photo_path else "", "path": str(track.photo_path) if track.photo_path else "", }) track.announced_status = track.status track.event_count += 1