"""One manually confirmed finger load/unload trial. The acquisition worker owns frames; the shared recorder owns disk I/O. This module owns only the site, planned steps and operator-confirmed event order. """ from __future__ import annotations import csv import hashlib import json import re import time from dataclasses import dataclass from pathlib import Path import numpy as np from orisys_app_shared.record import ExperimentRecorder, RecordConfig REFERENCE_HEADER = ( "step_index", "branch", "target_load_n", "t_start_host_mono_ns", "t_end_host_mono_ns", "F_ref_normal_N", "F_ref_x_N", "F_ref_y_N", "F_ref_z_N", "force_frame", "source", "stability_verified", ) def _folder_part(value: str) -> str: return re.sub(r"[^\w-]+", "-", value.strip()).strip("-_ ")[:48] @dataclass(frozen=True) class Site: u_px: int v_px: int position_sensor_mm: tuple[float, float, float] face_index: int raster_xy_px: tuple[int, int] uv_origin_raster_xy_px: tuple[int, int] def uv_origin(flat) -> tuple[int, int]: """Lowest valid pixel in the valid column nearest the image midpoint.""" rows, columns = np.nonzero(flat.face_index >= 0) if not len(rows): raise ValueError("UV map has no valid sensor pixels") middle = (flat.width - 1) / 2 column = int(columns[np.argmin(np.abs(columns - middle))]) row = int(rows[columns == column].max()) return column, row def site_at( flat, u_px: int, v_px: int, *, origin: tuple[int, int] | None = None ) -> Site: """Resolve a precise raster pixel; empty mask pixels cannot become sites.""" if flat is None: raise ValueError("UV map is not loaded") origin_x, origin_y = origin if origin is not None else uv_origin(flat) raster_x, raster_y = origin_x + int(u_px), origin_y - int(v_px) sample = flat.sample(raster_x, raster_y) if sample is None: raise ValueError("UV pixel is outside the valid sensor surface") point, _normal, face = sample return Site( int(u_px), int(v_px), tuple(float(v) for v in point), int(face), (raster_x, raster_y), (origin_x, origin_y), ) def load_path(levels: list[float]) -> list[tuple[str, float]]: """One continuous ascent and descent, ending at zero.""" values = [float(value) for value in levels] if not values or any(not 0.0 < value <= 30.0 for value in values): raise ValueError("load levels must be in (0, 30] N") if values != sorted(set(values)): raise ValueError("load levels must be strictly increasing") return [("load", value) for value in values] + [ ("unload", value) for value in [*reversed(values[:-1]), 0.0] ] class ManualTrial: """Connect a running finger source to one independent recording directory.""" def __init__(self, worker) -> None: self.worker = worker self.recorder: ExperimentRecorder | None = None self.directory: Path | None = None self.steps: list[tuple[str, float]] = [] self.step_index = 0 self.status = "idle" self.aborted = False self.reference_alignment_possible = False self._last_confirm_ns: int | None = None self._reference_file = None self._reference_writer = None self._active_reference: tuple[int, str, float, float, int] | None = None self._reference_error = "" def start( self, *, root: Path, site: Site, levels: list[float], hold_s: float, sensor_model: str, sensor_sn: str, bundle_path: str, source: dict, config_ref: str, fps: float, ) -> Path: if self.status in {"recording", "closing"}: raise RuntimeError("a trial is already active") if not self.worker.is_running or self.worker.latest() is None: raise RuntimeError("start the finger input and wait for its first frame") if not sensor_sn.strip(): raise ValueError("sensor SN is required") if not sensor_model.strip(): raise ValueError("sensor model is required") model_part = _folder_part(sensor_model) sn_part = _folder_part(sensor_sn) if not model_part or not sn_part: raise ValueError( "sensor model and SN need letters or numbers for the folder" ) label = f"{model_part}_{sn_part}" if not float(fps) > 0.0: raise ValueError("input frame rate must be positive") self.steps = load_path(levels) if not 10.0 <= float(hold_s) <= 30.0: raise ValueError("hold time must be between 10 and 30 seconds") packet = self.worker.latest() if packet.capture_mono_ns is None: raise RuntimeError("the input has no frame-bound host timestamp") mesh_path = Path(bundle_path) / "mesh.npz" config_path = Path(config_ref) if not mesh_path.is_file() or not config_path.is_file(): raise ValueError("sensor mesh and configuration files are required") mesh_sha256 = hashlib.sha256(mesh_path.read_bytes()).hexdigest() config_sha256 = hashlib.sha256(config_path.read_bytes()).hexdigest() self.reference_alignment_possible = packet.time_basis == "host_camera" metadata = { "purpose": "autocal_finger_manual_trial", "sensor_sn": sensor_sn.strip(), "sensor_model": sensor_model.strip(), "site": { "uv_xy_px": [site.u_px, site.v_px], "position_sensor_mm": list(site.position_sensor_mm), "face_index": site.face_index, "raster_xy_px": list(site.raster_xy_px), "uv_origin_raster_xy_px": list(site.uv_origin_raster_xy_px), "bundle_path": bundle_path, "mesh_sha256": mesh_sha256, }, "plan": {"levels_n": levels, "hold_s": float(hold_s)}, "input_source": source, "config_ref": config_ref, "config_sha256": config_sha256, "time_basis": packet.time_basis, "reference_alignment_possible": self.reference_alignment_possible, "reference_source": "manual_operator_confirmation", "reference_file": "reference.csv", } recorder = ExperimentRecorder( RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0) ) directory = recorder.start( root=Path(root), session_id=packet.session_id, metadata=metadata, directory_label=label, ) reference_file = None try: reference_file = (directory / "reference.csv").open( "w", newline="", encoding="utf-8" ) reference_writer = csv.writer(reference_file) reference_writer.writerow(REFERENCE_HEADER) reference_file.flush() except OSError: if reference_file is not None: reference_file.close() recorder.stop(wait=True) raise self.recorder = recorder self.directory = directory self._reference_file = reference_file self._reference_writer = reference_writer self._active_reference = None self._reference_error = "" self.step_index = 0 self.aborted = False self._last_confirm_ns = None self.status = "recording" self.worker.add_sink(recorder.on_packet) self._event("trial_started", {"site_uv_xy_px": [site.u_px, site.v_px]}) return directory @property def next_step(self) -> tuple[str, float] | None: return ( self.steps[self.step_index] if self.step_index < len(self.steps) else None ) def _event(self, kind: str, detail: dict, *, now_ns: int | None = None) -> None: recorder = self.recorder if recorder is None: return now_mono = now_ns if now_ns is not None else time.perf_counter_ns() packet = self.worker.latest() if packet is not None and packet.capture_mono_ns is not None: event_mono_t = ( float(packet.mono_t or 0.0) + (now_mono - packet.capture_mono_ns) / 1e9 ) frame_id = packet.frame_id else: event_mono_t = None frame_id = -1 recorder.log_event( kind, json.dumps( {**detail, "t_host_mono_ns": now_mono, "t_host_utc_ns": time.time_ns()}, ensure_ascii=False, ), mono_t=event_mono_t, frame_id=frame_id, ) def _end_reference(self, end_ns: int) -> None: if self._active_reference is None: return step_index, branch, target, reference, start_ns = self._active_reference try: self._reference_writer.writerow( ( step_index, branch, target, start_ns, end_ns, reference, "", "", "", "", "manual_operator_confirmation", 0, ) ) self._reference_file.flush() except OSError as exc: self._reference_error = str(exc) finally: self._active_reference = None def confirm(self, reference_n: float) -> tuple[str, float]: if self.status != "recording" or self.next_step is None: raise RuntimeError("no load step is waiting for confirmation") branch, target = self.next_step reference = float(reference_n) if not 0.0 <= reference <= 30.0: raise ValueError("reference load must be between 0 and 30 N") now_ns = time.perf_counter_ns() if self._last_confirm_ns is not None: self._end_reference(now_ns) if self._reference_error: self.finish( aborted=True, reason=f"reference write failed: {self._reference_error}", ) raise OSError(self._reference_error) self._event( "step_ended", { "step_index": self.step_index - 1, "held_s": (now_ns - self._last_confirm_ns) / 1e9, }, now_ns=now_ns, ) self._event( "step_reached", { "step_index": self.step_index, "branch": branch, "target_load_n": target, "reference_load_n": reference, "reference_kind": "manual", }, now_ns=now_ns, ) self._active_reference = (self.step_index, branch, target, reference, now_ns) self.step_index += 1 self._last_confirm_ns = now_ns return branch, target @property def held_s(self) -> float | None: if self._last_confirm_ns is None or self.status != "recording": return None return (time.perf_counter_ns() - self._last_confirm_ns) / 1e9 def finish(self, *, aborted: bool = False, reason: str = "") -> None: if self.status != "recording" or self.recorder is None: raise RuntimeError("no active trial") if not aborted and self.next_step is not None: raise RuntimeError("complete the load/unload path before finishing") now_ns = time.perf_counter_ns() if self._last_confirm_ns is not None: self._end_reference(now_ns) self._event( "step_ended", { "step_index": self.step_index - 1, "held_s": (now_ns - self._last_confirm_ns) / 1e9, }, now_ns=now_ns, ) try: self._reference_file.close() except OSError as exc: self._reference_error = str(exc) if self._reference_error: aborted = True reason = f"reference write failed: {self._reference_error}" self._event( "trial_aborted" if aborted else "trial_finished", {"reason": reason}, now_ns=now_ns, ) self.aborted = aborted self.worker.remove_sink(self.recorder.on_packet) self.recorder.stop(wait=False) self.status = "closing" def poll(self) -> str: if self.status == "recording" and self.recorder is not None: if not self.worker.is_running or not self.recorder.is_recording: self.finish(aborted=True, reason="input or writer stopped") if self.status == "closing" and self.recorder is not None: report = self.recorder.report if report is not None: self.status = ( "complete" if ( report.ok and report.stats.frames > 0 and report.stats.video_frames > 0 and report.stats.segments == 1 and report.stats.dropped_data == 0 and report.stats.dropped_video == 0 and not self.aborted and self.next_step is None ) else "incomplete" ) return self.status