diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index 4759fff..b3f37b3 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json index 49f5287..6c36d4e 100644 --- a/apps/studio/config/locales/en_US.json +++ b/apps/studio/config/locales/en_US.json @@ -115,6 +115,8 @@ "box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)", "selection": "Selection {duration}s / {samples} samples — {detail}", "selection_empty": "No valid samples in the selection", + "value_unavailable": "n/a", + "value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)", "browse_only_tip": "Drag or scroll on the chart to browse history" }, "layout": { @@ -154,14 +156,21 @@ "depth_state_approximate": "No reset or pre-roll history for this segment: the algorithm state is an approximation", "depth_warnings": "Recomputation notes: {detail}", "warnings": "Replay notes: {detail}", + "decimated": "This recording is long, so the curves are a decimated view ({shown} of {total} rows; intervals are read from disk on demand)", "warn_config_changed": "the config differs from recording time, so the recomputed depth may differ", "warn_sdk_changed": "the SDK version differs from recording time, so the recomputation may differ", "warn_config_missing": "no config hash was recorded, so recomputation cannot be verified", "warn_depth_recomputed": "depth comes from offline recomputation, not from stored data", "warn_rows_skipped": "some unparsable CSV rows were skipped", "warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")", + "warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ", "warn_state_across_segment": "the algorithm state cannot be carried across a segment", - "warn_state_approximate": "the algorithm state is an approximation" + "warn_state_approximate": "the algorithm state is an approximation", + "warn_state_approximate_after_rewind": "the video was reopened to reach an earlier frame, so the state may differ", + "warn_state_too_far_back": "the zeroing is too far back: reading there would exceed the replay budget, so the state is approximate", + "warn_reset_frame_not_indexed": "the zeroing frame is not in the frame index, so the state cannot be rebuilt from it", + "warn_reset_frame_not_in_video": "the zeroing frame never reached the video, so the state cannot be rebuilt from it", + "warn_reset_not_applied": "the replay could not re-apply the zeroing, so the state may differ" }, "record": { "label": "Record", @@ -177,6 +186,8 @@ "start_failed": "Could not start recording: {error}", "finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}", "finished_with_problems": "Recording finished with problems: {detail}", + "closing": "Closing the recording files…", + "failed_state": "The recording failed (see the summary and the log)", "alarm": "Recording problem: {detail}" }, "cal": { @@ -204,8 +215,12 @@ "open_record_tip": "Reopen a saved calibration session to inspect every point's samples and diagnostics", "open_record_filter": "Calibration records (*.json)", "open_record_ok": "Loaded {path} ({points} point(s))", + "open_record_device": "Recorded on: {device}", "open_record_warnings": "Note: {detail}", "open_record_failed": "Could not open the calibration record: {error}", + "apply_mismatch_title": "Calibration does not match the target device", + "apply_mismatch": "This coefficient file belongs to: {device}\nMismatch: {detail}\n\nCurrent target: {sensor_type} / {config}\n\nApplying it would load coefficients that were not measured on this device.", + "apply_mismatch_skipped": "Apply cancelled (the calibration belongs to another device)", "sn": "SN", "sn_placeholder": "Serial number (optional)", "live_raw_label": "Live raw", @@ -255,11 +270,13 @@ "export_local": "Also wrote {path}", "export_warnings": "Skipped: {detail}", "export_failed": "Export failed: {error}", + "export_no_model": "Nothing to export: {detail}", "apply_title": "Apply calibration", "apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.", "apply_skipped": "Exported; not applied to the device", "apply_pending": "Requested calibration for {sensor_type}; waiting for the device to confirm…", "applied": "Device confirmed the calibration is in effect (version {version})", + "applied_none": "Device confirmed: no calibration is loaded (the column stays empty)", "export_only": "Exported coefficients to {path} (not applied to the device)", "apply_no_target": "No device to apply to (run an acquisition first)" }, @@ -286,6 +303,7 @@ "video_ended": "Video ended", "camera_disconnected": "Camera disconnected", "reset_ok": "Tracker reset", + "reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…", "finger_run_unavailable": "Run is not available for this sensor type", "video_required": "Select a video file first", "config_missing": "Sensor config not found for {type}", diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json index 9e9d3d0..9cee18d 100644 --- a/apps/studio/config/locales/zh_CN.json +++ b/apps/studio/config/locales/zh_CN.json @@ -115,6 +115,8 @@ "box_zoom_tip": "切换为框选放大(拖动图表框选区域)", "selection": "选区 {duration}s / {samples} 点 — {detail}", "selection_empty": "选区无有效样本", + "value_unavailable": "不可用", + "value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)", "browse_only_tip": "拖动或滚轮即可进入历史浏览" }, "layout": { @@ -154,14 +156,21 @@ "depth_state_approximate": "本段缺少归零或预卷历史,算法参考状态为近似重建", "depth_warnings": "重算提示: {detail}", "warnings": "回放提示: {detail}", + "decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)", "warn_config_changed": "配置文件与录制时不一致,重算出的深度可能与当时不同", "warn_sdk_changed": "SDK 版本与录制时不一致,重算结果可能不同", "warn_config_missing": "找不到配置哈希,无法校验重算一致性", "warn_depth_recomputed": "深度由离线重算得到,不是当时保存的数据", "warn_rows_skipped": "有无法解析的 CSV 行被跳过", "warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)", + "warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同", "warn_state_across_segment": "重算不能跨分段延续算法状态", - "warn_state_approximate": "算法参考状态为近似重建" + "warn_state_approximate": "算法参考状态为近似重建", + "warn_state_approximate_after_rewind": "为回到更早的帧重新打开了视频,参考状态可能偏离当时", + "warn_state_too_far_back": "归零点过于久远,需要读取的帧数超出上限,已按近似状态重建", + "warn_reset_frame_not_indexed": "归零帧不在帧索引中,无法从该点重建状态", + "warn_reset_frame_not_in_video": "归零帧未写入视频,无法从该点重建状态", + "warn_reset_not_applied": "重放无法重新执行归零,参考状态可能偏离当时" }, "record": { "label": "记录", @@ -177,6 +186,8 @@ "start_failed": "无法开始记录: {error}", "finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}", "finished_with_problems": "记录完成,但存在问题: {detail}", + "closing": "正在关闭记录文件…", + "failed_state": "记录已失败(详见摘要与日志)", "alarm": "记录异常: {detail}" }, "cal": { @@ -204,8 +215,12 @@ "open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态", "open_record_filter": "标定记录 (*.json)", "open_record_ok": "已载入 {path}({points} 个点)", + "open_record_device": "记录设备: {device}", "open_record_warnings": "注意: {detail}", "open_record_failed": "打开标定记录失败: {error}", + "apply_mismatch_title": "标定与目标设备不匹配", + "apply_mismatch": "该标定文件属于: {device}\n不匹配项: {detail}\n\n当前目标: {sensor_type} / {config}\n\n继续应用会把不适用于本设备的系数写入运行中的设备。", + "apply_mismatch_skipped": "已取消应用(标定身份与目标设备不匹配)", "sn": "序列号", "sn_placeholder": "序列号(可选)", "live_raw_label": "实时原始值", @@ -255,11 +270,13 @@ "export_local": "同时写入 {path}", "export_warnings": "跳过: {detail}", "export_failed": "导出失败: {error}", + "export_no_model": "没有可导出的模型: {detail}", "apply_title": "应用标定", "apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。", "apply_skipped": "已导出,未应用到设备", "apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…", "applied": "设备已确认标定生效(版本 {version})", + "applied_none": "设备已确认:当前没有加载标定(该项留空)", "export_only": "已导出系数 {path}(未应用到设备)", "apply_no_target": "没有可应用的设备(请先运行一次采集)" }, @@ -286,6 +303,7 @@ "video_ended": "视频已结束", "camera_disconnected": "摄像机已断开", "reset_ok": "追踪器已重置", + "reset_pending": "已请求归零,等待采集线程确认…", "finger_run_unavailable": "此传感器类型暂不支持运行", "video_required": "请先选择视频文件", "config_missing": "未找到传感器配置: {type}", diff --git a/apps/studio/docs/record-channel.md b/apps/studio/docs/record-channel.md index 412748f..b2ddc7f 100644 --- a/apps/studio/docs/record-channel.md +++ b/apps/studio/docs/record-channel.md @@ -293,9 +293,44 @@ outputs/recordings/_session/ --- -## 8. 尚未完成(第三阶段及以后) +## 8. 长历史浏览:按需读盘而不是整段载入 + +`record/index.py` 的 `RecordingIndex` 是回放的数据源索引: + +- 流式扫一遍 `forces.csv`,只把**小东西**留在内存:每行一个时间(float64)、一个 + valid 标志、一个帧号,以及每 `CHECKPOINT_ROWS`(4096)行的字节偏移。500k 行约 + 6.5 MB(`SessionStore` 的默认上限正好对应 30 Hz 下约 4.6 小时)。 +- `row_range(t0, t1)` 用二分定位行区间,`read_rows(start, stop)` 先 `seek` 到最近的 + 检查点再向前扫描——读一个 10 秒区间就是 10 秒的解析量,不是整段。 + 区间会额外带上 `t0` 之前的那一行,绘制时才能画出跨过左边界的线段。 +- `overview(buckets)` 在**同一次流式扫描**里把每行折进所属桶,得到每桶每轴的 + min/max/count。全程色带因此不会漏掉某桶里的尖峰(按行抽样会漏)。 + +`ReadingReader` 相应变化: + +| 方法 | 作用 | +|---|---| +| `info()` | 行数超过 `max_rows`(默认 200k)时给出**降采样视图**,并置 `decimated=True`;`duration_s` 始终取自帧索引,不取自视图 | +| `window(t0, t1, max_points=…)` | 区间精确读取(可再降采样),尖峰按轴保留 | +| `overview(unit, buckets)` | 全程包络,按 (unit, buckets) 缓存 | +| `statistics(t0, t1, unit)` | 只算该区间的统计量 | + +`force/source.py` 把两种来源统一成同一接口:`StoreCurveSource`(实时内存块)与 +`RecordingCurveSource`(磁盘区间 + 缓存包络),都提供 +`extent() / window() / envelope() / statistics() / duration_s()`;`ForcePlotsPanel` +只认这个接口,因此同一套浏览逻辑既能看内存会话也能看磁盘录制。 + +回放窗口在曲线被降采样时会明确写出「显示 N / 共 M 行」,而不是让操作员自己数点。 + +> 未做的验收项:真实分辨率与目标 ARM 板上的最大 RSS、积压与丢失测试(需要硬件), +> 以及选中时间段的 CSV 导出。 + +--- + +## 9. 尚未完成(第三阶段及以后) - PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。 - CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。 - 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。 +- 在目标 ARM 板上用真实分辨率测长时录制的最大 RSS、队列积压与丢失。 diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index ea5c209..dea8059 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -34,10 +34,11 @@ from orisys_app_shared.acquisition.constants import ( ) from orisys_app_shared.force import select_display_forces from orisys_app_shared.force.calibration_recorder import CalibrationRecorder -from orisys_app_shared.force.session import SessionSample, SessionStore +from orisys_app_shared.force.session import SessionStore from orisys_app_shared.paths import recordings_dir -from orisys_app_shared.record import ExperimentRecorder, RecordConfig +from orisys_app_shared.record import RecordConfig from orisys_app_shared.record.sample import to_record_sample +from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.logging import report_error from orisys_app_shared.paths import logo_path, sensor_json_path @@ -133,20 +134,20 @@ class StudioMainWindow(QMainWindow): on_finished=self._on_calibration_point, on_cancelled=self._on_calibration_cancelled, ) - self.recorder = ExperimentRecorder() - self.recorder.set_alarm_handler(self._on_record_alarm) - self._record_alarms: list[str] = [] - self._record_last_report = None - self._record_fps = 30.0 + # The record channel's mechanics (recorder lifecycle, metadata, the + # per-packet fan-out, confirmed events) live in the coordinator; this + # window keeps the buttons, the menus and the status messages. + self.recording = RecordingCoordinator( + session_store=self.session_store, + cal_recorder=self.cal_recorder, + ) + self.recorder = self.recording.recorder self._session_counter = 0 self._playback = None self._record_error_count = 0 self._dropped_reported = 0 - self._active_calibration_id = "" #: Last coefficients file written by the calibration panel (not applied). self._last_calibration_export = "" - #: Confirmed calibration changes, handed over from the acquisition thread. - self._pending_calibration_events: list[tuple[str, str, int]] = [] self._depth_range_value: tuple[float, float] | None = None self._last_depth_frame = None @@ -249,14 +250,28 @@ class StudioMainWindow(QMainWindow): worker.add_sink(self._on_packet_recorded) # ------------------------------------------------------- experiment record - def _on_record_alarm(self, message: str) -> None: - """Called from the writer thread. Queue it; never touch Qt here.""" - logger.error("Recording alarm: %s", message) - self._record_alarms.append(str(message)) + @property + def _record_fps(self) -> float: + return self.recording.record_fps + + @property + def _active_calibration_id(self) -> str: + return self.recording.active_calibration + + @_active_calibration_id.setter + def _active_calibration_id(self, value: str) -> None: + self.recording.set_active_calibration(value) + + @property + def _record_last_report(self): + return self.recording.last_report + + @property + def _record_report_pending(self) -> bool: + return self.recording.report_pending def _drain_record_alarms(self) -> None: - while self._record_alarms: - message = self._record_alarms.pop(0) + for message in self.recording.drain_alarms(): self._set_phase("error") self.statusBar().showMessage(t("record.alarm", detail=message)) @@ -274,16 +289,13 @@ class StudioMainWindow(QMainWindow): self.statusBar().showMessage(t("record.requires_run")) return mode = self.toolbar.record_mode() - config = RecordConfig( - video=(mode == "video_data"), - lossless_frames=bool(self.prefs.get("record_lossless_frames", False)), - fps=float(self._record_fps), - segment_seconds=float(self.prefs.get("record_segment_seconds", 300.0)), + config = record_config_from_prefs( + self.prefs, mode=mode, fps=float(self._record_fps) ) try: - directory = self.recorder.start( + directory = self.recording.start( root=recordings_dir(), - session_id=int(self.session_store.session_id), + config=config, metadata=self._build_session_metadata(config), calibration_id=self._active_calibration_id, calibration_version=self._active_calibration_id, @@ -297,21 +309,41 @@ class StudioMainWindow(QMainWindow): self.statusBar().showMessage(t("record.start_failed", error=str(exc))) logger.exception("Could not start recording") return - self._record_last_report = None self.recorder.log_event("record_started", f"mode={mode}") self.toolbar.set_record_status(Path(directory).name) self.statusBar().showMessage(t("record.started", mode=t(f"record.mode_{mode}"))) logger.info("Recording started: %s (mode=%s)", directory, mode) def _stop_recording(self, *, wait: bool = True) -> None: - if not self.recorder.is_recording: + """Stop the recording and publish what actually happened. + + A recording that already failed on its own is **not** a no-op: its report + still has to be consumed, or the button keeps claiming a recording is + running and the failure is never shown. + + ``wait=False`` returns as soon as the stop is requested; the writer owns + the completion and the UI timer publishes it (see ``_poll_record_report``). + """ + if self.recorder.directory is None and self.recorder.report is None: + # Nothing was ever started: keep the widget state consistent anyway. self.toolbar.set_record_status("") + self.toolbar.set_record_checked(False) return - self.recorder.log_event("record_stopped", "") - report = self.recorder.stop(wait=wait) - self._record_last_report = report + self._publish_record_report(self.recording.stop(wait=wait)) + + def _publish_record_report(self, report) -> None: + """Update the button and the summary from a recording report.""" self.toolbar.set_record_status("") self.toolbar.set_record_checked(False) + if report is None: + return + if not report.completed: + # The writer is still closing its files. Never present that as a + # finished experiment; the timer publishes the real report. + self.statusBar().showMessage(t("record.closing")) + return + if report.stats.failed: + self._set_phase("error") if report.problems: self.statusBar().showMessage( t("record.finished_with_problems", detail="; ".join(report.problems)) @@ -326,6 +358,12 @@ class StudioMainWindow(QMainWindow): ) ) + def _poll_record_report(self) -> None: + """Publish the terminal report once the writer has closed the files.""" + report = self.recording.poll() + if report is not None: + self._publish_record_report(report) + def _take_preroll(self) -> list: """Frame history from the active worker, if it keeps one. @@ -333,15 +371,17 @@ class StudioMainWindow(QMainWindow): recording follows, so a stale history can never be attached to a later recording. """ - worker = self._active_worker - take = getattr(worker, "take_preroll", None) - if not callable(take): - return [] - try: - return list(take()) - except Exception: # noqa: BLE001 - the history is optional, not the record - logger.exception("Could not take the pre-roll buffer") - return [] + return self.recording.take_preroll(self._active_worker) + + @staticmethod + def _device_serial(info) -> str: + """The sensor's own serial number, when the SDK exposes one.""" + for attribute in ("sn", "serial", "serial_number"): + value = getattr(info, attribute, None) + if value: + return str(value) + serial = getattr(info, "info", None) + return str(getattr(serial, "SN", "") or "") def _build_session_metadata(self, config: RecordConfig) -> dict: from orisys_app_shared import API_VERSION, __version__ as shared_version @@ -379,6 +419,10 @@ class StudioMainWindow(QMainWindow): # filled in by the acquisition thread's confirmation, so it can # be empty here; the CSV's per-row version is authoritative. "version": self._active_calibration_id, + # The operator-entered serial of the *calibrated* device, as the + # calibration panel has it (the device SN when the config has one). + "sn": self.cal_panel.sn_edit.text().strip(), + "device_sn": self._device_serial(info), }, # Depth is deliberately NOT stored (decided with the team): it is a # function of the frame and the sensor configuration, so replay @@ -395,7 +439,10 @@ class StudioMainWindow(QMainWindow): "conventions": { "axis_order": ["FN", "FX", "FY"], "force_unit": "N when calibrated, otherwise raw counts", - "time_base": "t_rel_s is relative to the first recorded frame", + "time_base": ( + "t_rel_s is relative to the first recorded frame; an event " + "that predates it has no relative base and is left empty" + ), "frame_id": "monotonic per acquisition session", }, "filter": { @@ -432,25 +479,10 @@ class StudioMainWindow(QMainWindow): def _on_packet_recorded(self, packet) -> None: """Runs on the **acquisition thread**. Must stay cheap and Qt-free. - The plots are allowed to drop frames; this path is not. It is also the - reason calibration sampling no longer depends on the calibration panel - being visible. - - The packet is converted exactly once, by the same function the CSV writer - uses, so the store and the file can never disagree about whether a value - is a measurement. + The record channel's mechanics are the coordinator's; all this keeps is + the UI's own bookkeeping (how many readouts failed). """ - record = to_record_sample( - packet, calibration_version=self._active_calibration_id - ) - sample = SessionSample.from_record(record) - self.session_store.append(sample) - self.cal_recorder.feed(sample) - # Video + CSV + metadata share this one record path. - self.recorder.on_packet(packet) - fps = float(getattr(packet, "fps", 0.0) or 0.0) - if fps > 0.0: - self._record_fps = fps + record = self.recording.on_packet(packet) if not record.valid: self._record_error_count += 1 @@ -919,11 +951,64 @@ class StudioMainWindow(QMainWindow): self.recorder.log_event("calibration_exported", str(path)) self.statusBar().showMessage(t("cal.export_only", path=Path(path).name)) + def calibration_identity(self, path: str) -> dict: + """What device a coefficient file says it belongs to. + + Read with the SDK's own parser so the check sees the same document the + device would load. A file that cannot be read yields no identity, and the + caller then applies it without a mismatch claim. + """ + try: + import orisys.calibration as sdk_calibration + + document = sdk_calibration.load(path, strict=True) + except Exception: # noqa: BLE001 - reported by the apply path itself + logger.exception("Could not read calibration identity from %s", path) + return {} + identity = dict(getattr(document, "identity", {}) or {}) + pipeline = dict(getattr(document, "pipeline", {}) or {}) + return { + "sensor_type": str(identity.get("sensor_type") or ""), + "sn": str(identity.get("sn") or ""), + "config_ref": str(pipeline.get("config_ref") or ""), + } + + @staticmethod + def _same_path(first: str, second: str) -> bool: + if not first or not second: + return True # nothing to compare: not a mismatch + if first == second: + return True + try: + return Path(first).resolve() == Path(second).resolve() + except Exception: # noqa: BLE001 - a non-path value simply differs + return False + + def calibration_target_mismatch( + self, path: str, *, sensor_type: str, config_ref: str + ) -> list[str]: + """Reasons this calibration does not obviously belong to this device. + + Applying coefficients that were measured on a different sensor silently + changes what every recorded newton value means, so the mismatch is stated + before the confirmation, not discovered later. + """ + recorded = self.calibration_identity(path) + reasons: list[str] = [] + recorded_type = recorded.get("sensor_type") or "" + if recorded_type and sensor_type and recorded_type != sensor_type: + reasons.append(f"sensor_type: {recorded_type} != {sensor_type}") + recorded_config = recorded.get("config_ref") or "" + if recorded_config and not self._same_path(recorded_config, config_ref): + reasons.append(f"config_ref: {recorded_config} != {config_ref}") + return reasons + def _on_calibration_apply(self, path: str) -> None: """Push freshly exported coefficients into the device — on confirmation. The user is shown the exact target device first, and only the matching - worker is updated. + worker is updated. A calibration that names a *different* device or + configuration is called out explicitly and defaults to "no". """ path = str(path or self._last_calibration_export) if not path: @@ -931,6 +1016,29 @@ class StudioMainWindow(QMainWindow): return sensor_type = self.sensor_picker.sensor_type() config_ref = self._resolve_config_ref() or "" + reasons = self.calibration_target_mismatch( + path, sensor_type=sensor_type, config_ref=config_ref + ) + if reasons: + answer = QMessageBox.question( + self, + t("cal.apply_mismatch_title"), + t( + "cal.apply_mismatch", + device=self.calibration_identity(path).get("sensor_type") or "?", + detail="; ".join(reasons), + sensor_type=sensor_type, + config=config_ref, + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self.recorder.log_event( + "calibration_apply_blocked", "; ".join(reasons) + ) + self.statusBar().showMessage(t("cal.apply_mismatch_skipped")) + return answer = QMessageBox.question( self, t("cal.apply_title"), @@ -952,30 +1060,32 @@ class StudioMainWindow(QMainWindow): t("cal.apply_pending", sensor_type=sensor_type) ) - def _on_calibration_applied(self, path: str, version: str, frame_id: int) -> None: + def _on_calibration_applied( + self, path: str, version: str, frame_id: int, mono_t: float, session: int + ) -> None: """Called from the acquisition thread once new coefficients are loaded.""" # The acquisition thread must not touch Qt, so only plain state moves here. - self._pending_calibration_events.append( - (str(path), str(version), int(frame_id)) - ) + self.recording.note_calibration_applied(path, version, frame_id, mono_t, session) + + def _on_reset_applied(self, frame_id: int, mono_t: float, session: int) -> None: + """Called from the acquisition thread once a zeroing took effect.""" + self.recording.note_reset(frame_id, mono_t, session) def _drain_calibration_events(self) -> None: """Publish confirmed calibration changes on the UI thread.""" - while self._pending_calibration_events: - path, version, frame_id = self._pending_calibration_events.pop(0) - # The version is the calibration's content hash: a path can be - # re-exported with different coefficients, a hash cannot. - self._active_calibration_id = version or path - self.session_store.set_calibration_id(self._active_calibration_id) - self.recorder.set_calibration_version(self._active_calibration_id) - self.recorder.log_event( - "calibration_applied", - f"{path} ({self._active_calibration_id[:12] or 'unhashed'})", - frame_id=frame_id, - ) - self.statusBar().showMessage( - t("cal.applied", version=self._active_calibration_id[:12]) - ) + for event in self.recording.drain_calibration_events(): + version = event["version"] + if version: + self.statusBar().showMessage(t("cal.applied", version=version[:12])) + else: + # An empty version is the honest answer for a device with no + # calibration; it must not read as "the last one is still on". + self.statusBar().showMessage(t("cal.applied_none")) + + def _drain_reset_events(self) -> None: + """Publish confirmed zeroings on the UI thread.""" + for _event in self.recording.drain_reset_events(): + self.statusBar().showMessage(t("status.reset_ok")) def _worker_for(self, sensor_type: str): self._ensure_workers() @@ -1228,7 +1338,6 @@ class StudioMainWindow(QMainWindow): # starts empty and is filled from that confirmation — a path is not a # version, and the same path can hold different numbers. self._active_calibration_id = "" - self._pending_calibration_events.clear() self._record_error_count = 0 self._dropped_reported = 0 self._depth_range_value = None @@ -1318,18 +1427,12 @@ class StudioMainWindow(QMainWindow): if self._session_phase != "running" or self._active_worker is None: return try: + # A request, not the zeroing itself: the acquisition thread applies it + # between two frames and reports the frame and time it took effect. self._active_worker.reset() self.force_plots.reset() self._last_plot_packet_key = None - # Recorded with the frame it happened at: a reset clears the SDK's - # tracker, so an offline depth replay can start here instead of - # guessing at the algorithm's earlier state. - self.recorder.log_event( - "zeroing", - "tracker reset", - frame_id=self._current_frame_id(), - ) - self.statusBar().showMessage(t("status.reset_ok")) + self.statusBar().showMessage(t("status.reset_pending")) except Exception as exc: self.statusBar().showMessage(t("status.run_failed", error=str(exc))) @@ -1351,6 +1454,11 @@ class StudioMainWindow(QMainWindow): def _on_run_tick(self) -> None: if self._active_worker is None: return + # Record bookkeeping happens before anything that can return early: a + # recording that failed while the device was quiet still has to update + # the button and the summary. + self._drain_record_alarms() + self._poll_record_report() if self._last_session_error: err = self._last_session_error self._last_session_error = "" @@ -1405,31 +1513,31 @@ class StudioMainWindow(QMainWindow): if is_new: self._last_plot_packet_key = packet_key - # Invalid readouts must not be drawn as if they were data, and they - # must not be written into the plot buffer: NaN would poison the - # auto-range and a zero would be indistinguishable from a real one. - if packet.valid: + # The live view consumes the *same* standardised measurement as the + # CSV and the session store: one conversion decides what a value + # means, so the chart cannot show something the record does not + # contain (or vice versa). + record = to_record_sample(packet) + if record.valid: display = select_display_forces( units_pref=self.toolbar.units(), - force_calibrated=packet.force_calibrated, - fn=packet.normal_force, - fx=packet.shear_x, - fy=packet.shear_y, - fn_raw=packet.normal_force_raw, - fx_raw=packet.shear_x_raw, - fy_raw=packet.shear_y_raw, + sample=record, ) if display.using_raw_fallback: self.statusBar().showMessage(t("status.units_raw_fallback")) self.force_plots.push( - packet.timestamp, - display.fn, - display.fx, - display.fy, + # Session-relative time, the same base the store and + # ``forces.csv`` use. The wall clock must never reach the + # buffer: it would put the live axis on a different scale. + self._session_time(packet), + display.values[0], + display.values[1], + display.values[2], unit=display.unit, + available=display.available, ) else: - self._report_invalid_sample(packet.invalid_reason) + self._report_invalid_sample(record.invalid_reason) # The calibration panel is a *view*: sampling is driven by the # record channel, so hiding the panel can no longer stall it. self.cal_panel.set_live( @@ -1441,8 +1549,8 @@ class StudioMainWindow(QMainWindow): # Finalise calibration sampling on the UI thread once its deadline passes. self.cal_recorder.tick() self.cal_panel.poll_recorder() - self._drain_record_alarms() self._drain_calibration_events() + self._drain_reset_events() self._report_dropped_samples() mode = self.arrow_panel.view_mode() @@ -1451,6 +1559,23 @@ class StudioMainWindow(QMainWindow): if self._show_fps and is_new: self.arrow_panel.set_fps(packet.fps) + def _session_time(self, packet) -> float: + """Session-relative acquisition time for the live view. + + The session store already defines the origin (its first appended sample), + and ``forces.csv`` uses the same base. Deriving the live time from that + one origin is what keeps the live buffer, a rebuilt history and the replay + on a single X axis. + """ + origin = self.session_store.origin_time() + absolute = getattr(packet, "mono_t", None) + if absolute is None: + absolute = getattr(packet, "timestamp", 0.0) + try: + return float(absolute) - float(origin) + except (TypeError, ValueError): + return 0.0 + def _report_invalid_sample(self, reason: str) -> None: """Surface failed readouts instead of letting them pass as data. diff --git a/apps/studio/src/orisys_studio/calibration_service.py b/apps/studio/src/orisys_studio/calibration_service.py new file mode 100644 index 0000000..690f2ad --- /dev/null +++ b/apps/studio/src/orisys_studio/calibration_service.py @@ -0,0 +1,130 @@ +"""Calibration service: the panel's operations, without the panel. + +The panel shows a table and emits status messages; everything that touches the +file system or a device identity happens here: + +* preparing the session once (store, diagnostics and export share one model); +* saving the audit record, reopening one, and exporting the coefficients; +* keeping a reopened record's **own** device identity instead of restamping it + with whatever device happens to be connected. + +This is deliberately Qt-free so it can be tested without a display. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Optional + +from orisys_app_shared.force.calibration_io import ( + CHANNEL_KEYS, + CalSessionStore, + export_force_calibration_v2, + meta_from_sensor_json, +) +from orisys_app_shared.force.calibration_session import ( + CalibrationSessionRecord, + read_calibration_session, + session_filename, + write_calibration_session, +) +from orisys_app_shared.paths import calibration_export_dir + +logger = logging.getLogger(__name__) + + +class CalibrationService: + """Owns the calibration session's files and its prepared model.""" + + def __init__(self, *, store: Optional[CalSessionStore] = None) -> None: + self.store = store or CalSessionStore() + #: The one prepared model the panel displays and exports. + self.prepared: Any = None + #: Device identity of a reopened record, while it is open. + self.record_identity: Optional[dict] = None + #: The record currently open, if any. + self.open_record: Optional[CalibrationSessionRecord] = None + + # --------------------------------------------------------------- identity + def export_meta( + self, + *, + sensor_type: str, + config_ref: str | None, + sn: str = "", + duration_s: float = 0.0, + ) -> dict: + """The identity/pipeline metadata an export would carry. + + While a saved record is open its **own** device identity wins: reopening + device A's calibration on a machine connected to device B must keep saying + A, or the evidence is silently restamped with B's serial number. + """ + meta = meta_from_sensor_json(config_ref) + meta["sensor_type"] = str(sensor_type or "box") + meta["sn"] = str(sn or "") + meta["record_duration_s"] = float(duration_s or 0.0) + identity = self.record_identity + if identity: + meta["sensor_type"] = identity.get("sensor_type") or meta["sensor_type"] + meta["sn"] = identity.get("sn") or meta["sn"] + meta["config_ref"] = identity.get("config_ref") or meta.get("config_ref") + meta["source_record"] = str(identity.get("path") or "") + meta["source_created_at"] = str(identity.get("created_at") or "") + return meta + + def clear_record_identity(self) -> None: + """A new run is the live device's context again.""" + self.record_identity = None + self.open_record = None + + # ------------------------------------------------------------ preparation + def prepare(self, recorder, meta: dict) -> Any: + """Prepare the session once: store, diagnostics and export share it.""" + self.prepared = None + self.store.clear() + if recorder is None: + return None + prepared = recorder.prepared(meta=meta) + self.prepared = prepared + for key in CHANNEL_KEYS: + self.store.set_channel(key, prepared.channel_samples(key)) + # The residuals belong to the points, because the table shows them. + for point in recorder.all_points(): + value = prepared.residuals.get(id(point)) + point.residual_n = float("nan") if value is None else float(value) + return prepared + + # ------------------------------------------------------------------- files + def save_record(self, recorder, meta: dict) -> Path: + """Write the audit record. Returns the written path.""" + points = recorder.all_points() + path = calibration_export_dir() / session_filename(meta.get("sensor_type")) + return write_calibration_session(path, points, meta=meta) + + def load_record(self, path: str | Path) -> CalibrationSessionRecord: + """Reopen a saved session and remember whose record it is.""" + record = read_calibration_session(path) + identity = record.identity() + self.record_identity = {**identity, "path": str(record.path)} + self.open_record = record + return record + + def export_coefficients( + self, + *, + sensor_type: str, + export_dir: str | Path | None = None, + local_bundle_dir: str | Path | None = None, + ) -> tuple[Path, Path | None, list[str]]: + """Serialise the prepared model. Raises ValueError when there is none.""" + if self.prepared is None or not getattr(self.prepared, "ok", False): + warnings = list(getattr(self.prepared, "warnings", []) or []) + raise ValueError("; ".join(warnings) or "no complete channels") + return export_force_calibration_v2( + self.prepared, + export_dir=export_dir or calibration_export_dir(), + sensor_type=str(sensor_type or "box"), + local_bundle_dir=local_bundle_dir, + ) diff --git a/apps/studio/src/orisys_studio/recording.py b/apps/studio/src/orisys_studio/recording.py new file mode 100644 index 0000000..17c8254 --- /dev/null +++ b/apps/studio/src/orisys_studio/recording.py @@ -0,0 +1,266 @@ +"""The record channel: one experiment directory, one session store, one sampler. + +This is the coordination the main window used to do inline — and it is deliberately +free of Qt. Three consumers are fed from the same acquisition packet: + +* the :class:`ExperimentRecorder`, which writes the directory; +* the whole-session store the charts and the browser read; +* the calibration sampler (``CalibrationRecorder``), whose lifecycle must not + depend on the calibration panel being visible. + +The window keeps the buttons, the menus and the status messages; the decisions +about *what* is recorded, *how* it is named and *what actually happened* live +here, where they can be tested without a display. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Callable, Optional + +from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport +from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot +from orisys_app_shared.record.sample import to_record_sample + +logger = logging.getLogger(__name__) + + +class RecordingCoordinator: + """Owns the recorder and the per-packet fan-out.""" + + def __init__( + self, + *, + session_store, + cal_recorder, + recorder: Optional[ExperimentRecorder] = None, + ) -> None: + self.session_store = session_store + self.cal_recorder = cal_recorder + self.recorder = recorder or ExperimentRecorder() + #: Confirmed calibration changes: ``(path, version, frame_id, mono_t, session)``. + self._calibration_events: list[tuple[str, str, int, float, int]] = [] + #: Confirmed zeroings: ``(frame_id, mono_t, session)``. + self._reset_events: list[tuple[int, float, int]] = [] + #: Raised by the writer thread; drained on the UI thread. + self._alarms: list[str] = [] + #: A stop was requested and the writer has not closed its files yet. + self.report_pending = False + self.last_report: Optional[RecordReport] = None + self._active_calibration_id = "" + self._record_fps = 30.0 + self.recorder.set_alarm_handler(self._on_alarm) + + # ------------------------------------------------------------- acquisition + def on_packet(self, packet): + """Runs on the **acquisition thread**. Must stay cheap and Qt-free. + + The packet is converted exactly once, by the same function the CSV writer + uses, so the store and the file can never disagree about whether a value + is a measurement. Returns the converted record, which the caller may use + for its own (UI-side) bookkeeping. + """ + record = to_record_sample( + packet, calibration_version=self._active_calibration_id + ) + from orisys_app_shared.force.session import SessionSample + + sample = SessionSample.from_record(record) + self.session_store.append(sample) + self.cal_recorder.feed(sample) + # Video + CSV + metadata share this one record path. + self.recorder.on_packet(packet) + fps = float(getattr(packet, "fps", 0.0) or 0.0) + if fps > 0.0: + self._record_fps = fps + return record + + def take_preroll(self, worker) -> list: + """Frame history from the active worker, if it keeps one.""" + take = getattr(worker, "take_preroll", None) + if not callable(take): + return [] + try: + return list(take()) + except Exception: # noqa: BLE001 - the history is optional, not the record + logger.exception("Could not take the pre-roll buffer") + return [] + + def set_active_calibration(self, version: str) -> None: + """The calibration the record should name until the device says otherwise.""" + self._active_calibration_id = str(version or "") + + @property + def active_calibration(self) -> str: + return self._active_calibration_id + + @property + def record_fps(self) -> float: + return self._record_fps + + # ------------------------------------------------------------------ events + def note_calibration_applied( + self, path: str, version: str, frame_id: int, mono_t: float, session: int + ) -> None: + """Called from the acquisition thread once coefficients are loaded.""" + self._calibration_events.append( + (str(path), str(version), int(frame_id), float(mono_t), int(session)) + ) + + def note_reset(self, frame_id: int, mono_t: float, session: int) -> None: + """Called from the acquisition thread once a zeroing took effect.""" + self._reset_events.append((int(frame_id), float(mono_t), int(session))) + + def drain_calibration_events(self) -> list[dict]: + """Publish confirmed calibrations. Returns what the UI should show.""" + published: list[dict] = [] + while self._calibration_events: + path, version, frame_id, mono_t, session = self._calibration_events.pop(0) + # The version is the calibration's content hash: a path can be + # re-exported with different coefficients, a hash cannot. + self._active_calibration_id = version + self.session_store.set_calibration_id(version) + self.recorder.set_calibration_version(version) + snapshot = self.snapshot_calibration(path, version, mono_t, frame_id) + self.recorder.log_event( + "calibration_applied", + f"{path} ({version[:12] or 'unhashed'})" + + (f" -> {snapshot}" if snapshot else ""), + mono_t=mono_t, + frame_id=frame_id, + ) + published.append( + { + "path": path, + "version": version, + "frame_id": frame_id, + "mono_t": mono_t, + "session": session, + "snapshot": snapshot, + } + ) + return published + + def drain_reset_events(self) -> list[dict]: + """Publish confirmed zeroings.""" + published: list[dict] = [] + while self._reset_events: + frame_id, mono_t, session = self._reset_events.pop(0) + self.recorder.log_event( + "zeroing", + "tracker reset (applied by the acquisition thread)", + mono_t=mono_t, + frame_id=frame_id, + ) + published.append( + {"frame_id": frame_id, "mono_t": mono_t, "session": session} + ) + return published + + def snapshot_calibration( + self, path: str, version: str, mono_t: float, frame_id: int + ) -> str: + """Copy the applied coefficients into the recording. Returns its name.""" + directory = self.recorder.directory + if directory is None or not path or not Path(path).is_file(): + return "" + try: + name = store_calibration_snapshot(directory, Path(path), version) + except Exception: # noqa: BLE001 - the record continues without it + logger.exception("Could not snapshot the applied calibration") + return "" + self.recorder.note_calibration_snapshot( + version=version, name=name, frame_id=frame_id, mono_t=mono_t + ) + return name + + # ------------------------------------------------------------------ alarms + def _on_alarm(self, message: str) -> None: + """Called from the writer thread. Queue it; never touch Qt here.""" + logger.error("Recording alarm: %s", message) + self._alarms.append(str(message)) + + def drain_alarms(self) -> list[str]: + drained, self._alarms = self._alarms, [] + return drained + + # --------------------------------------------------------------- lifecycle + def start( + self, + *, + root: Path, + config: RecordConfig, + metadata: dict[str, Any], + calibration_id: str = "", + calibration_version: str = "", + preroll=(), + ) -> Path: + """Start a recording. Raises when the directory cannot be created.""" + directory = self.recorder.start( + root=root, + session_id=int(self.session_store.session_id), + metadata=metadata, + calibration_id=calibration_id, + calibration_version=calibration_version, + preroll=preroll, + ) + self.last_report = None + self.report_pending = False + return Path(directory) + + def stop(self, *, wait: bool = True) -> Optional[RecordReport]: + """Stop and return the report, or ``None`` when nothing was started.""" + if self.recorder.directory is None and self.recorder.report is None: + self.report_pending = False + return None + if self.recorder.is_recording: + self.recorder.log_event("record_stopped", "") + report = self.recorder.stop(wait=wait) + self.publish(report) + return report + + def publish(self, report: Optional[RecordReport]) -> None: + """Record a report; a completed one becomes ``last_report``.""" + if report is None: + return + if not report.completed: + # The writer is still closing; the UI timer publishes the real one. + self.report_pending = True + return + self.report_pending = False + self.last_report = report + + def poll(self) -> Optional[RecordReport]: + """The terminal report, once the writer has actually closed the files.""" + if not self.report_pending: + return None + report = self.recorder.report + if report is None: + return None + self.publish(report) + return report + + @property + def is_recording(self) -> bool: + return self.recorder.is_recording + + @property + def directory(self): + return self.recorder.directory + + +def record_config_from_prefs( + prefs: dict, + *, + mode: str, + fps: float, + on_problem: Optional[Callable[[str], None]] = None, +) -> RecordConfig: + """Build the record config the toolbar/prefs describe.""" + return RecordConfig( + video=(mode == "video_data"), + lossless_frames=bool(prefs.get("record_lossless_frames", False)), + fps=float(fps), + segment_seconds=float(prefs.get("record_segment_seconds", 300.0)), + ) diff --git a/apps/studio/src/orisys_studio/ui/cal_panel.py b/apps/studio/src/orisys_studio/ui/cal_panel.py index 7c5626b..53b3164 100644 --- a/apps/studio/src/orisys_studio/ui/cal_panel.py +++ b/apps/studio/src/orisys_studio/ui/cal_panel.py @@ -30,18 +30,16 @@ from orisys_app_shared.force.calibration_io import ( CalSample, CalSessionStore, ChannelKey, - export_force_calibration_v2, meta_from_sensor_json, ) from orisys_app_shared.force.calibration_session import ( - read_calibration_session, session_filename, - write_calibration_session, ) from orisys_app_shared.i18n import t from orisys_app_shared.paths import calibration_export_dir from orisys_app_shared.theme import color +from orisys_studio.calibration_service import CalibrationService from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog CHANNEL_LABELS = ("fn", "fx", "fy") @@ -71,7 +69,10 @@ class CalPanel(QFrame): super().__init__(parent) self.setObjectName("contentPanel") self._theme = theme - self._store = CalSessionStore() + #: The files, the identity and the prepared model live in the service; + #: this panel shows them and emits status messages. + self._service = CalibrationService() + self._store = self._service.store self._channel: ChannelKey = "fn" self._running = False #: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the @@ -244,6 +245,14 @@ class CalPanel(QFrame): self.retranslate() # ------------------------------------------------------------------ API + @property + def _prepared(self): + return self._service.prepared + + @property + def _record_identity(self): + return self._service.record_identity + def set_theme(self, theme: str) -> None: self._theme = theme self._render_live_label() @@ -266,6 +275,8 @@ class CalPanel(QFrame): def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None: self._sensor_type = str(sensor_type or "box") self._config_ref = config_ref + # A new run is the live device's context again. + self._service.clear_record_identity() def set_live(self, fn: float, fx: float, fy: float) -> None: """Update the live readout. Sampling is handled by the recorder.""" @@ -519,7 +530,8 @@ class CalPanel(QFrame): if self._recorder is None: self.fit_label.setText("") return - fit = self._recorder.fit(self._channel) + # Same prepared result the store and the export use — never a second one. + fit = self._recorder.fit(self._channel, prepared=self._prepared) if not fit.usable: self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points)) return @@ -549,12 +561,30 @@ class CalPanel(QFrame): state = t("cal.state_restore") if point.excluded else t("cal.state_exclude") self.btn_toggle_excluded.setText(state) + def point_dialog(self, point): + """The diagnostic view for a point, rebuilt from its samples if needed. + + A reopened record carries the full sample series, so the three-axis view + can always be produced — waiting for a cached trace would have made + exactly the reopened points (the ones worth auditing) unopenable. + """ + if point is None or not (point.trace or point.samples): + return None + if not point.trace: + from orisys_app_shared.force.calibration_session import ( + restore_diagnostics, + ) + + restore_diagnostics(point) + if not point.trace: + return None + return CalibrationPointDialog(point, theme=self._theme, parent=self) + def _on_row_activated(self) -> None: """Double click a row to inspect that point's three-axis curve.""" - point = self._current_point() - if point is None or not point.trace: - return - CalibrationPointDialog(point, theme=self._theme, parent=self).exec_() + dialog = self.point_dialog(self._current_point()) + if dialog is not None: + dialog.exec_() def _on_toggle_excluded(self) -> None: point = self._current_point() @@ -580,32 +610,24 @@ class CalPanel(QFrame): t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation") ) - def _rebuild_store(self) -> None: - """Rebuild the export store from the points that are still in the fit. + def _export_meta(self) -> dict: + """The identity/pipeline metadata the export would carry.""" + return self._service.export_meta( + sensor_type=self._sensor_type, + config_ref=self._config_ref, + sn=self.sn_edit.text().strip(), + duration_s=float(self.duration_spin.value()), + ) - Validation points are held out: they exist to check the model, so letting - them into the build would both corrupt it and make the "independent" - error meaningless. Repeated reference loads are merged rather than - overwritten, so every sample that was actually taken reaches the model. + def _rebuild_store(self) -> None: + """Prepare the session once: store, diagnostics and export share it. + + The panel used to filter, aggregate and build its own model while + ``CalibrationRecorder.fit`` prepared a second one for the table, so the + displayed residual and the exported curve could describe different + models. Both now read :class:`PreparedCalibration`, built by the service. """ - self._store.clear() - if self._recorder is None: - return - for point in self._recorder.all_points(): - if point.excluded or point.is_validation: - continue - stats = point.loaded_stats() - if not np.isfinite(stats.mean): - continue - self._store.aggregate( - point.channel, - CalSample( - weight_n=float(point.reference_load_n), - raw_mean=float(stats.mean), - n=int(stats.n), - std=float(stats.std) if np.isfinite(stats.std) else 0.0, - ), - ) + self._service.prepare(self._recorder, self._export_meta()) def _on_delete_row(self) -> None: point = self._current_point() @@ -630,20 +652,24 @@ class CalPanel(QFrame): self._refresh_table() def _on_export(self) -> None: - meta = meta_from_sensor_json(self._config_ref) - meta["sensor_type"] = self._sensor_type - meta["sn"] = self.sn_edit.text().strip() - meta["record_duration_s"] = float(self.duration_spin.value()) + # Re-prepare first: the SN and the requested duration may have changed, + # and the file must be exactly the model the panel is showing. + self._rebuild_store() + self._refresh_fit_label() + if self._prepared is None or not self._prepared.ok: + QMessageBox.warning( + self, + t("panels.cal"), + t("cal.export_no_model", detail="; ".join(self._warnings())), + ) + return try: # Write into the user's calibration directory only. Overwriting the # device bundle under config/sensors// is now an explicit, # separate step so that exporting cannot silently change what the # next Sensor() will load. - primary, _local_path, warnings = export_force_calibration_v2( - self._store, - export_dir=calibration_export_dir(), + primary, _local_path, warnings = self._service.export_coefficients( sensor_type=self._sensor_type, - meta=meta, local_bundle_dir=None, ) except ValueError as exc: @@ -664,6 +690,9 @@ class CalPanel(QFrame): self.calibration_exported.emit(str(primary)) QMessageBox.information(self, t("panels.cal"), msg) + def _warnings(self) -> list[str]: + return list(getattr(self._prepared, "warnings", []) or []) + def _on_apply(self) -> None: if not self._last_export_path: self.status_message.emit(t("cal.apply_no_export")) @@ -678,12 +707,10 @@ class CalPanel(QFrame): if not points: self.status_message.emit(t("cal.save_record_empty")) return - meta = meta_from_sensor_json(self._config_ref) - meta["sensor_type"] = self._sensor_type - meta["sn"] = self.sn_edit.text().strip() - path = calibration_export_dir() / session_filename(self._sensor_type) + # ``_export_meta`` keeps a reopened record's own device identity. + meta = self._export_meta() try: - written = write_calibration_session(path, points, meta=meta) + written = self._service.save_record(self._recorder, meta) except Exception as exc: # noqa: BLE001 - reported to the operator QMessageBox.warning( self, t("panels.cal"), t("cal.save_record_failed", error=str(exc)) @@ -694,30 +721,36 @@ class CalPanel(QFrame): self.status_message.emit(msg) QMessageBox.information(self, t("panels.cal"), msg) - def _on_open_record(self) -> None: - """Reopen a saved session so it can be inspected again.""" + def _on_open_record(self, path: str | None = None) -> None: + """Reopen a saved session: its points **and** its device identity.""" if self._recorder is None: return - path, _filter = QFileDialog.getOpenFileName( - self, - t("cal.open_record"), - str(calibration_export_dir()), - t("cal.open_record_filter"), - ) + if path is None: + path, _filter = QFileDialog.getOpenFileName( + self, + t("cal.open_record"), + str(calibration_export_dir()), + t("cal.open_record_filter"), + ) if not path: return try: - record = read_calibration_session(path) + record = self._service.load_record(path) except Exception as exc: # noqa: BLE001 - reported to the operator QMessageBox.warning( self, t("panels.cal"), t("cal.open_record_failed", error=str(exc)) ) return + # Show the record's own SN: it is what a later save or export will carry, + # so the operator must not be looking at the connected device's number. + if record.identity().get("sn"): + self.sn_edit.setText(record.identity()["sn"]) self._recorder.load_points(record.points, replace=True) self._rebuild_store() self._refresh_table() self._refresh_enabled() msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points)) + msg += " " + t("cal.open_record_device", device=record.describe()) if record.warnings: msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings)) self.status_message.emit(msg) diff --git a/apps/studio/src/orisys_studio/ui/force_plots.py b/apps/studio/src/orisys_studio/ui/force_plots.py index e10df69..c80d84b 100644 --- a/apps/studio/src/orisys_studio/ui/force_plots.py +++ b/apps/studio/src/orisys_studio/ui/force_plots.py @@ -18,6 +18,7 @@ from PyQt5.QtWidgets import ( from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ from orisys_app_shared.force import MetricHistory from orisys_app_shared.force.history import DEFAULT_MAX_FPS +from orisys_app_shared.force.source import StoreCurveSource from orisys_app_shared.i18n import t from orisys_app_shared.theme import color @@ -100,8 +101,13 @@ class ForcePlotsPanel(QFrame): self._fixed_newton = True self._unit = "raw" self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0) + #: Per axis: does ``_last_values`` hold a real value in the active unit? + self._available: tuple[bool, bool, bool] = (True, True, True) # -- browsing state ------------------------------------------------- self._store = None + #: Where the curves read from: the in-memory session store here, a + #: recording on disk in the replay. One interface either way. + self._source = StoreCurveSource() self._mode = "follow" self._window: tuple[float, float] = (0.0, 0.0) self._cursor_t = 0.0 @@ -313,6 +319,7 @@ class ForcePlotsPanel(QFrame): self.history.reset() self._clear_curves() self._last_values = (0.0, 0.0, 0.0) + self._available = (True, True, True) self._unit = "raw" self._mode = "follow" self._window = (0.0, 0.0) @@ -328,19 +335,33 @@ class ForcePlotsPanel(QFrame): def push( self, - timestamp: float, + t_rel_s: float, fn: float, fx: float, fy: float, *, unit: str = "raw", + available: tuple[bool, bool, bool] | None = None, ) -> None: + """Append one live sample. + + ``t_rel_s`` is **session-relative** time — the same base the session store + and ``forces.csv`` use. Handing the wall clock in here used to put the live + buffer on a different time axis from a rebuilt history, so switching units + (or entering browse) made the X axis jump by the size of the epoch. + + ``available`` marks axes that have no value in ``unit`` (a device with + only its normal-force axis calibrated). Those keep NaN and show as a gap + and a dash, never as a raw count wearing a newton label. + """ if unit != self._unit: # Never mix units in one buffer. Callers switch units through # ``set_units``/``set_fixed_newton`` which rebuild from the session # store; a bare change here would silently reinterpret old samples. self._unit = unit self._last_values = (float(fn), float(fx), float(fy)) + if available is not None: + self._available = tuple(bool(flag) for flag in available) self._render_value_cards() if self._mode == "browse": # Browsing is a display pause, not a capture pause: the record @@ -351,7 +372,7 @@ class ForcePlotsPanel(QFrame): # Store the true value. Clamping here (the old ``max(0.0, fn)``) erased # negative FN from the buffer and hid zero drift; out-of-range values are # now handled by the axis range, which only affects what is drawn. - self.history.push(timestamp, float(fn), float(fx), float(fy)) + self.history.push(t_rel_s, float(fn), float(fx), float(fy)) state = self.history.state() if state is None: return @@ -372,7 +393,9 @@ class ForcePlotsPanel(QFrame): self.plots[i].setYRange(*state.y_ranges[i], padding=0.0) finally: self._syncing = False - self.set_cursor(float(timestamp) - (self._time_origin() or 0.0)) + # The buffer already carries session-relative time, so the cursor is the + # time itself: subtracting an origin here would double-count it. + self.set_cursor(float(t_rel_s)) # The overview is a full-store read, so refresh it on a timer rather # than on every frame. now = time.perf_counter() @@ -381,12 +404,6 @@ class ForcePlotsPanel(QFrame): self._last_overview_at = now self._sync_overview() - def _time_origin(self) -> float: - """The store's time origin, so the cursor matches the overview axis.""" - if self._store is None: - return 0.0 - return self._store.origin_time() - def set_units(self, unit: str, store=None) -> None: """Switch display units without mixing units in the curve buffer. @@ -398,11 +415,13 @@ class ForcePlotsPanel(QFrame): if unit == self._unit and store is None: return self._unit = unit + # ``clear`` keeps the buffer's time origin on purpose: the rebuilt history + # is in the same session-relative base, so the X axis must not restart. self.history.clear() self._clear_curves() self._overview_dirty = True if store is not None: - self._store = store + self.set_store(store) if self._mode == "browse": self.refresh_from_store() else: @@ -414,6 +433,7 @@ class ForcePlotsPanel(QFrame): def set_store(self, store) -> None: """Attach the whole-session store the browser reads from.""" self._store = store + self._source.set_store(store) self._overview_dirty = True self._refresh_mode_label() @@ -437,19 +457,19 @@ class ForcePlotsPanel(QFrame): def show_all(self) -> None: """Overview: fit the entire session, in browse mode.""" - if self._store is None: + if self._source is None: return - first, last = self._store.extent() + first, last = self._source.extent() if last <= first: return self._set_mode("browse") self.set_window(first, last) def set_window(self, t0: float, t1: float) -> None: - """Show ``[t0, t1]`` from the whole-session store.""" - if self._store is None: + """Show ``[t0, t1]`` from the whole-session source.""" + if self._source is None: return - first, last = self._store.extent() + first, last = self._source.extent() if t1 <= t0: t1 = t0 + max(0.001, self.history.window_sec) t0 = max(first, float(t0)) @@ -463,21 +483,23 @@ class ForcePlotsPanel(QFrame): if self._store is None: return t0, t1 = self._window - slice_ = self._store.visible( + # One shared rule for every view, decided **per axis**: valid and finite + # for *that* axis. An all-axes rule hid a valid FN whenever the device had + # no shear calibration, and a finiteness-only rule let a stale value + # reappear after another view excluded it. + window = self._source.window( t0, t1, max_points=self._max_draw_points, unit=self._unit ) self._syncing = True try: - if len(slice_) == 0: + if len(window) == 0: for curve in self.curves: curve.setData([], []) else: - # One shared rule for every view: valid *and* finite. A stale - # number left by a failed compute step must not reappear here - # after another view already excluded it. - times, values, _mask = slice_.plottable(self._unit) for index, curve in enumerate(self.curves): - curve.setData(times, values[:, index]) + times, values = window.plottable_axis(index) + curve.setData(times, values) + self._availability_from_window(window) for plot in self.plots: plot.setXRange(t0, t1, padding=0.0) self._apply_axis_mode() @@ -523,7 +545,7 @@ class ForcePlotsPanel(QFrame): x0, x1 = float(x_range[0]), float(x_range[1]) if not (x1 > x0): return - first, last = self._store.extent() + first, last = self._source.extent() if last <= first: return # Keep the window inside the recorded session. @@ -554,7 +576,7 @@ class ForcePlotsPanel(QFrame): self.stats_label.setText("") return t0, t1 = self._window - stats = self._store.statistics(t0, t1, unit=self._unit) + stats = self._source.statistics(t0, t1, unit=self._unit) if stats.samples == 0: self.stats_label.setText(t("chart.selection_empty")) return @@ -581,19 +603,19 @@ class ForcePlotsPanel(QFrame): def _sync_overview(self) -> None: if self._store is None: return - first, last = self._store.extent() + first, last = self._source.extent() if last <= first: self.overview_curve.setData([], []) return - # The overview is a full-store read, so only rebuild it when it can have - # changed (window/unit/session) rather than on every pan. - slice_ = self._store.visible( - first, last, max_points=self._overview_points(), unit=self._unit + # The overview is a whole-session read, so only rebuild it when it can + # have changed (window/unit/session) rather than on every pan. + window = self._source.envelope( + buckets=self._overview_points(), unit=self._unit ) - values = slice_.value_for_unit(self._unit) + values = np.asarray(window.values, dtype=float) column = values[:, 0] finite = np.isfinite(column) - self.overview_curve.setData(slice_.times[finite], column[finite]) + self.overview_curve.setData(window.times[finite], column[finite]) self.overview.setXRange(first, last, padding=0.0) if self._mode == "browse": self.overview_region.setRegion(self._window) @@ -616,20 +638,25 @@ class ForcePlotsPanel(QFrame): self.btn_overview.setEnabled(self._store is not None) def _reload_from_store(self, store) -> None: - """Re-populate the visible buffer from the whole-session store.""" + """Re-populate the visible buffer from the session source.""" window = self.history.window_sec - slice_ = store.tail(window) if window > 0 else store.data() - if len(slice_) == 0: + first, last = self._source.extent() + span = window if window > 0 else max(0.0, last - first) + data = self._source.window( + max(first, last - span), last, unit=self._unit + ) + if len(data) == 0: return - values = slice_.value_for_unit(self._unit) - for index in range(len(slice_)): - if not bool(slice_.valid[index]): + self._availability_from_window(data) + values = np.asarray(data.values, dtype=float) + for index in range(len(data)): + if not bool(data.valid[index]): continue row = values[index] if not all(np.isfinite(v) for v in row): continue self.history.push( - float(slice_.times[index]), + float(data.times[index]), float(row[0]), float(row[1]), float(row[2]), @@ -641,6 +668,19 @@ class ForcePlotsPanel(QFrame): curve.setData(state.times, state.values[:, i]) self.plots[i].setXRange(*state.x_range, padding=0.0) + def _availability_from_window(self, window) -> None: + """Mark which axes have a value in the active unit, from a read window. + + Used after a unit switch: the value cards must not keep showing the + previous unit's numbers, and an axis with no curve in this unit has to be + a dash until a fresh sample arrives. + """ + if window is None or len(window) == 0: + return + values = np.asarray(window.values, dtype=float) + finite = np.isfinite(values) & np.asarray(window.valid, dtype=bool)[:, None] + self._available = tuple(bool(finite[:, index].any()) for index in range(3)) + def _uses_fixed_y(self) -> bool: """Fixed Y whenever we have limits for the active unit. @@ -690,10 +730,21 @@ class ForcePlotsPanel(QFrame): def _render_value_cards(self) -> None: fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}" - for value_label, unit_label, value in zip( - self.value_labels, self.value_units, self._last_values + unavailable = t("chart.value_unavailable") + for index, (value_label, unit_label, value) in enumerate( + zip(self.value_labels, self.value_units, self._last_values) ): + if not self._available[index]: + # An axis with no curve in this unit: a dash, never a raw count + # presented as a newton value. + value_label.setText(unavailable) + unit_label.setText("") + value_label.setToolTip( + t("chart.value_unavailable_tip", channel=CHANNEL_LABELS[index]) + ) + continue value_label.setText(fmt.format(value=value)) + value_label.setToolTip("") unit_label.setText(unit_display(self._unit)) self.unit_badge.setText(unit_display(self._unit)) diff --git a/apps/studio/src/orisys_studio/ui/playback_dialog.py b/apps/studio/src/orisys_studio/ui/playback_dialog.py index a80433a..3756715 100644 --- a/apps/studio/src/orisys_studio/ui/playback_dialog.py +++ b/apps/studio/src/orisys_studio/ui/playback_dialog.py @@ -10,6 +10,7 @@ video frame, the depth pane and the charts' cursor line together. from __future__ import annotations +import logging import threading from pathlib import Path @@ -45,6 +46,10 @@ except ImportError as exc: # pragma: no cover CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy") SLIDER_STEPS = 2000 +#: Upper bound on the points handed to the replay curves. +CHART_MAX_POINTS = 4000 + +logger = logging.getLogger(__name__) class PlaybackDialog(QDialog): @@ -75,6 +80,13 @@ class PlaybackDialog(QDialog): self._depth_pending_seq: int | None = None self._depth_pending_position: float | None = None self._depth_result_seq: int | None = None + #: Which recording each answer belongs to (incremented on every open). + self._record_serial = 0 + self._depth_pending_record: int | None = None + self._depth_result_record: int | None = None + self._depth_result_frame = -1 + #: Processors whose owning request was still running when it was retired. + self._retired_recomputers: list = [] self._depth_poll = QTimer(self) self._depth_poll.setInterval(120) self._depth_poll.timeout.connect(self._poll_depth) @@ -166,9 +178,16 @@ class PlaybackDialog(QDialog): self.warning_label.setText(t("playback.open_failed", error=str(exc))) def open_recording(self, directory: Path) -> None: - if self._recomputer is not None: - self._recomputer.close() - self._recomputer = None + """Open a recording. Any depth answer still in flight is invalidated.""" + # A running request belongs to the *previous* recording: its result must + # never be pasted into the new one, and its processor must be closed by + # the thread that is using it, not here. + self._retire_recomputer() + self._record_serial += 1 + self._depth_request_seq += 1 + self._depth_pending_seq = None + self._depth_pending_position = None + self._depth_error = None self._reader = RecordingReader(directory) self._recomputer = DepthRecomputer(self._reader) info = self._reader.info() @@ -188,19 +207,48 @@ class PlaybackDialog(QDialog): self._depth_result_seq = None self.set_position(0.0) + def _retire_recomputer(self) -> None: + """Hand the current processor to its worker, or close it here if idle. + + Closing a processor that a worker thread is still reading is a use-after- + free; only the thread that owns it may close it, so a busy one is marked + for the worker to close when it finishes. + """ + recomputer, self._recomputer = self._recomputer, None + if recomputer is None: + return + if self._depth_busy(): + self._retired_recomputers.append(recomputer) + else: + try: + recomputer.close() + except Exception: # noqa: BLE001 - closing is best effort + logger.exception("Could not close the depth recomputer") + def _refresh_warnings(self) -> None: if self._reader is None: return + info = self._reader.info() _ok, sdk_version = DepthRecomputer.sdk_available() # The reader compares the recorded config hash against the config file as # it is now, which is what the depth recomputation would actually use. codes = self._reader.replay_warnings(sdk_version=sdk_version) - if not codes: - self.warning_label.setText("") - return - self.warning_label.setText( - t("playback.warnings", detail=", ".join(t(f"playback.warn_{c}") for c in codes)) - ) + text = "" + if codes: + text = t( + "playback.warnings", + detail=", ".join(t(f"playback.warn_{c}") for c in codes), + ) + if info.decimated: + # The curves are a view, not every sample: say so rather than let the + # operator count points. + note = t( + "playback.decimated", + shown=len(info.times), + total=info.total_rows, + ) + text = f"{text} {note}" if text else note + self.warning_label.setText(text) # ------------------------------------------------------------------ drawing def _render_curves(self) -> None: @@ -211,11 +259,21 @@ class PlaybackDialog(QDialog): for curve in self.curves: curve.setData([], []) return - # Same rule as the live charts: invalid or non-finite points are not - # drawn, whichever view is asking. - times, drawn, _mask = info.plottable(self._unit) + # Same rule as the live charts, per axis: a point is drawn only where the + # sample is valid and *that* axis is finite, so a single-axis calibration + # does not hide the axis that is calibrated. For a long recording the + # whole-session view is decimated while it is read from disk, in the unit + # being shown. + window_all = getattr(self._reader, "window_all", None) + if callable(window_all): + window = window_all(unit=self._unit, max_points=CHART_MAX_POINTS) + for index, curve in enumerate(self.curves): + times, values = window.plottable_axis(self._unit, index) + curve.setData(times, values) + return for index, curve in enumerate(self.curves): - curve.setData(times, drawn[:, index]) + times, values = info.plottable_axis(self._unit, index) + curve.setData(times, values) self.plot_widget.ci.layout.setRowStretchFactor(index, 1) self.plot_widget.getItem(index, 0).setXRange( float(info.times[0]), float(info.times[-1]), padding=0.02 @@ -307,22 +365,40 @@ class PlaybackDialog(QDialog): self._depth_error = None self.depth_label.setText(t("playback.depth_running")) position = self._position - # Bind the answer to this request: a result that arrives after the cursor - # moved is discarded rather than pasted onto the new position. + # Bind the answer to this request: the recording, the frame and a request + # sequence. A result that arrives after the cursor moved — or after + # another recording was opened — is discarded rather than pasted on. + recomputer = self._recomputer + record = self._record_serial + frame = self._current_frame() self._depth_request_seq += 1 seq = self._depth_request_seq self._depth_pending_seq = seq self._depth_pending_position = position + self._depth_pending_record = record def work() -> None: - # Runs off the UI thread and touches no Qt object. + # Runs off the UI thread and touches no Qt object. It captures the + # processor it was given: the dialog may already be showing another + # recording by the time this returns. try: - result = self._recomputer.depth_at(position) + result = recomputer.depth_at(position) except BaseException as exc: # noqa: BLE001 - reported via the poll - self._depth_error = exc + self._depth_error = (record, exc) return + finally: + if recomputer in self._retired_recomputers: + # Retired while this request was running: the owning thread + # closes it, not the UI thread. + self._retired_recomputers.remove(recomputer) + try: + recomputer.close() + except Exception: # noqa: BLE001 + logger.exception("Could not close a retired recomputer") self._depth_result = result self._depth_result_seq = seq + self._depth_result_record = record + self._depth_result_frame = getattr(frame, "frame_id", -1) self._depth_thread = threading.Thread( target=work, name="depth-replay", daemon=True @@ -337,15 +413,22 @@ class PlaybackDialog(QDialog): return self._depth_poll.stop() pending = self._depth_pending_seq + record = self._depth_pending_record self._depth_pending_seq = None self._depth_pending_position = None + self._depth_pending_record = None if self._depth_error is not None: + error_record, exc = self._depth_error + self._depth_error = None + if error_record != self._record_serial: + # The failure belongs to a recording that is no longer open. + return self._depth_ok = False self.depth_label.setPixmap(QPixmap()) - self.depth_label.setText( - t("playback.depth_failed", detail=str(self._depth_error)) - ) - self._depth_error = None + self.depth_label.setText(t("playback.depth_failed", detail=str(exc))) + return + if record != self._record_serial: + # Another recording was opened while this was running. return result = self._depth_result if result is None or self._depth_result_seq != pending: @@ -468,10 +551,18 @@ class PlaybackDialog(QDialog): def closeEvent(self, event) -> None: # noqa: N802 self._depth_poll.stop() # A depth recomputation that is blocked inside the SDK cannot be - # cancelled; it is a daemon thread, so it does not keep the process - # alive and the window always closes. - if self._recomputer is not None and not self._depth_busy(): - self._recomputer.close() + # cancelled; it is a daemon thread, so it does not keep the process alive + # and the window always closes. The processor is closed by whichever + # thread owns it (see ``_retire_recomputer``), never by the UI thread + # while a worker is reading it. + for recomputer in list(self._retired_recomputers): + if not self._depth_busy(): + self._retired_recomputers.remove(recomputer) + try: + recomputer.close() + except Exception: # noqa: BLE001 + logger.exception("Could not close a retired recomputer") + self._retire_recomputer() super().closeEvent(event) def _depth_busy(self) -> bool: diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py index aad4320..7f45573 100644 --- a/apps/studio/tests/test_ui_smoke.py +++ b/apps/studio/tests/test_ui_smoke.py @@ -289,7 +289,7 @@ def test_cal_panel_records_and_lists_samples(make_window): ] -def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40) -> None: +def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40): """Drive one point through the recorder and into the panel's table.""" from orisys_app_shared.force.session import SessionSample @@ -305,7 +305,9 @@ def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40 valid=True, ) ) - window.cal_panel.on_record_finished(window.cal_recorder.finish_now()) + point = window.cal_recorder.finish_now() + window.cal_panel.on_record_finished(point) + return point def test_calibration_diagnostics_drive_the_fit(make_window): @@ -357,6 +359,64 @@ def test_calibration_diagnostics_drive_the_fit(make_window): model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")}) assert abs(predict_channel(model, "fn", 400.0) - 2.0) > 0.05 + # The store, the label and the export are the same preparation: the panel + # must not derive the model twice and show numbers for one of them. + prepared = window.cal_panel._prepared + assert prepared is not None and prepared.ok + assert list(store.channel("fn")) == list(prepared.samples["fn"]) + assert prepared.points_in_fit("fn") == 3 + + +def test_the_panel_shows_the_numbers_of_the_file_it_exports(make_window, record_root): + """Displayed diagnostics must match the written coefficients. + + A repeated reference load makes this sharp: the two samples of one load must + be merged before the build, so the exported curve and the panel's residual + and validation figures all come from that single prepared model. + """ + import json + from pathlib import Path + + import orisys.calibration as sdk_calibration + + from orisys_app_shared.force.calibration_io import build_force_calibration_v2 + + window = _show(make_window(), (1400, 880)) + window.cal_panel.set_running(True) + window.cal_panel.set_meta_context(sensor_type="box", config_ref=None) + _add_calibration_point(window, load=1.0, raw=100.0) + _add_calibration_point(window, load=2.0, raw=200.0) + _add_calibration_point(window, load=2.0, raw=240.0) # a deliberate repeat + _add_calibration_point(window, load=4.0, raw=400.0) + _add_calibration_point(window, load=3.0, raw=290.0) + + panel = window.cal_panel + panel.table.setCurrentCell(4, 0) + panel._on_toggle_validation() + panel._rebuild_store() + panel._refresh_fit_label() + prepared = panel._prepared + assert prepared is not None and prepared.ok + assert len(prepared.samples["fn"]) == 3, "the repeated load was not merged" + + # Write the document the export would write, load it back with the SDK and + # check that its predictions are the prepared ones. + document, _warnings = build_force_calibration_v2(prepared) + path = Path(record_root) / "force_calibration.json" + path.write_text(json.dumps(document), encoding="utf-8") + loaded = sdk_calibration.load(path, strict=True) + + for point in window.cal_recorder.points: + raw = float(point.loaded_stats().mean) + predicted = float(loaded.get("fn").convert(raw)) + assert predicted == pytest.approx(prepared.predict("fn", raw), abs=1e-9) + + # ...and the label describes that same model. + label = panel.fit_label.text() + assert "分段线性" in label or "piecewise" in label, label + assert "3" in label + assert prepared.max_validation_error("fn") > 0.0 + def test_calibration_point_dialog_shows_three_axes(make_window): window = _show(make_window(), (1400, 880)) @@ -373,6 +433,137 @@ def test_calibration_point_dialog_shows_three_axes(make_window): dialog.close() +def _write_session_file(root, *, sensor_type, sn, config_ref, points): + import json + + from orisys_app_shared.force.calibration_session import build_session_document + + document = build_session_document( + points, + meta={"sensor_type": sensor_type, "sn": sn, "config_ref": config_ref}, + ) + path = Path(root) / f"calibration_session_{sensor_type}.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def test_opening_device_a_record_keeps_device_a_identity(make_window, record_root): + """Device A's record must not be restamped with device B's identity.""" + import json + from unittest.mock import patch as _patch + + import orisys_studio.ui.cal_panel as cal_panel_module + from orisys_app_shared.force.calibration_io import build_force_calibration_v2 + + window = _show(make_window(), (1400, 880)) + panel = window.cal_panel + # A record taken on device A, with a full audit series. + session_points = [ + _add_calibration_point(window, load=1.0, raw=100.0), + _add_calibration_point(window, load=3.0, raw=300.0), + ] + source = _write_session_file( + record_root, + sensor_type="box", + sn="SN-A", + config_ref="D:/lab/deviceA/sensor.json", + points=session_points, + ) + window.cal_recorder.clear_points() + + # ...and the live context is device B. + panel.set_meta_context(sensor_type="finger", config_ref="D:/lab/deviceB/sensor.json") + panel.sn_edit.setText("SN-B") + + panel._on_open_record(str(source)) + assert panel.sn_edit.text() == "SN-A", "the record's SN was overwritten" + meta = panel._export_meta() + assert meta["sensor_type"] == "box" + assert meta["sn"] == "SN-A" + assert meta["config_ref"] == "D:/lab/deviceA/sensor.json" + assert meta["source_record"].endswith(source.name) + + # Saving the record again still says A. + import orisys_studio.calibration_service as cal_service_module + + with _patch.object(cal_panel_module, "calibration_export_dir", lambda: Path(record_root)): + with _patch.object( + cal_service_module, "calibration_export_dir", lambda: Path(record_root) + ): + with _patch.object(cal_panel_module.QMessageBox, "information", lambda *a, **k: None): + panel._on_save_record() + saved = sorted(Path(record_root).glob("calibration_session_box_*.json")) + assert saved, "the record was not saved under device A's type" + saved_meta = json.loads(saved[-1].read_text(encoding="utf-8"))["meta"] + assert saved_meta["sn"] == "SN-A" + assert saved_meta["sensor_type"] == "box" + assert saved_meta["config_ref"] == "D:/lab/deviceA/sensor.json" + + # ...and so does the exported coefficient document. + document, _warnings = build_force_calibration_v2(panel._prepared) + assert document["meta"]["sensor_type"] == "box" + assert document["meta"]["sn"] == "SN-A" + assert document["meta"]["config_ref"] == "D:/lab/deviceA/sensor.json" + + +def test_a_reopened_point_can_be_inspected_from_its_samples(make_window, record_root): + """A record with samples but no cached trace still opens its diagnostics.""" + window = _show(make_window(), (1400, 880)) + panel = window.cal_panel + points = [ + _add_calibration_point(window, load=1.0, raw=100.0), + _add_calibration_point(window, load=2.0, raw=200.0), + ] + import json + + from orisys_app_shared.force.calibration_session import build_session_document + + document = build_session_document(points, meta={"sensor_type": "box"}) + for entry in document["points"]: + entry["trace"] = {} # only the audit series survived + entry["trace_dt_s"] = 0.0 + source = Path(record_root) / "no_trace.json" + source.write_text(json.dumps(document), encoding="utf-8") + + panel._on_open_record(str(source)) + reopened = window.cal_recorder.points[0] + assert reopened.samples, "the reopened point lost its samples" + dialog = panel.point_dialog(reopened) + assert dialog is not None, "the reopened point could not be inspected" + assert len(dialog.plot_widget.ci.items) == 3 + dialog.close() + + +def test_applying_another_devices_calibration_is_flagged(make_window, record_root): + """The apply path states a sensor/config mismatch instead of silently applying.""" + import json + + from orisys_app_shared.force.calibration_io import build_export_model, write_force_calibration + from orisys_app_shared.force.calibration_io import CalSample, CalSessionStore + + window, stuck = _running_window(make_window) + store = CalSessionStore() + store.aggregate("fn", CalSample(weight_n=0.0, raw_mean=0.0, n=10)) + store.aggregate("fn", CalSample(weight_n=5.0, raw_mean=500.0, n=10)) + model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")}) + document = model.to_dict() + document["identity"] = {"sensor_type": "box", "sn": "SN-A"} + document["pipeline"] = {"config_ref": "D:/lab/deviceA/sensor.json"} + path = Path(record_root) / "deviceA_calibration.json" + path.write_text(json.dumps(document), encoding="utf-8") + + # The window is connected to a finger device. + mismatch = window.calibration_target_mismatch( + str(path), sensor_type="finger", config_ref="D:/lab/deviceB/sensor.json" + ) + assert any("sensor_type" in reason for reason in mismatch), mismatch + assert any("config_ref" in reason for reason in mismatch), mismatch + assert not window.calibration_target_mismatch( + str(path), sensor_type="box", config_ref="D:/lab/deviceA/sensor.json" + ) + _finish(window, stuck) + + def test_force_plots_render_demo_data(make_window): window = _show(make_window(), (1440, 880)) for i in range(60): @@ -502,6 +693,344 @@ def test_history_keeps_negative_fn(make_window): _finish(window, stuck) +def test_the_live_axis_is_session_relative_and_survives_a_unit_switch(make_window): + """The live buffer, the store and a rebuilt history share one time base. + + Feeding the wall clock into the plot put the live axis ~1.7e9 s away from the + session store's, so a unit switch (which rebuilds from the store) jumped the + X axis and the chart looked like it had lost its history. + """ + wall = 1_700_000_000.0 + window, stuck = _running_window(make_window) + window.toolbar.units_combo.setCurrentIndex(0) # newton + window.force_plots.reset() + for index in range(90): + stuck.emit( + ui_check._fake_packet( + index, timestamp=wall + index / 30.0, mono_t=index / 30.0 + ) + ) + window._on_run_tick() + + before = window.force_plots.history.state() + assert before is not None + end_before = float(before.times[-1]) + assert end_before < 60.0, ( + f"the live X axis is not session-relative: it ends at {end_before}" + ) + assert abs(end_before - 89 / 30.0) < 0.5, end_before + + for units_index in (1, 0, 1, 0): # raw -> N -> raw -> N + window.toolbar.units_combo.setCurrentIndex(units_index) + + after = window.force_plots.history.state() + assert after is not None, "the unit switch emptied the rebuilt history" + assert abs(float(after.times[-1]) - end_before) < 1.0, ( + f"the X axis jumped across a unit switch: {end_before} -> {after.times[-1]}" + ) + _finish(window, stuck) + + +def test_a_single_calibrated_axis_is_shown_and_the_others_are_unavailable(make_window): + """Only FN calibrated: FN in newtons, FX/FY explicitly unavailable.""" + import math + + from orisys_app_shared.i18n import t + + window, stuck = _running_window(make_window) + window.toolbar.units_combo.setCurrentIndex(0) # newton + window.force_plots.reset() + for index in range(10): + stuck.emit( + ui_check._fake_packet( + index, + calibrated_axes=("fn",), + shear_n=float("nan"), + ) + ) + window._on_run_tick() + + plots = window.force_plots + assert plots._available == (True, False, False), plots._available + assert plots.value_labels[0].text() != t("chart.value_unavailable") + for index in (1, 2): + assert plots.value_labels[index].text() == t("chart.value_unavailable"), ( + "an axis with no curve must not show a number" + ) + state = plots.history.state() + assert math.isfinite(float(state.values[:, 0].max())) + assert not any(math.isfinite(v) for v in state.values[:, 1]), ( + "FX carried a value in newton mode without a curve" + ) + _finish(window, stuck) + + +def test_a_slow_close_is_not_reported_as_a_finished_recording(make_window): + """Stopping must not present an unclosed file as a finished experiment.""" + import threading as _threading + from unittest.mock import patch as _patch + + from orisys_app_shared.i18n import t + from orisys_app_shared.record.experiment import _SessionWriter + + window, stuck = _running_window(make_window) + window.toolbar.btn_record.setChecked(True) + stuck.emit(ui_check._fake_packet(0)) + window._on_run_tick() + + started = _threading.Event() + release = _threading.Event() + original = _SessionWriter.write_sample + + def slow(self, *args): + started.set() + release.wait(10.0) + return original(self, *args) + + with _patch.object(_SessionWriter, "write_sample", slow): + stuck.emit(ui_check._fake_packet(1)) + window._on_run_tick() + assert started.wait(5.0), "the writer never picked up the frame" + + window._stop_recording(wait=False) + assert window.recorder.report is None, "the stop claimed a finished recording" + assert window._record_report_pending, "the pending report was not tracked" + assert window._record_last_report is None + assert not window.toolbar.btn_record.isChecked(), ( + "the record button still looks active while the file closes" + ) + assert window.statusBar().currentMessage() == t("record.closing") + + release.set() + deadline = __import__("time").monotonic() + 10.0 + while window.recorder.report is None and __import__("time").monotonic() < deadline: + __import__("time").sleep(0.01) + window._on_run_tick() + + report = window._record_last_report + assert report is not None and report.completed, "the terminal report was never published" + assert not window._record_report_pending + _finish(window, stuck) + + +def test_a_failed_recording_is_consumed_even_when_it_is_not_running(make_window): + """A recording that died on its own still has a report to publish.""" + import time as _time + from unittest.mock import patch as _patch + + from orisys_app_shared.record.experiment import _SessionWriter + + window, stuck = _running_window(make_window) + window.toolbar.btn_record.setChecked(True) + assert window.recorder.is_recording + + with _patch.object(_SessionWriter, "write_sample", side_effect=OSError("disk full")): + for index in range(8): + stuck.emit(ui_check._fake_packet(index)) + window._on_run_tick() + deadline = _time.monotonic() + 3.0 + while window.recorder.stats().write_errors < 5 and _time.monotonic() < deadline: + _time.sleep(0.01) + assert window.recorder.stats().failed + assert not window.recorder.is_recording + + # ``is_recording`` is already False: the stop must still consume the report. + window._stop_recording() + report = window._record_last_report + assert report is not None and report.stats.failed + assert not window.toolbar.btn_record.isChecked() + assert window._session_phase == "error" + _finish(window, stuck) + + +def test_the_depth_request_belongs_to_the_recording_it_started_in(make_window, record_root): + """Opening another recording must invalidate the request still running.""" + import threading as _threading + import time as _time + + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.record.depth import DepthResult + from orisys_studio.ui.playback_dialog import PlaybackDialog + + window = _show(make_window(), (1200, 700)) + dialog = PlaybackDialog(theme="dark", directory=Path(record_root)) + first_serial = dialog._record_serial + + started = _threading.Event() + release = _threading.Event() + closed: list[str] = [] + + class _Blocking: + def preflight(self) -> str: + return "" + + def depth_at(self, _position, **_kwargs): + started.set() + release.wait(10.0) + return DepthResult( + depth=np.ones((2, 2)), frame_id=0, t_rel_s=0.0, record_id="old" + ) + + def close(self) -> None: + closed.append("closed") + + blocking = _Blocking() + dialog._recomputer = blocking + dialog._on_recompute_depth() + assert started.wait(5.0), "the depth request never started" + + # Another recording is opened while the request is in flight. + dialog.open_recording(Path(record_root)) + assert dialog._record_serial == first_serial + 1 + assert dialog._recomputer is not blocking, "the processor was not replaced" + assert closed == [], "the UI thread closed a processor a worker was reading" + + release.set() + deadline = _time.monotonic() + 10.0 + while not closed and _time.monotonic() < deadline: + QApplication.processEvents() + _time.sleep(0.01) + assert closed == ["closed"], "the retired processor was never closed" + assert dialog._depth_thread is not None and not dialog._depth_thread.is_alive() + + # The answer belongs to the previous recording, so it is dropped, not drawn. + dialog._poll_depth() + QApplication.processEvents() + assert dialog._depth_ok is False + pixmap = dialog.depth_label.pixmap() + assert pixmap is None or pixmap.isNull(), "an answer for another recording was drawn" + dialog.close() + window.close() + + +def test_a_busy_depth_processor_is_not_closed_by_the_ui_thread(make_window, record_root): + import threading as _threading + import time as _time + + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.record.depth import DepthResult + from orisys_studio.ui.playback_dialog import PlaybackDialog + + window = _show(make_window(), (1200, 700)) + dialog = PlaybackDialog(theme="dark", directory=Path(record_root)) + started = _threading.Event() + release = _threading.Event() + closed: list[str] = [] + + class _Blocking: + def preflight(self) -> str: + return "" + + def depth_at(self, _position, **_kwargs): + started.set() + release.wait(10.0) + return DepthResult(depth=np.ones((2, 2)), frame_id=0, t_rel_s=0.0) + + def close(self) -> None: + closed.append("closed") + + dialog._recomputer = _Blocking() + dialog._on_recompute_depth() + assert started.wait(5.0) + dialog.close() # closing the window must not free a processor in use + QApplication.processEvents() + assert closed == [], "the window closed a processor that was still reading" + release.set() + deadline = _time.monotonic() + 10.0 + while not closed and _time.monotonic() < deadline: + QApplication.processEvents() + _time.sleep(0.01) + assert closed == ["closed"], "the owning thread never closed the processor" + window.close() + + +def test_a_confirmed_calibration_is_snapshotted_into_the_recording(make_window, record_root): + """The recording keeps the coefficients themselves, not only their hash.""" + import json + from pathlib import Path + + import orisys_studio.app_window as app_module + from orisys_app_shared.i18n import t + + window, stuck = _running_window(make_window) + app_module.recordings_dir = lambda: record_root + window.toolbar.btn_record.setChecked(True) + assert window.recorder.is_recording + + coefficients = Path(record_root) / "force_calibration_box.json" + coefficients.write_text('{"unit": "N", "channels": {}}', encoding="utf-8") + version = "deadbeefcafe1234" + window._on_calibration_applied( + str(coefficients), version, 21, 0.7, int(window.session_store.session_id) + ) + window._drain_calibration_events() + + directory = window.recorder.directory + assert directory is not None + snapshots = sorted(path.name for path in (directory / "calibration").iterdir()) + assert snapshots == [f"force_calibration_{version[:12]}.json"], snapshots + assert (directory / "calibration" / snapshots[0]).read_text( + encoding="utf-8" + ) == '{"unit": "N", "channels": {}}' + assert window._active_calibration_id == version + assert window.session_store.calibration_id == version + + window._stop_recording() + meta = json.loads((directory / "session.json").read_text(encoding="utf-8")) + entries = meta["calibration_snapshots"] + assert entries and entries[0]["version"] == version + assert entries[0]["file"] == f"calibration/{snapshots[0]}" + assert entries[0]["frame_id"] == 21 + event = next(e for e in meta["events"] if e["kind"] == "calibration_applied") + assert event["frame_id"] == 21 and event["mono_t"] == pytest.approx(0.7) + assert snapshots[0] in event["detail"] + _finish(window, stuck) + + +def test_a_confirmed_absence_of_calibration_does_not_keep_the_old_version(make_window): + """An empty version is the honest answer; it must not fall back to the path.""" + from orisys_app_shared.i18n import t + + window = _show(make_window(), (1400, 880)) + window._active_calibration_id = "previous-hash" + window._on_calibration_applied("/tmp/whatever.json", "", 5, 0.2, 1) + window._drain_calibration_events() + assert window._active_calibration_id == "", ( + "an uncalibrated device kept claiming the previous coefficients" + ) + assert window.statusBar().currentMessage() == t("cal.applied_none") + + +def test_a_zeroing_is_recorded_only_after_the_worker_confirms_it(make_window, record_root): + import json + + import orisys_studio.app_window as app_module + + window, stuck = _running_window(make_window) + app_module.recordings_dir = lambda: record_root + window.toolbar.btn_record.setChecked(True) + stuck.emit(ui_check._fake_packet(3)) + window._on_run_tick() + + window._on_reset() + assert stuck.reset_requests == 1, "the zeroing was not requested" + # The stub confirms from the acquisition side, as a real device does. + stuck.on_reset_applied = window._on_reset_applied + window._on_reset() + window._on_run_tick() + window._stop_recording() + + directory = window.recorder.directory + meta = json.loads((directory / "session.json").read_text(encoding="utf-8")) + zeroing = [e for e in meta["events"] if e["kind"] == "zeroing"] + assert len(zeroing) == 1, f"unexpected zeroing events: {zeroing}" + assert zeroing[0]["frame_id"] == 3, zeroing[0] + assert zeroing[0]["mono_t"] is not None + _finish(window, stuck) + + def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window): import numpy as np diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py index 2a3a264..ddeceea 100644 --- a/apps/studio/tools/ui_check.py +++ b/apps/studio/tools/ui_check.py @@ -144,6 +144,10 @@ def _fake_packet( valid: bool = True, depth=None, reason: str = "", + calibrated_axes=None, + timestamp: float | None = None, + mono_t: float | None = None, + shear_n: float | None = None, ): """A packet shaped like the real worker's, for the record-channel checks.""" import numpy as np @@ -157,17 +161,19 @@ def _fake_packet( return SdkPacket( raw_frame=blank, processed_frame=blank, - timestamp=1000.0 + frame_id / 30.0, + timestamp=1000.0 + frame_id / 30.0 if timestamp is None else timestamp, + mono_t=mono_t, fps=30.0, process_ms=1.0, normal_force=(2.0 + frame_id * 0.01) if valid else nan, - shear_x=0.5 if valid else nan, + shear_x=(0.5 if shear_n is None else shear_n) if valid else nan, shear_y=-0.5 if valid else nan, normal_force_raw=(9.0 + frame_id * 0.1) if valid else nan, shear_x_raw=0.4 if valid else nan, shear_y_raw=-0.4 if valid else nan, force_unit="N", force_calibrated=True, + calibrated_axes=tuple(calibrated_axes or ()), session_id=1, frame_id=frame_id, valid=valid, @@ -194,6 +200,27 @@ class _StuckWorker(RecordSinkMixin): self.stop_blocked_for = 0.0 #: Released by ``release()`` so daemon threads do not outlive the run. self._release = threading.Event() + #: Calibration requests this worker was asked to apply. + self.calibration_requests: list[str] = [] + #: Zeroing requests, and the confirmation callback the window installs. + self.reset_requests = 0 + self.on_reset_applied = None + + def set_pending_calibration(self, path) -> None: + self.calibration_requests.append(str(path)) + + def reset(self) -> None: + """Request a zeroing; a real device confirms it from its own thread. + + This stub confirms immediately (a fast device), so the window's event path + — request, confirmation, recorded event — is exercised end to end. + """ + self.reset_requests += 1 + if callable(self.on_reset_applied): + packet = self._latest + frame_id = int(getattr(packet, "frame_id", -1)) + mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0) + self.on_reset_applied(frame_id, mono_t, 1) def start(self, _source, *, config_ref=None) -> None: self._release.clear() @@ -229,9 +256,6 @@ class _StuckWorker(RecordSinkMixin): reason, self._end_reason = self._end_reason, None return reason - def reset(self) -> None: - return None - def stop(self, *, wait: bool = False) -> None: self._end_reason = self._end_reason or "stopped" started = time.perf_counter() @@ -666,8 +690,10 @@ def main() -> int: ) from orisys_app_shared.force.calibration_io import ( build_export_model, + build_force_calibration_v2 as _build_v2, predict_channel, ) + import orisys.calibration as _sdk_calibration store = window.cal_panel._store model = build_export_model( @@ -678,6 +704,44 @@ def main() -> int: abs(predicted - 2.0) > 0.05, f"excluding a point left the exported model unchanged: {predicted}", ) + # One preparation: the store the panel shows is the prepared model's + # input, and the export serialises that same object. + prepared = window.cal_panel._prepared + check( + prepared is not None and prepared.ok, + "the panel has no prepared calibration to show or export", + ) + check( + list(store.channel("fn")) == list(prepared.samples["fn"]), + "the panel store is not the prepared model's input", + ) + doc, _warnings = _build_v2(prepared) + export_dir = APP_ROOT / "outputs" / "_cal_export_check" + if export_dir.exists(): + for stale in export_dir.iterdir(): + stale.unlink() + export_dir.mkdir(parents=True, exist_ok=True) + written = export_dir / "exported_calibration.json" + import json as _cal_json + + written.write_text(_cal_json.dumps(doc), encoding="utf-8") + loaded = _sdk_calibration.load(written, strict=True) + for point in window.cal_recorder.points: + if point.excluded: + continue + raw = float(point.loaded_stats().mean) + check( + abs( + float(loaded.get("fn").convert(raw)) + - prepared.predict("fn", raw) + ) + <= 1e-9, + "the exported file and the panel disagree about the model", + ) + check( + len(prepared.samples["fn"]) == 3, + f"the prepared build has {len(prepared.samples['fn'])} points, not 3", + ) window.cal_recorder.reset_session() window.cal_panel._refresh_table() @@ -961,7 +1025,8 @@ def main() -> int: app.processEvents() check(window.recorder.is_recording, "the recording did not start") check( - window.toolbar.btn_record.isEnabled() and window.toolbar.btn_record.text() == t("record.stop_label"), + window.toolbar.btn_record.isEnabled() + and window.toolbar.btn_record.text() == t("record.stop_label"), "the record button is not a usable stop control", ) check( @@ -974,13 +1039,26 @@ def main() -> int: window._on_run_tick() stuck.emit(_fake_packet(90, valid=False, reason="read failed")) window._on_run_tick() - window._on_reset() # event: zeroing + # The zeroing is a *request*; the event is recorded only once the acquisition + # thread confirms it took effect (the stub confirms immediately). + stuck.on_reset_applied = window._on_reset_applied + window._on_reset() + window._on_run_tick() + app.processEvents() window.toolbar.btn_record.setChecked(False) app.processEvents() report = window._record_last_report check(not window.recorder.is_recording, "the recording did not stop") check(report is not None and report.ok, f"recording reported problems: {getattr(report, 'problems', None)}") + check( + not window.toolbar.btn_record.isChecked(), + "the record button still looks active after the recording stopped", + ) + check( + window._record_report_pending is False, + "a finished recording is still waiting for a report", + ) import csv as _csv import json as _json @@ -1019,6 +1097,388 @@ def main() -> int: stuck.release() _shutil.rmtree(scratch, ignore_errors=True) + section("record failure states") + + from unittest.mock import patch as _patch + + from orisys_app_shared.record.experiment import _SessionWriter as _Writer + + scratch_fail = APP_ROOT / "outputs" / "_record_fail" + _shutil.rmtree(scratch_fail, ignore_errors=True) + scratch_fail.mkdir(parents=True, exist_ok=True) + stuck_fail = _StuckWorker() + fail_window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + ) + fail_window.resize(1440, 880) + fail_window.show() + fail_window._ensure_workers = lambda: None + fail_window._box_worker = stuck_fail + fail_window._finger_worker = stuck_fail + fail_window._start_run() + fail_window._run_timer.stop() + fail_window._set_phase("running") + + # A directory that cannot be created is a terminal, retryable failure. + blocked = scratch_fail / "blocked" + blocked.write_text("", encoding="utf-8") + _app_module.recordings_dir = lambda: blocked + fail_window.toolbar.btn_record.setChecked(True) + app.processEvents() + check( + not fail_window.recorder.is_recording, + "a recording started even though its directory could not be created", + ) + check( + not fail_window.toolbar.btn_record.isChecked(), + "the record button stayed active after the start failed", + ) + check( + fail_window.recorder.state == "failed", + f"a failed start left the recorder in {fail_window.recorder.state!r}", + ) + + # ...and the operator can simply try again. + _app_module.recordings_dir = lambda: scratch_fail + fail_window.toolbar.btn_record.setChecked(True) + app.processEvents() + check( + fail_window.recorder.is_recording, + "the recorder refused to retry after a failed directory", + ) + + # A writer that keeps failing must fail the recording, and the window must + # consume that report even though ``is_recording`` is already False. + with _patch.object(_Writer, "write_sample", side_effect=OSError("disk full")): + for index in range(8): + stuck_fail.emit(_fake_packet(index)) + fail_window._on_run_tick() + deadline = time.monotonic() + 3.0 + while ( + fail_window.recorder.stats().write_errors < 5 + and time.monotonic() < deadline + ): + app.processEvents() + time.sleep(0.01) + check( + fail_window.recorder.stats().failed, + "repeated write failures never failed the recording", + ) + check( + not fail_window.recorder.is_recording, + "a dead writer still claimed to be recording", + ) + fail_window._stop_recording() + app.processEvents() + failed_report = fail_window._record_last_report + check( + failed_report is not None and failed_report.stats.failed, + "the window did not consume the failure report", + ) + check( + not fail_window.toolbar.btn_record.isChecked(), + "the record button stayed active after the recording failed", + ) + check( + fail_window._session_phase == "error", + f"the session phase is {fail_window._session_phase!r} after a failed recording", + ) + + fail_window.close() + app.processEvents() + stuck_fail.release() + _app_module.recordings_dir = lambda: scratch + _shutil.rmtree(scratch_fail, ignore_errors=True) + + section("calibration record identity") + + import json as _cal_json + from unittest.mock import patch as _patch_mismatch + + import orisys_studio.ui.cal_panel as _cal_panel_module + import orisys_studio.calibration_service as _cal_service_module + from PyQt5.QtWidgets import QMessageBox as _QMessageBox + + from orisys_app_shared.force.calibration_io import ( + build_force_calibration_v2 as _build_v2b, + write_force_calibration as _write_cal, + ) + from orisys_app_shared.force.calibration_session import ( + build_session_document as _build_session_doc, + ) + + record_dir = APP_ROOT / "outputs" / "_cal_record_check" + if record_dir.exists(): + for stale in record_dir.iterdir(): + stale.unlink() + record_dir.mkdir(parents=True, exist_ok=True) + + identity_window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + ) + identity_window.resize(1400, 880) + identity_window.show() + identity_window.cal_panel.set_running(True) + taken_points = [] + for load, raw in ((1.0, 100.0), (3.0, 300.0)): + identity_window.cal_recorder.start( + channel="fn", reference_load_n=load, duration_s=1.0 + ) + for index in range(20): + identity_window.cal_recorder.feed( + _calibration_sample(index, fn_raw=raw) + ) + taken_points.append(identity_window.cal_recorder.finish_now()) + device_a = record_dir / "device_a_session.json" + device_a.write_text( + _cal_json.dumps( + _build_session_doc( + taken_points, + meta={ + "sensor_type": "box", + "sn": "SN-A", + "config_ref": "D:/lab/deviceA/sensor.json", + }, + ) + ), + encoding="utf-8", + ) + identity_window.cal_recorder.clear_points() + + # The live device is B; opening A's record must not restamp it. + panel = identity_window.cal_panel + panel.set_meta_context(sensor_type="finger", config_ref="D:/lab/deviceB/sensor.json") + panel.sn_edit.setText("SN-B") + panel._on_open_record(str(device_a)) + app.processEvents() + check( + panel.sn_edit.text() == "SN-A", + f"the reopened record's SN was overwritten: {panel.sn_edit.text()!r}", + ) + meta = panel._export_meta() + check(meta["sensor_type"] == "box", "the record's sensor type was overwritten") + check(meta["sn"] == "SN-A", "the record's SN was not kept") + check( + meta["config_ref"] == "D:/lab/deviceA/sensor.json", + f"the record's config was overwritten: {meta['config_ref']!r}", + ) + check( + str(meta.get("source_record", "")).endswith(device_a.name), + "the export does not reference the source record", + ) + saved = sorted(record_dir.glob("calibration_session_box_*.json")) + with _patch_mismatch.object( + _cal_panel_module, "calibration_export_dir", lambda: record_dir + ): + with _patch_mismatch.object( + _cal_service_module, "calibration_export_dir", lambda: record_dir + ): + with _patch_mismatch.object( + _cal_panel_module.QMessageBox, "information", lambda *a, **k: None + ): + panel._on_save_record() + saved = sorted(record_dir.glob("calibration_session_box_*.json")) + check(bool(saved), "saving a reopened record produced no file") + if saved: + saved_meta = _cal_json.loads(saved[-1].read_text(encoding="utf-8"))["meta"] + check( + saved_meta.get("sn") == "SN-A" and saved_meta.get("sensor_type") == "box", + f"the saved record was restamped: {saved_meta.get('sn')!r}", + ) + doc, _warnings = _build_v2b(panel._prepared) + check( + doc["meta"]["sn"] == "SN-A" and doc["meta"]["sensor_type"] == "box", + "the exported coefficients carry the wrong device identity", + ) + + # A reopened point with only its samples can still be inspected. + reopened = identity_window.cal_recorder.points[0] + for entry in _build_session_doc([reopened])["points"]: + entry["trace"] = {} + dialog = panel.point_dialog(reopened) + check(dialog is not None, "a reopened point could not be inspected") + if dialog is not None: + check( + len(dialog.plot_widget.ci.items) == 3, + "the rebuilt diagnostic is not one trace per axis", + ) + dialog.close() + + # Applying a calibration that names another device is flagged and defaults + # to "no". + stuck_identity = _StuckWorker() + identity_window._ensure_workers = lambda: None + identity_window._box_worker = stuck_identity + identity_window._finger_worker = stuck_identity + coefficients = record_dir / "device_a_coefficients.json" + _write_cal(doc, coefficients) + check( + bool( + identity_window.calibration_target_mismatch( + str(coefficients), + sensor_type="finger", + config_ref="D:/lab/B/sensor.json", + ) + ), + "a calibration for another device was not reported as a mismatch", + ) + check( + not identity_window.calibration_target_mismatch( + str(coefficients), + sensor_type="box", + config_ref="D:/lab/deviceA/sensor.json", + ), + "matching identities were reported as a mismatch", + ) + with _patch_mismatch.object( + _QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.No) + ): + identity_window._on_calibration_apply(str(coefficients)) + check( + not stuck_identity.calibration_requests, + "a mismatched calibration was applied after the user declined", + ) + check( + identity_window.statusBar().currentMessage() == t("cal.apply_mismatch_skipped"), + f"the refusal was not reported: {identity_window.statusBar().currentMessage()!r}", + ) + # A matching calibration still goes through the normal confirmation. + identity_window.sensor_picker.set_sensor_type("box") + with _patch_mismatch.object( + _QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.Yes) + ): + identity_window._on_calibration_apply(str(coefficients)) + check( + stuck_identity.calibration_requests == [str(coefficients)], + f"a matching calibration was not requested: {stuck_identity.calibration_requests}", + ) + + identity_window.close() + app.processEvents() + _shutil.rmtree(record_dir, ignore_errors=True) + + section("display units and time base") + import math as _math + + stuck_units = _StuckWorker() + units_window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + ) + units_window.resize(1440, 880) + units_window.show() + units_window._ensure_workers = lambda: None + units_window._box_worker = stuck_units + units_window._finger_worker = stuck_units + units_window._start_run() + units_window._run_timer.stop() + units_window._set_phase("running") + units_plots = units_window.force_plots + units_window.toolbar.units_combo.setCurrentIndex(0) # newton + units_plots.reset() + wall = 1_700_000_000.0 + for index in range(90): + stuck_units.emit( + _fake_packet(index, timestamp=wall + index / 30.0, mono_t=index / 30.0) + ) + units_window._on_run_tick() + before = units_plots.history.state() + end_before = float(before.times[-1]) if before is not None else -1.0 + check( + before is not None and end_before < 60.0, + f"the live X axis is not session-relative (ends at {end_before})", + ) + for units_index in (1, 0, 1, 0): + units_window.toolbar.units_combo.setCurrentIndex(units_index) + app.processEvents() + after = units_plots.history.state() + check( + after is not None and abs(float(after.times[-1]) - end_before) < 1.0, + "the X axis jumped across a raw/N switch: " + f"{end_before} -> {None if after is None else after.times[-1]}", + ) + check( + units_plots.history.sample_count > 0, + "the rebuilt history lost every sample", + ) + units_window.close() + units_window._box_worker = None + stuck_units.release() + app.processEvents() + + # A device with only its normal-force axis calibrated: FN in newtons, and the + # other two axes explicitly unavailable instead of showing raw counts. + stuck_one = _StuckWorker() + one_window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + ) + one_window.resize(1440, 880) + one_window.show() + one_window._ensure_workers = lambda: None + one_window._box_worker = stuck_one + one_window._finger_worker = stuck_one + one_window._start_run() + one_window._run_timer.stop() + one_window._set_phase("running") + one_window.toolbar.units_combo.setCurrentIndex(0) # newton + one_window.force_plots.reset() + for index in range(10): + stuck_one.emit( + _fake_packet(index, calibrated_axes=("fn",), shear_n=float("nan")) + ) + one_window._on_run_tick() + app.processEvents() + one_plots = one_window.force_plots + check( + one_plots._available == (True, False, False), + f"per-axis availability is wrong: {one_plots._available}", + ) + check( + one_plots.value_labels[0].text() != t("chart.value_unavailable"), + "the calibrated axis was reported as unavailable", + ) + check( + all( + one_plots.value_labels[index].text() == t("chart.value_unavailable") + for index in (1, 2) + ), + "an axis without a curve showed a number in newton mode", + ) + one_state = one_plots.history.state() + check( + one_state is not None + and _math.isfinite(float(one_state.values[:, 0].max())), + "the calibrated axis was not plotted", + ) + check( + one_state is not None + and not any(_math.isfinite(v) for v in one_state.values[:, 1]), + "FX carried a value without a calibration curve", + ) + one_window.close() + one_window._box_worker = None + stuck_one.release() + app.processEvents() + section("session browsing") import numpy as np @@ -1190,6 +1650,31 @@ def main() -> int: all(int(curve.getData()[0].size) == 40 for curve in playback.curves), "replay did not draw every force sample", ) + + # Long-history browsing: an interval read is bounded by the interval, and the + # whole-session envelope is a band rather than every row. + from orisys_app_shared.force.source import RecordingCurveSource + from orisys_app_shared.record.index import RecordingIndex + from orisys_app_shared.record.playback import RecordingReader as _RecordingReader + + reader = _RecordingReader(recording, max_rows=10) + source = RecordingCurveSource(reader) + check(source.kind == "disk", "the replay source is not the recording") + index = reader.index() + check(index.rows == 40, f"the index found {index.rows} rows, not 40") + start, stop = index.row_range(0.5, 1.0) + check( + 0 <= start < stop <= index.rows and (stop - start) <= 20, + f"an interval read spans {stop - start} rows", + ) + interval = source.window(0.5, 1.0, unit="N") + check(0 < len(interval) <= 20, f"the interval read returned {len(interval)} rows") + envelope = source.envelope(buckets=8, unit="N") + check(0 < len(envelope) <= 8, f"the envelope has {len(envelope)} points") + check( + reader.info().total_rows == 40, + f"the recording reports {reader.info().total_rows} rows", + ) check(playback.slider.isEnabled(), "the replay position slider is disabled") check( t("playback.warn_depth_recomputed").split("{")[0].strip()[:4] @@ -1268,6 +1753,92 @@ def main() -> int: playback.close() app.processEvents() + + # The plan must state where to start, which reset it is anchored to and + # whether the state is really recoverable; and a request must belong to the + # recording it started in. + from orisys_app_shared.record.depth import ( + STATE_APPROXIMATE, + STATE_PREROLL, + DepthRecomputer, + ) + from orisys_app_shared.record.playback import RecordingReader + + plan_reader = RecordingReader(recording) + planner = DepthRecomputer(plan_reader) + frames = plan_reader.info().frames + check(bool(frames), "the replay recording has no frame index") + plan = planner.plan_for(frames[10]) + check( + plan.state == STATE_APPROXIMATE and plan.anchor == "segment_start", + f"a recording with no reset/pre-roll is not reported as approximate: {plan.state}", + ) + check( + plan.steps and plan.steps[0].source == plan_reader.segment_path(frames[10].segment), + "the plan does not name the video to read", + ) + check( + "state_approximate" in plan.warnings, + f"the approximate replay is not reported: {plan.warnings}", + ) + # A pre-roll makes the start of a recording reproducible. + session_path = recording / "session.json" + session_doc = _json.loads(session_path.read_text(encoding="utf-8")) + prerecord = recording / "preroll_0001.mp4" + prerecord.write_bytes(b"") + session_doc["preroll_frames"] = 30 + session_doc["preroll_segment"] = prerecord.name + session_path.write_text(_json.dumps(session_doc), encoding="utf-8") + planner.close() + planner = DepthRecomputer(RecordingReader(recording)) + plan = planner.plan_for(planner.reader.info().frames[5]) + check( + plan.state == STATE_PREROLL and plan.preroll == prerecord, + f"the pre-roll was not used for the start of the recording: {plan.state}", + ) + check(plan.frames_to_read == 30 + 6, f"unexpected replay cost: {plan.frames_to_read}") + planner.close() + + # A reset boundary is re-applied, and a request is bound to its recording. + _Blocking.released.clear() + second = PlaybackDialog(theme="dark", directory=recording) + second.show() + app.processEvents() + serial_before = second._record_serial + blocking = _Blocking() + second._recomputer = blocking + second._on_recompute_depth() + check(second._depth_pending_record == serial_before, "the request has no recording id") + check( + second._depth_pending_seq is not None, + "the request carries no sequence number", + ) + # Opening another recording invalidates the running request... + second.open_recording(recording) + check( + second._record_serial == serial_before + 1, + "opening a recording did not invalidate the running request", + ) + check( + second._recomputer is not blocking, + "the invalidated processor was not replaced", + ) + _Blocking.released.set() + for _ in range(40): + app.processEvents() + _time.sleep(0.05) + if not second._depth_busy(): + break + second._poll_depth() + app.processEvents() + pixmap = second.depth_label.pixmap() + check( + pixmap is None or pixmap.isNull(), + "an answer for a previous recording was drawn into the new one", + ) + second.close() + app.processEvents() + _shutil.rmtree(replay_root, ignore_errors=True) print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py index dad3ef9..3da6ec7 100644 --- a/shared/src/orisys_app_shared/acquisition/finger_worker.py +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -14,20 +14,25 @@ from orisys_app_shared.acquisition.constants import ( LOWPASS_CUTOFF_HZ, ) from orisys_app_shared.acquisition.sinks import RecordSinkMixin +# The packet and the geometry come from ``models``, so the finger worker does not +# depend on the box worker; only genuinely shared helpers are imported from it. +from orisys_app_shared.acquisition.models import ( + FixedCameraMode, + SdkPacket, + ensure_bgr as _ensure_bgr, + is_file_source as _is_file_source, + load_fixed_mode, + orisys_importable, +) from orisys_app_shared.acquisition.sdk_worker import ( CAMERA_DISCONNECT_TIMEOUT_S, HANDOVER_TIMEOUT_S, STOP_JOIN_TIMEOUT_S, VIDEO_EOF_TIMEOUT_S, - FixedCameraMode, - SdkPacket, - _ensure_bgr, - _is_file_source, calibration_digest, - load_fixed_mode, loaded_calibration_version, loaded_path, - orisys_importable, + process_chain, read_forces_from_sensor, ) @@ -65,13 +70,17 @@ class FingerAcquisitionWorker(RecordSinkMixin): *, on_error: Optional[Callable[[str], None]] = None, on_status: Optional[Callable[[str], None]] = None, - on_calibration_applied: Optional[Callable[[str, str, int], None]] = None, + on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None, + on_reset_applied: Optional[Callable[[int, float, int], None]] = None, ): self._init_sinks() self._on_error = on_error self._on_status = on_status - #: Called from the acquisition thread once new coefficients are loaded. + #: Called from the acquisition thread once new coefficients are loaded, + #: with ``(path, version, frame_id, mono_t, session_id)``. self._on_calibration_applied = on_calibration_applied + #: Called from the acquisition thread once a queued zeroing took effect. + self._on_reset_applied = on_reset_applied self._thread: Optional[threading.Thread] = None self._stop = threading.Event() self._lock = threading.Lock() @@ -86,6 +95,8 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._end_reason: Optional[str] = None self._phase: str = "idle" self._pending_calibration: Optional[str] = None + #: A zeroing requested from another thread, applied by this one. + self._pending_reset: bool = False self._mono_t0: float = 0.0 self._calibration_version: str = "" @@ -129,6 +140,11 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._latest = None self._end_reason = None self._phase = "connecting" + # This session starts with no calibration of its own: an uncalibrated + # runtime must not inherit the previous session's version, and a stale + # zeroing request must not fire into the new session's frames. + self._calibration_version = "" + self._pending_reset = False self._session_id += 1 session = self._session_id self._stop.clear() @@ -171,22 +187,54 @@ class FingerAcquisitionWorker(RecordSinkMixin): self._phase = "idle" def reset(self) -> None: + """Request a tracker reset, applied by the acquisition thread. + + The runtime's tactile reset from the GUI thread would race the frame the + loop is processing, so the zeroing happens at a frame boundary on the + acquisition thread, which reports the frame and time it took effect. + """ with self._lock: - runtime = self._runtimes.get(self._session_id) - if runtime is None: - raise RuntimeError("Finger runtime is not running") - runtime.tactile.reset() + if self._session_id <= 0: + raise RuntimeError("Finger runtime is not running") + self._pending_reset = True + + def _consume_pending_reset(self, runtime, frame_id: int, mono_t: float) -> None: + """Apply a queued reset and report the frame it actually took effect at.""" + with self._lock: + pending = self._pending_reset + self._pending_reset = False + session = self._session_id + if not pending or runtime is None: + return + tactile = getattr(runtime, "tactile", None) + target = tactile if callable(getattr(tactile, "reset", None)) else None + if target is None: + sensor = getattr(tactile, "raw_sensor", None) + target = sensor if callable(getattr(sensor, "reset", None)) else None + if target is None: + raise RuntimeError("runtime does not support reset") + target.reset() + if self._on_reset_applied is not None: + self._on_reset_applied(int(frame_id), float(mono_t), int(session)) def set_pending_calibration(self, path: str | Path | None) -> None: """Queue a force_calibration.json path for the acquisition thread.""" with self._lock: self._pending_calibration = str(path) if path is not None else None - def _consume_pending_calibration(self, sensor, frame_id: int = 0) -> None: - """Apply queued coefficients and report confirmation, not intent.""" + def _consume_pending_calibration( + self, sensor, frame_id: int = 0, mono_t: float = 0.0 + ) -> None: + """Apply queued coefficients and report confirmation, not intent. + + A rejected document leaves the version alone on purpose: the sensor still + has the coefficients it had. What never happens is inventing a version for + a device that has no calibration — that is the empty string. + """ with self._lock: path = self._pending_calibration self._pending_calibration = None + session = self._session_id if path is None or sensor is None: return if not hasattr(sensor, "set_calibration"): @@ -195,7 +243,9 @@ class FingerAcquisitionWorker(RecordSinkMixin): version = calibration_digest(path) self._calibration_version = version if self._on_calibration_applied is not None: - self._on_calibration_applied(str(path), version, int(frame_id)) + self._on_calibration_applied( + str(path), version, int(frame_id), float(mono_t), int(session) + ) def _set_end_reason(self, reason: str) -> None: with self._lock: @@ -287,12 +337,13 @@ class FingerAcquisitionWorker(RecordSinkMixin): # name those coefficients from the first frame, not the request path. tactile = getattr(runtime, "tactile", None) loaded = loaded_calibration_version(tactile) if tactile else "" - if loaded: - self._calibration_version = loaded - if self._on_calibration_applied is not None: - self._on_calibration_applied( - str(loaded_path(tactile) or ""), loaded, 0 - ) + # Assigned even when empty: an uncalibrated device reports *no* + # calibration, never the previous session's version. + self._calibration_version = loaded + if loaded and self._on_calibration_applied is not None: + self._on_calibration_applied( + str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session) + ) except Exception as exc: self._set_end_reason("error") if self._on_error is not None and session == self._session_id: @@ -341,20 +392,26 @@ class FingerAcquisitionWorker(RecordSinkMixin): none_streak_start = None tactile = runtime.tactile compute_error = "" + # The frame's acquisition time, so an event that happens on this + # frame is dated when it happened rather than when it was queued. + frame_mono = max(0.0, time.perf_counter() - self._mono_t0) try: - self._consume_pending_calibration(tactile, frame_id) + self._consume_pending_calibration(tactile, frame_id, frame_mono) except Exception as exc: # noqa: BLE001 message = f"calibration rejected: {type(exc).__name__}: {exc}" if self._on_error is not None and session == self._session_id: self._on_error(message) compute_error = message try: - tactile.compute_deformation(decompose=True, check_motion=False) - tactile.compute_contact() - except Exception as exc: # noqa: BLE001 - reported on the packet - compute_error = compute_error or ( - f"compute: {type(exc).__name__}: {exc}" - ) + # Applied between two frames on this thread, then reported. + self._consume_pending_reset(runtime, frame_id, frame_mono) + except Exception as exc: # noqa: BLE001 + message = f"reset failed: {type(exc).__name__}: {exc}" + if self._on_error is not None and session == self._session_id: + self._on_error(message) + # The same chain the offline depth replay runs on the tactile. + chain_depth, chain_error = process_chain(tactile) + compute_error = compute_error or chain_error t1 = time.perf_counter() frame_times.append(t1) cutoff = t1 - 1.0 @@ -395,7 +452,11 @@ class FingerAcquisitionWorker(RecordSinkMixin): stale_forces=bool(compute_error), raw_available=bool(forces.get("raw_available", True)), flow=forces["flow"], - depth=forces.get("depth"), + depth=( + forces.get("depth") + if forces.get("depth") is not None + else chain_depth + ), valid_mask=runtime.valid_mask, ) with self._lock: diff --git a/shared/src/orisys_app_shared/acquisition/models.py b/shared/src/orisys_app_shared/acquisition/models.py new file mode 100644 index 0000000..50d7b88 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/models.py @@ -0,0 +1,131 @@ +"""The data shapes shared by the acquisition workers, the record channel and the UI. + +They live here rather than in one of the workers so that neither worker has to +import the other, and so the record channel can describe a packet without +importing a device loop. The workers re-export these names for compatibility. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class FixedCameraMode: + """The camera geometry a session runs with.""" + + width: int + height: int + fps: float + config_ref: str + + def label(self) -> str: + return f"{self.width}x{self.height} @{self.fps:g}" + + +@dataclass +class SdkPacket: + """One acquired frame: the images, the force readout and its provenance.""" + + raw_frame: np.ndarray + processed_frame: np.ndarray + timestamp: float + fps: float + process_ms: float + normal_force: float = 0.0 + shear_x: float = 0.0 + shear_y: float = 0.0 + normal_force_raw: float = 0.0 + shear_x_raw: float = 0.0 + shear_y_raw: float = 0.0 + force_unit: str = "raw" + force_calibrated: bool = False + #: Which axes actually have a calibration curve loaded. The sensor-level + #: ``force_calibrated`` flag cannot express a partially calibrated device. + calibrated_axes: tuple[str, ...] = () + session_id: int = 0 + #: Monotonic per-session frame index; the join key for video, CSV and + #: calibration records. + frame_id: int = 0 + #: Monotonic, session-relative acquisition time. Used for ordering so a wall + #: clock that steps backwards cannot reorder history. ``None`` means the + #: producer did not measure one, and the wall clock is used instead — a + #: default of ``0.0`` would have made every frame look simultaneous. + mono_t: Optional[float] = None + #: Wall clock at hand-off, for metadata only. ``None`` means unmeasured. + wall_t: Optional[float] = None + #: False when the force readout failed. Invalid packets carry NaN forces and + #: must never be averaged into calibration data or written as zeros. + valid: bool = True + invalid_reason: str = "" + #: True when the compute chain failed for this frame. The SDK's force fields + #: then still hold the value computed for an earlier frame, so they must not + #: be recorded as this frame's measurement. + stale_forces: bool = False + #: Content hash of the calibration actually in effect when this frame was + #: acquired; changes mid-session when new coefficients are applied. + calibration_version: str = "" + #: False when the sensor exposes no RAW channels; the raw fields are then + #: NaN rather than a copy of the calibrated values. + raw_available: bool = True + flow: Optional[np.ndarray] = None + depth: Optional[np.ndarray] = None + valid_mask: Optional[np.ndarray] = None + + +def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode: + """Resolve a config reference (a file path or a product name) to its geometry.""" + ref = str(config_ref).strip() + path = Path(ref) + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + resolved = str(path.resolve()) + else: + import orisys + + data = orisys.configs.load_config(ref) + resolved = ref + camera = data.get("camera") or {} + raw = camera.get("resolution_raw") or [1920, 1080] + fps = float(camera.get("fps") or 30.0) + return FixedCameraMode( + width=int(raw[0]), + height=int(raw[1]), + fps=fps, + config_ref=resolved, + ) + + +def ensure_bgr(img: np.ndarray) -> np.ndarray: + """Three-channel BGR view of whatever the sensor hands over.""" + if img.ndim == 2: + return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + if img.ndim == 3 and img.shape[2] == 1: + return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR) + if img.ndim == 3 and img.shape[2] == 4: + return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + return img + + +def is_file_source(video_source: str | int) -> bool: + """True when the source is a file (a replay) rather than a live camera.""" + return isinstance(video_source, str) + + +def orisys_importable() -> tuple[bool, str]: + """Whether the SDK can be imported, with the reason when it cannot.""" + try: + import orisys # noqa: F401 + + return True, getattr(__import__("orisys"), "__version__", "unknown") + except Exception as exc: # noqa: BLE001 + return False, str(exc) diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py index 2908939..17ee9de 100644 --- a/shared/src/orisys_app_shared/acquisition/sdk_worker.py +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -19,6 +19,14 @@ from orisys_app_shared.acquisition.constants import ( FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ, ) +from orisys_app_shared.acquisition.models import ( + FixedCameraMode, + SdkPacket, + ensure_bgr as _ensure_bgr, + is_file_source as _is_file_source, + load_fixed_mode, + orisys_importable, +) from orisys_app_shared.acquisition.sinks import RecordSinkMixin VIDEO_EOF_TIMEOUT_S = 0.5 @@ -29,105 +37,22 @@ STOP_JOIN_TIMEOUT_S = 5.0 #: previous session that is still winding down before opening the device. HANDOVER_TIMEOUT_S = 60.0 +# Re-exported so ``from ...sdk_worker import SdkPacket`` keeps working; the +# definitions live in ``models`` so neither worker depends on the other. +__all__ = [ + "FixedCameraMode", + "SdkPacket", + "SdkAcquisitionWorker", + "calibration_digest", + "load_fixed_mode", + "loaded_calibration_version", + "loaded_path", + "orisys_importable", + "process_chain", + "read_depth", + "read_forces_from_sensor", +] -@dataclass(frozen=True) -class FixedCameraMode: - width: int - height: int - fps: float - config_ref: str - - def label(self) -> str: - return f"{self.width}x{self.height} @{self.fps:g}" - - -@dataclass -class SdkPacket: - raw_frame: np.ndarray - processed_frame: np.ndarray - timestamp: float - fps: float - process_ms: float - normal_force: float = 0.0 - shear_x: float = 0.0 - shear_y: float = 0.0 - normal_force_raw: float = 0.0 - shear_x_raw: float = 0.0 - shear_y_raw: float = 0.0 - force_unit: str = "raw" - force_calibrated: bool = False - #: Which axes actually have a calibration curve loaded. The sensor-level - #: ``force_calibrated`` flag cannot express a partially calibrated device. - calibrated_axes: tuple[str, ...] = () - session_id: int = 0 - #: Monotonic per-session frame index; the join key for video, CSV and - #: calibration records. - frame_id: int = 0 - #: Monotonic, session-relative acquisition time. Used for ordering so a wall - #: clock that steps backwards cannot reorder history. ``None`` means the - #: producer did not measure one, and the wall clock is used instead — a - #: default of ``0.0`` would have made every frame look simultaneous. - mono_t: Optional[float] = None - #: Wall clock at hand-off, for metadata only. ``None`` means unmeasured. - wall_t: Optional[float] = None - #: False when the force readout failed. Invalid packets carry NaN forces and - #: must never be averaged into calibration data or written as zeros. - valid: bool = True - invalid_reason: str = "" - #: True when the compute chain failed for this frame. The SDK's force fields - #: then still hold the value computed for an earlier frame, so they must not - #: be recorded as this frame's measurement. - stale_forces: bool = False - #: Content hash of the calibration actually in effect when this frame was - #: acquired; changes mid-session when new coefficients are applied. - calibration_version: str = "" - #: False when the sensor exposes no RAW channels; the raw fields are then - #: NaN rather than a copy of the calibrated values. - raw_available: bool = True - flow: Optional[np.ndarray] = None - depth: Optional[np.ndarray] = None - valid_mask: Optional[np.ndarray] = None - - -def orisys_importable() -> tuple[bool, str]: - try: - import orisys # noqa: F401 - - return True, getattr(__import__("orisys"), "__version__", "unknown") - except Exception as exc: # noqa: BLE001 - return False, str(exc) - - -def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode: - ref = str(config_ref).strip() - path = Path(ref) - if path.is_file(): - data = json.loads(path.read_text(encoding="utf-8")) - resolved = str(path.resolve()) - else: - import orisys - - data = orisys.configs.load_config(ref) - resolved = ref - camera = data.get("camera") or {} - raw = camera.get("resolution_raw") or [1920, 1080] - fps = float(camera.get("fps") or 30.0) - return FixedCameraMode( - width=int(raw[0]), - height=int(raw[1]), - fps=fps, - config_ref=resolved, - ) - - -def _ensure_bgr(img: np.ndarray) -> np.ndarray: - if img.ndim == 2: - return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) - if img.ndim == 3 and img.shape[2] == 1: - return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR) - if img.ndim == 3 and img.shape[2] == 4: - return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) - return img def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool: @@ -147,6 +72,43 @@ def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> return False +def process_chain(sensor) -> tuple[Optional[np.ndarray], str]: + """Run the live processing chain for one already-read frame. + + ``get_img`` alone is not the pipeline: the SDK's depth map is produced by + ``compute_deformation`` + ``compute_contact``. The offline depth replay calls + this same function, so it cannot drift from what was recorded live. + Returns ``(depth, error)``; the depth is ``None`` when the chain failed or the + sensor exposes none. + """ + compute_error = "" + compute_deformation = getattr(sensor, "compute_deformation", None) + compute_contact = getattr(sensor, "compute_contact", None) + if callable(compute_deformation) and callable(compute_contact): + # Same calls and arguments as the acquisition loop. + try: + compute_deformation(decompose=True, check_motion=False) + compute_contact() + except Exception as exc: # noqa: BLE001 - reported to the caller + compute_error = f"compute: {type(exc).__name__}: {exc}" + else: + compute_error = f"no processing chain on {type(sensor).__name__}" + return read_depth(sensor), compute_error + + +def read_depth(sensor) -> Optional[np.ndarray]: + """The current depth channel of ``sensor``, or ``None`` when unavailable.""" + try: + info = sensor.info + if not hasattr(info, "DEPTH"): + return None + value = sensor.read_info(info.DEPTH) + except Exception: # noqa: BLE001 - reported as "no depth for this frame" + logger.exception("Could not read the depth channel") + return None + return None if value is None else np.asarray(value) + + def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None): """Read one force sample. @@ -284,10 +246,6 @@ def _calibrated_axes(sensor) -> tuple[str, ...]: return tuple(axes) -def _is_file_source(video_source: str | int) -> bool: - return isinstance(video_source, str) - - def calibration_digest(path: str | Path) -> str: """Content hash of a calibration document — the calibration's identity. @@ -352,14 +310,20 @@ class SdkAcquisitionWorker(RecordSinkMixin): *, on_error: Optional[Callable[[str], None]] = None, on_status: Optional[Callable[[str], None]] = None, - on_calibration_applied: Optional[Callable[[str, str, int], None]] = None, + on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None, + on_reset_applied: Optional[Callable[[int, float, int], None]] = None, ): self._init_sinks() self._on_error = on_error self._on_status = on_status #: Called from the acquisition thread once new coefficients are actually - #: loaded, with ``(path, version, frame_id)``. Confirmation, not intent. + #: loaded, with ``(path, version, frame_id, mono_t, session_id)``. + #: Confirmation, not intent — and it carries the frame and time so the + #: recorded event is dated when it happened. self._on_calibration_applied = on_calibration_applied + #: Called from the acquisition thread once a queued zeroing took effect, + #: with ``(frame_id, mono_t, session_id)``. + self._on_reset_applied = on_reset_applied self._thread: Optional[threading.Thread] = None self._stop = threading.Event() self._lock = threading.Lock() @@ -373,6 +337,8 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._end_reason: Optional[str] = None self._phase: str = "idle" self._pending_calibration: Optional[str] = None + #: A zeroing requested from another thread, applied by this one. + self._pending_reset: bool = False #: Monotonic origin for ``SdkPacket.mono_t``, reset per session. self._mono_t0: float = 0.0 #: Content hash of the calibration currently in effect. @@ -418,6 +384,11 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._latest = None self._end_reason = None self._phase = "connecting" + # The previous session's coefficients say nothing about this one: an + # uncalibrated device must not inherit a version it never loaded, and + # an old reset request must not fire into the new session's frames. + self._calibration_version = "" + self._pending_reset = False self._session_id += 1 session = self._session_id self._stop.clear() @@ -463,28 +434,57 @@ class SdkAcquisitionWorker(RecordSinkMixin): self._phase = "idle" def reset(self) -> None: + """Request a tracker reset, applied by the acquisition thread. + + ``Sensor.reset()`` from the GUI thread would race the frame the loop is + processing, so the actual zeroing happens at a frame boundary on the + acquisition thread — which then reports the frame and the time it took + effect. The confirmation callback is ``on_reset_applied``. + """ with self._lock: - sensor = self._sensors.get(self._session_id) - if sensor is None: - raise RuntimeError("Sensor is not running") - sensor.reset() + if self._session_id <= 0: + raise RuntimeError("Sensor is not running") + self._pending_reset = True + + def _consume_pending_reset(self, sensor, frame_id: int, mono_t: float) -> None: + """Apply a queued reset and report the frame it actually took effect at.""" + with self._lock: + pending = self._pending_reset + self._pending_reset = False + session = self._session_id + if not pending or sensor is None: + return + reset = getattr(sensor, "reset", None) + if not callable(reset): + raise RuntimeError("sensor does not support reset") + reset() + if self._on_reset_applied is not None: + self._on_reset_applied(int(frame_id), float(mono_t), int(session)) def set_pending_calibration(self, path: str | Path | None) -> None: """Queue a force_calibration.json path for the acquisition thread.""" with self._lock: self._pending_calibration = str(path) if path is not None else None - def _consume_pending_calibration(self, sensor, frame_id: int = 0) -> None: + def _consume_pending_calibration( + self, sensor, frame_id: int = 0, mono_t: float = 0.0 + ) -> None: """Apply queued coefficients and report **confirmation**, not intent. ``Sensor.set_calibration`` loads with ``strict=True`` and raises on an invalid document, so success is observable. The old code swallowed that exception, which is why the app logged "calibration applied" before anything had actually been loaded. + + A rejection leaves the version alone on purpose: the sensor still has the + coefficients it had, so the previous version is still the true one. What + never happens is inventing a version for a device that has no calibration + — that is the empty string, set when the session opened. """ with self._lock: path = self._pending_calibration self._pending_calibration = None + session = self._session_id if path is None or sensor is None: return if not hasattr(sensor, "set_calibration"): @@ -493,7 +493,9 @@ class SdkAcquisitionWorker(RecordSinkMixin): version = calibration_digest(path) self._calibration_version = version if self._on_calibration_applied is not None: - self._on_calibration_applied(str(path), version, int(frame_id)) + self._on_calibration_applied( + str(path), version, int(frame_id), float(mono_t), int(session) + ) def _set_end_reason(self, reason: str) -> None: with self._lock: @@ -574,12 +576,13 @@ class SdkAcquisitionWorker(RecordSinkMixin): # here. Without this the record would name the path passed on the # command line instead of the calibration that was applied. loaded = loaded_calibration_version(sensor) - if loaded: - self._calibration_version = loaded - if self._on_calibration_applied is not None: - self._on_calibration_applied( - str(loaded_path(sensor) or ""), loaded, 0 - ) + # Assigned even when empty: an uncalibrated device must report *no* + # calibration, never the previous session's version. + self._calibration_version = loaded + if loaded and self._on_calibration_applied is not None: + self._on_calibration_applied( + str(loaded_path(sensor) or ""), loaded, 0, 0.0, int(session) + ) self._status(f"Streaming {mode.label()}") except Exception as exc: self._set_end_reason("error") @@ -627,23 +630,32 @@ class SdkAcquisitionWorker(RecordSinkMixin): none_streak_start = None compute_error = "" + # The frame's acquisition time, so an event that happens on this + # frame is dated when it happened rather than when it was queued. + frame_mono = max(0.0, time.perf_counter() - self._mono_t0) try: # Applying new coefficients is reported separately from the # compute chain: a rejected document is an operator error, # not a numerical failure. - self._consume_pending_calibration(sensor, frame_id) + self._consume_pending_calibration(sensor, frame_id, frame_mono) except Exception as exc: # noqa: BLE001 message = f"calibration rejected: {type(exc).__name__}: {exc}" if self._on_error is not None and session == self._session_id: self._on_error(message) compute_error = message try: - sensor.compute_deformation(decompose=True, check_motion=False) - sensor.compute_contact() - except Exception as exc: # noqa: BLE001 - reported on the packet - compute_error = compute_error or ( - f"compute: {type(exc).__name__}: {exc}" - ) + # The zeroing is applied *here*, between two frames, and only + # then reported — the same confirmation rule as the + # calibration. + self._consume_pending_reset(sensor, frame_id, frame_mono) + except Exception as exc: # noqa: BLE001 + message = f"reset failed: {type(exc).__name__}: {exc}" + if self._on_error is not None and session == self._session_id: + self._on_error(message) + # The same chain the offline depth replay runs. The depth it + # reads is the packet's depth, so the channel is read once. + chain_depth, chain_error = process_chain(sensor) + compute_error = compute_error or chain_error t1 = time.perf_counter() frame_times.append(t1) cutoff = t1 - 1.0 @@ -683,7 +695,7 @@ class SdkAcquisitionWorker(RecordSinkMixin): stale_forces=bool(compute_error), raw_available=bool(forces.get("raw_available", True)), flow=forces["flow"], - depth=forces.get("depth"), + depth=forces.get("depth") if forces.get("depth") is not None else chain_depth, ) with self._lock: self._latest = packet diff --git a/shared/src/orisys_app_shared/force/calibration_io.py b/shared/src/orisys_app_shared/force/calibration_io.py index 6737232..e45e72b 100644 --- a/shared/src/orisys_app_shared/force/calibration_io.py +++ b/shared/src/orisys_app_shared/force/calibration_io.py @@ -8,10 +8,10 @@ from __future__ import annotations import math from copy import deepcopy -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from datetime import datetime from pathlib import Path -from typing import Any, Literal, Mapping +from typing import Any, Iterable, Literal, Mapping from orisys.calibration import ( CalSample as SdkCalSample, @@ -95,8 +95,6 @@ def predict_channel(cal, channel: str, raw: float) -> float: triple = [0.0, 0.0, 0.0] triple[_CONVERT_SLOT[channel]] = float(raw) return float(cal.convert(triple[0], triple[1], triple[2])[_CONVERT_SLOT[channel]]) - - @dataclass class CalSessionStore: """In-memory calibration points for FN / FX / FY.""" @@ -176,12 +174,8 @@ def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]: } -def build_force_calibration_v2( - store: CalSessionStore, - *, - meta: dict[str, Any] | None = None, -) -> tuple[dict[str, Any], list[str]]: - """Build schema v2 JSON. Returns (document, skipped_channel_warnings).""" +def _meta_blocks(meta: dict[str, Any] | None) -> tuple[dict, dict, dict, str]: + """Split a flat export-meta dict into the SDK's identity/pipeline/extra.""" meta = dict(meta or {}) identity = { "sensor_type": meta.get("sensor_type", ""), @@ -194,9 +188,9 @@ def build_force_calibration_v2( "config_ref": meta.get("config_ref"), } extra = { - k: v - for k, v in meta.items() - if k + key: value + for key, value in meta.items() + if key not in ( "sensor_type", "sn", @@ -207,30 +201,255 @@ def build_force_calibration_v2( "samples", ) } + return identity, pipeline, extra, str(meta.get("origin") or "user") + + +def aggregate_samples(rows: Iterable[CalSample]) -> list[CalSample]: + """n-weighted merge of repeated reference loads, sorted by load. + + Sampling the same load twice is normal and deliberate; the model has to see + the combined mean and the scatter *between* the samples, not whichever + sample happened to be last. + """ + merged: list[CalSample] = [] + for sample in rows: + for index, existing in enumerate(merged): + if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS: + merged[index] = merge_samples(existing, sample) + break + else: + merged.append(sample) + merged.sort(key=lambda item: item.weight_n) + return merged + + +def training_samples(points) -> tuple[dict[str, list[CalSample]], list, list]: + """Split points into the training build and the held-out validation set. + + This is the **single** filter: a point that is excluded, invalid or has no + reference load never reaches a model, and a validation point is only ever + measured against one. Callers must not re-implement it. + """ + training: list = [] + validation: list = [] + for point in points or (): + if bool(getattr(point, "excluded", False)): + continue + try: + load = float(point.reference_load_n) + raw_mean = float(point.loaded_stats().mean) + except (AttributeError, TypeError, ValueError): + continue + if not math.isfinite(load) or not math.isfinite(raw_mean): + continue + if bool(getattr(point, "is_validation", False)): + validation.append(point) + else: + training.append(point) + + by_channel: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS} + for point in training: + stats = point.loaded_stats() + std = float(stats.std) + by_channel[str(point.channel)].append( + CalSample( + weight_n=float(point.reference_load_n), + raw_mean=float(stats.mean), + n=int(stats.n or 0), + std=std if math.isfinite(std) else 0.0, + ) + ) + return ( + {key: aggregate_samples(rows) for key, rows in by_channel.items()}, + training, + validation, + ) + + +@dataclass(frozen=True) +class PreparedCalibration: + """One preparation of a calibration session: samples -> model -> diagnostics. + + The diagnostics and the exported document come from *this* object, so the + numbers on screen describe exactly the model that is written to disk. Two + separate preparations (one for the panel, one for the fit) is how the + displayed residual and the applied curve drifted apart in the first place. + """ + + model: Any = None + samples: dict[str, list[CalSample]] = field(default_factory=dict) + training: list = field(default_factory=list) + validation: list = field(default_factory=list) + #: ``id(point)`` -> prediction error at that point (predicted - reference). + residuals: dict[int, float] = field(default_factory=dict) + #: ``id(point)`` -> absolute error of a held-out point. + validation_errors: dict[int, float] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) + meta: dict = field(default_factory=dict) + + @property + def ok(self) -> bool: + return self.model is not None + + def channel_samples(self, key: str) -> list[CalSample]: + return list(self.samples.get(key) or []) + + def method(self, channel: str): + curve = self.curve(channel) + return str(getattr(curve, "method", "") or "") if curve is not None else None + + def breakpoints(self, channel: str) -> int: + curve = self.curve(channel) + return len(getattr(curve, "points", ()) or ()) if curve is not None else 0 + + def curve(self, channel: str): + if self.model is None: + return None + return self.model.get(channel) + + def predict(self, channel: str, raw: float) -> float: + if self.model is None: + return float("nan") + return predict_channel(self.model, channel, raw) + + def points_in_fit(self, channel: str) -> int: + return len(self.samples.get(channel) or []) + + def validation_points(self, channel: str) -> int: + return sum(1 for point in self.validation if point.channel == channel) + + def max_residual(self, channel: str) -> float: + values = [ + abs(value) + for point in self.training + if point.channel == channel + for value in [self.residuals.get(id(point))] + if value is not None and math.isfinite(value) + ] + return max(values) if values else float("nan") + + def max_validation_error(self, channel: str) -> float: + values = [ + value + for point in self.validation + if point.channel == channel + for value in [self.validation_errors.get(id(point))] + if value is not None and math.isfinite(value) + ] + return max(values) if values else float("nan") + + def to_document(self) -> dict[str, Any]: + """The schema-v2 document for this prepared model.""" + if self.model is None: + raise ValueError( + "No complete channels to build (each needs ≥2 points). " + + "; ".join(self.warnings) + ) + return self.model.to_dict() + + +def prepare_from_samples( + samples: dict[str, list[CalSample]], + *, + meta: dict[str, Any] | None = None, +) -> PreparedCalibration: + """Build the model and its diagnostics from prepared training samples. + + Repeated loads are merged here too, so a caller that hands in raw (unmerged) + samples still gets the same model as one that hands in points. + """ + merged = { + key: aggregate_samples(samples.get(key) or []) for key in CHANNEL_KEYS + } + identity, pipeline, extra, origin = _meta_blocks(meta) + warnings: list[str] = [] + model = None try: - cal = sdk_build( - store.to_sdk_samples(), + model = sdk_build( + {key: [sample.to_sdk() for sample in rows] for key, rows in merged.items()}, identity=identity, pipeline=pipeline, - origin=str(meta.get("origin") or "user"), + origin=origin, extra_meta=extra, ) - except Exception as exc: - # Match previous API: raise ValueError with channel warnings when empty. - raise ValueError(str(exc)) from exc + except Exception as exc: # noqa: BLE001 - surfaced as warnings + no model + warnings.append(str(exc)) + if model is not None: + try: + warnings.extend(sdk_check(model).warnings) + except Exception as exc: # noqa: BLE001 + warnings.append(f"check failed: {exc}") + for key in CHANNEL_KEYS: + if not model.has(key): + warnings.append(_missing_channel_message(key, merged.get(key) or [])) + return PreparedCalibration( + model=model, + samples=merged, + warnings=warnings, + meta=dict(meta or {}), + ) - result = sdk_check(cal) - warnings = list(result.warnings) - for key in CHANNEL_KEYS: - if not cal.has(key): - rows = store.channel(key) - if rows: - msg = f"{key}: need at least 2 points (have {len(rows)})" - else: - msg = f"{key}: no samples" - if msg not in warnings: - warnings.append(msg) - return cal.to_dict(), warnings + +def _missing_channel_message(key: str, rows: list[CalSample]) -> str: + if rows: + return f"{key}: need at least 2 points (have {len(rows)})" + return f"{key}: no samples" + + +def prepare_calibration( + points, + *, + meta: dict[str, Any] | None = None, +) -> PreparedCalibration: + """The one entry point: points -> training samples -> model -> diagnostics.""" + samples, training, validation = training_samples(points) + prepared = prepare_from_samples(samples, meta=meta) + residuals: dict[int, float] = {} + for point in training: + predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean)) + if math.isfinite(predicted): + residuals[id(point)] = predicted - float(point.reference_load_n) + validation_errors: dict[int, float] = {} + for point in validation: + predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean)) + if math.isfinite(predicted): + validation_errors[id(point)] = abs(predicted - float(point.reference_load_n)) + return replace( + prepared, + training=list(training), + validation=list(validation), + residuals=residuals, + validation_errors=validation_errors, + ) + + +def build_export_model(samples: dict[str, list[CalSample]]): + """The SDK model for ``samples`` — the same preparation the panel uses.""" + return prepare_from_samples(samples).model + + +def build_force_calibration_v2( + source, + *, + meta: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], list[str]]: + """Serialise the prepared model. Returns (document, warnings). + + ``source`` is a :class:`PreparedCalibration` (the normal path — the document + is exactly the model that was diagnosed) or, for older callers, a + :class:`CalSessionStore`, which is prepared here with ``meta``. + """ + prepared = ( + source + if isinstance(source, PreparedCalibration) + else prepare_from_samples( + {key: source.channel(key) for key in CHANNEL_KEYS}, meta=meta + ) + ) + if prepared.model is None: + # Match the previous API: raise ValueError carrying the channel warnings. + raise ValueError("; ".join(prepared.warnings) or "no complete channels") + return prepared.to_document(), list(prepared.warnings) def write_force_calibration( @@ -245,15 +464,18 @@ def write_force_calibration( def export_force_calibration_v2( - store: CalSessionStore, + source, *, export_dir: str | Path, sensor_type: str, meta: dict[str, Any] | None = None, local_bundle_dir: str | Path | None = None, ) -> tuple[Path, Path | None, list[str]]: - """Write timestamped export and optional local bundle force_calibration.json.""" - doc, warnings = build_force_calibration_v2(store, meta=meta) + """Write timestamped export and optional local bundle force_calibration.json. + + ``source`` is the prepared calibration (preferred) or a session store. + """ + doc, warnings = build_force_calibration_v2(source, meta=meta) if not doc.get("channels"): raise ValueError( "No complete channels to export (each needs ≥2 points). " diff --git a/shared/src/orisys_app_shared/force/calibration_recorder.py b/shared/src/orisys_app_shared/force/calibration_recorder.py index a3558c0..0aeda25 100644 --- a/shared/src/orisys_app_shared/force/calibration_recorder.py +++ b/shared/src/orisys_app_shared/force/calibration_recorder.py @@ -23,123 +23,39 @@ from __future__ import annotations import threading import time -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timezone -from typing import Callable, Literal, Optional +from typing import Callable, Optional import numpy as np -from orisys_app_shared.force.session import NA, SessionSample, summarise +from orisys_app_shared.force.models import ( + CHANNEL_KEYS, + MAX_TRACE_POINTS, + NA, + SAMPLE_FIELDS, + STATUS_CANCELLED, + STATUS_FEW_SAMPLES, + STATUS_OK, + AxisStats, + CalibrationPoint, + ChannelKey, + trace_dt, + trace_from, +) +from orisys_app_shared.force.session import SessionSample, summarise -try: # pragma: no cover - the module is a plain data definition - from orisys_app_shared.force.calibration_session import SAMPLE_FIELDS -except Exception: # noqa: BLE001 - a partial install must not break sampling - SAMPLE_FIELDS = ( - "frame_id", - "t_rel_s", - "fn_raw", - "fx_raw", - "fy_raw", - "fn_n", - "fx_n", - "fy_n", - "valid", - ) - -ChannelKey = Literal["fn", "fx", "fy"] -CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy") - -STATUS_OK = "ok" -STATUS_FEW_SAMPLES = "few_samples" -STATUS_CANCELLED = "cancelled" - - -@dataclass(frozen=True) -class AxisStats: - """Mean / standard deviation / valid count for one axis.""" - - mean: float = NA - std: float = NA - n: int = 0 - - def as_dict(self) -> dict: - return {"mean": self.mean, "std": self.std, "n": self.n} - - -@dataclass -class CalibrationPoint: - """One recorded calibration point, with all three axes captured.""" - - channel: ChannelKey - reference_load_n: float - direction: str = "load" - round_index: int = 0 - note: str = "" - started_at: str = "" - ended_at: str = "" - duration_s: float = 0.0 - requested_duration_s: float = 0.0 - n_total: int = 0 - n_valid: int = 0 - raw: dict[str, AxisStats] = field(default_factory=dict) - newtons: dict[str, AxisStats] = field(default_factory=dict) - status: str = STATUS_OK - #: Downsampled per-axis raw trace, so a row can be inspected after the fact. - trace: dict[str, list[float]] = field(default_factory=dict) - trace_dt_s: float = 0.0 - #: **Audit** series: every sample of the point, in - #: :data:`~orisys_app_shared.force.calibration_session.SAMPLE_FIELDS` order. - #: The trace above is a preview and must never be the only surviving copy. - samples: list[tuple] = field(default_factory=list) - #: Diagnostics, all operator-controlled. Excluded points leave the fit; - #: validation points are held out of it and used to test it. - excluded: bool = False - is_validation: bool = False - #: Filled in by :meth:`CalibrationRecorder.fit`. - residual_n: float = NA - - def loaded_stats(self) -> AxisStats: - return self.raw.get(self.channel, AxisStats()) - - def axis_mean(self, channel: str) -> float: - stats = self.raw.get(channel) - return stats.mean if stats is not None else NA - - def state_key(self) -> str: - if self.excluded: - return "cal.state_excluded" - if self.is_validation: - return "cal.state_validation" - return "cal.state_ok" - - def as_dict(self) -> dict: - return { - "channel": self.channel, - "reference_load_n": self.reference_load_n, - # Only the loaded axis has a known reference load; the other two are - # intentionally absent rather than zero. - "direction": self.direction, - "round_index": self.round_index, - "note": self.note, - "started_at": self.started_at, - "ended_at": self.ended_at, - "duration_s": self.duration_s, - "requested_duration_s": self.requested_duration_s, - "n_total": self.n_total, - "n_valid": self.n_valid, - "status": self.status, - "excluded": self.excluded, - "is_validation": self.is_validation, - "residual_n": self.residual_n, - "raw": {key: stats.as_dict() for key, stats in self.raw.items()}, - "newtons": {key: stats.as_dict() for key, stats in self.newtons.items()}, - # The audit series is part of the point's identity, not a UI extra: - # without it the only surviving copy of the samples is the - # downsampled trace. - "sample_fields": list(SAMPLE_FIELDS), - "n_samples": len(self.samples), - "samples": [list(row) for row in self.samples], - } +# Re-exported: the models live in ``force.models`` so the session file format can +# describe a point without importing the sampling state machine. +__all__ = [ + "AxisStats", + "CalibrationPoint", + "CalibrationRecorder", + "FitResult", + "SAMPLE_FIELDS", + "trace_dt", + "trace_from", +] @dataclass(frozen=True) @@ -177,10 +93,6 @@ class FitResult: } -#: Cap on stored trace samples per axis; peaks are preserved when reducing. -MAX_TRACE_POINTS = 400 - - class CalibrationRecorder: """Owns the calibration sampling lifecycle.""" @@ -358,8 +270,8 @@ class CalibrationRecorder: raw={"fn": axis_stats(2), "fx": axis_stats(3), "fy": axis_stats(4)}, newtons={"fn": axis_stats(5), "fx": axis_stats(6), "fy": axis_stats(7)}, status=STATUS_OK if valid_block.shape[0] >= 2 else STATUS_FEW_SAMPLES, - trace=_trace_from(valid_block), - trace_dt_s=_trace_dt(valid_block), + trace=trace_from(valid_block), + trace_dt_s=trace_dt(valid_block), # Every sample is kept, including the invalid ones: a point that was # mostly invalid is itself a finding. ``samples`` holds the original # tuples so ``frame_id`` stays an integer. @@ -456,94 +368,71 @@ class CalibrationRecorder: with self._lock: return [point for point in self.points if point.channel == channel] - def fit(self, channel: ChannelKey) -> FitResult: + def prepared(self, *, meta: Optional[dict] = None): + """The session prepared into one model plus its diagnostics. + + Everything that shows or writes a calibration goes through this, so the + panel, the fit summary and the exported file cannot disagree about which + points were used. + """ + from orisys_app_shared.force.calibration_io import prepare_calibration + + with self._lock: + points = list(self.points) + return prepare_calibration(points, meta=meta) + + def fit(self, channel: ChannelKey, prepared=None) -> FitResult: """Diagnose the model that export and application would actually use. The residual and validation figures used to come from a private least-squares line while the exported file carried a piecewise curve, so the numbers on screen did not describe the calibration that would be - applied. Both now come from the exported curve. Excluded points are - dropped, validation points are held out of the build and only measured. + applied. Both now come from the one prepared model (see + :func:`~orisys_app_shared.force.calibration_io.prepare_calibration`): + excluded points are dropped, validation points are held out of the build + and only measured, and repeated loads are merged before the build. """ - from orisys_app_shared.force.calibration_io import ( - CalSample, - build_export_model, - predict_channel, - ) + if prepared is None: + prepared = self.prepared() points = self.points_for(channel) - usable = [ - point - for point in points - if not point.excluded and not point.is_validation - and np.isfinite(point.reference_load_n) - and np.isfinite(point.loaded_stats().mean) - ] for point in points: - point.residual_n = NA - if len(usable) < 2: - return FitResult(channel=channel, n_points=len(usable)) + value = prepared.residuals.get(id(point)) + point.residual_n = NA if value is None else float(value) - samples: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS} - for point in usable: - samples[point.channel].append( - CalSample( - weight_n=float(point.reference_load_n), - raw_mean=float(point.loaded_stats().mean), - n=int(point.loaded_stats().n), - std=float(point.loaded_stats().std) - if np.isfinite(point.loaded_stats().std) - else 0.0, - ) - ) - try: - model = build_export_model(samples) - curve = model.get(channel) - except Exception: - # Without a buildable model there is nothing honest to report. - return FitResult(channel=channel, n_points=len(usable)) - if curve is None: - return FitResult(channel=channel, n_points=len(usable)) + curve = prepared.curve(channel) + n_points = prepared.points_in_fit(channel) + if curve is None or n_points < 2: + return FitResult(channel=channel, n_points=n_points) - residuals = [] - for point in usable: - predicted = predict_channel(model, point.channel, point.loaded_stats().mean) - residual = predicted - float(point.reference_load_n) - point.residual_n = float(residual) - residuals.append(residual) - - validation = [ - point - for point in points - if not point.excluded and point.is_validation - and np.isfinite(point.reference_load_n) - and np.isfinite(point.loaded_stats().mean) - ] - validation_error = NA - if validation: - errors = [ - abs( - predict_channel(model, point.channel, point.loaded_stats().mean) - - float(point.reference_load_n) - ) - for point in validation - ] - validation_error = float(max(errors)) - - raws = np.asarray([point.loaded_stats().mean for point in usable], dtype=float) - loads = np.asarray([point.reference_load_n for point in usable], dtype=float) + raws = np.asarray( + [ + float(point.loaded_stats().mean) + for point in prepared.training + if point.channel == channel + ], + dtype=float, + ) + loads = np.asarray( + [ + float(point.reference_load_n) + for point in prepared.training + if point.channel == channel + ], + dtype=float, + ) linear_gain = linear_offset = NA - if not np.allclose(raws, raws[0]): + if raws.size >= 2 and not np.allclose(raws, raws[0]): linear_gain, linear_offset = (float(v) for v in np.polyfit(raws, loads, 1)) return FitResult( channel=channel, - n_points=len(usable), - max_residual_n=float(np.max(np.abs(residuals))), - validation_error_n=validation_error, - validation_points=len(validation), + n_points=n_points, + max_residual_n=prepared.max_residual(channel), + validation_error_n=prepared.max_validation_error(channel), + validation_points=prepared.validation_points(channel), method=str(getattr(curve, "method", "") or ""), - breakpoints=len(getattr(curve, "points", ()) or ()), + breakpoints=prepared.breakpoints(channel), linear_gain=linear_gain, linear_offset=linear_offset, ) @@ -554,37 +443,3 @@ class CalibrationRecorder: self._rounds[channel] = index + 1 return index - -def _trace_from(valid_block: np.ndarray) -> dict[str, list[float]]: - """Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``.""" - if valid_block.size == 0: - return {} - traces: dict[str, list[float]] = {} - for key, column in (("fn", 2), ("fx", 3), ("fy", 4)): - values = valid_block[:, column] - values = values[np.isfinite(values)] - if values.size == 0: - continue - if values.size > MAX_TRACE_POINTS: - # Keep the shape of the trace, including its extremes. - edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int) - reduced = [ - values[start:max(start + 1, stop)] - for start, stop in zip(edges[:-1], edges[1:]) - ] - values = np.asarray( - [ - bucket[int(np.argmax(np.abs(bucket)))] - for bucket in reduced - if bucket.size - ] - ) - traces[key] = [float(value) for value in values] - return traces - - -def _trace_dt(valid_block: np.ndarray) -> float: - if valid_block.shape[0] < 2: - return 0.0 - span = float(valid_block[-1, 1] - valid_block[0, 1]) - return span / max(1, valid_block.shape[0] - 1) diff --git a/shared/src/orisys_app_shared/force/calibration_session.py b/shared/src/orisys_app_shared/force/calibration_session.py index 7b27c2a..ea5841b 100644 --- a/shared/src/orisys_app_shared/force/calibration_session.py +++ b/shared/src/orisys_app_shared/force/calibration_session.py @@ -23,23 +23,19 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable, Optional +from orisys_app_shared.force.models import ( + SAMPLE_FIELDS, + AxisStats, + CalibrationPoint, + trace_dt, + trace_from, +) + logger = logging.getLogger(__name__) #: Format version of the calibration session document. SESSION_FORMAT_VERSION = "1.0" SESSION_REQUIRED = ("format", "format_version", "created_at", "points") -#: Columns of each sample row inside a point, in order. -SAMPLE_FIELDS = ( - "frame_id", - "t_rel_s", - "fn_raw", - "fx_raw", - "fy_raw", - "fn_n", - "fx_n", - "fy_n", - "valid", -) POINT_REQUIRED = ("channel", "reference_load_n") @@ -59,6 +55,29 @@ class CalibrationSessionRecord: def ok(self) -> bool: return bool(self.points) and not self.warnings + def identity(self) -> dict: + """The device this session belongs to. + + A calibration record is evidence about *one* device: its sensor type, its + serial number and the configuration it was taken with. Reopening it must + not restamp it with whatever device happens to be connected now. + """ + return { + "sensor_type": str(self.meta.get("sensor_type") or ""), + "sn": str(self.meta.get("sn") or ""), + "config_ref": str(self.meta.get("config_ref") or ""), + "created_at": self.created_at, + } + + def describe(self) -> str: + identity = self.identity() + parts = [identity["sensor_type"] or "?"] + if identity["sn"]: + parts.append(f"SN {identity['sn']}") + if identity["config_ref"]: + parts.append(identity["config_ref"]) + return " / ".join(parts) + def _jsonable(value: Any) -> Any: """NaN/Inf are not valid JSON; they are written as ``null``.""" @@ -147,11 +166,6 @@ def read_calibration_session(path: str | Path) -> CalibrationSessionRecord: if version != SESSION_FORMAT_VERSION: warnings.append(f"format_version {version or '?'} is not {SESSION_FORMAT_VERSION}") - from orisys_app_shared.force.calibration_recorder import ( - CalibrationPoint, - AxisStats, - ) - points = [] for index, raw in enumerate(data.get("points") or []): if not isinstance(raw, dict): @@ -228,9 +242,39 @@ def _point_from_dict(raw: dict, CalibrationPoint, AxisStats): values = [float("nan") if value is None else value for value in row] rows.append(tuple(values)) point.samples = rows + # The full series is the audit data, so everything a diagnostic view needs is + # rebuilt from it: the per-axis trace, its sample interval and, when the + # stored duration is missing, the real sampling span. + restore_diagnostics(point) return point +def restore_diagnostics(point) -> None: + """Rebuild the three-axis trace and the sampling span from the samples.""" + if not point.samples: + return + try: + import numpy as np + except Exception: # noqa: BLE001 - a partial install must still reopen points + return + + width = max(len(row) for row in point.samples) + padded = [tuple(row) + (float("nan"),) * (width - len(row)) for row in point.samples] + block = np.asarray(padded, dtype=np.float64) + if block.shape[1] < len(SAMPLE_FIELDS): + return + valid = block[:, 8] > 0.5 + valid_block = block[valid] + if not point.trace and valid_block.size: + point.trace = trace_from(valid_block) + if not point.trace_dt_s and valid_block.size: + point.trace_dt_s = trace_dt(valid_block) + if not point.duration_s and block.shape[0] > 1: + span = float(block[-1, 1] - block[0, 1]) + if span == span and span >= 0.0: + point.duration_s = span + + def _optional_float(value: Any) -> float: if value is None: return float("nan") diff --git a/shared/src/orisys_app_shared/force/models.py b/shared/src/orisys_app_shared/force/models.py new file mode 100644 index 0000000..aa8527e --- /dev/null +++ b/shared/src/orisys_app_shared/force/models.py @@ -0,0 +1,170 @@ +"""The calibration data shapes, independent of sampling and of file storage. + +They live here so the sampling state machine and the session file format can both +describe a calibration point without importing each other. ``calibration_recorder`` +and ``calibration_session`` re-export these names for compatibility. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +import numpy as np + +#: Stands in for "this value is not available". +NA = float("nan") + +ChannelKey = Literal["fn", "fx", "fy"] +CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy") + +STATUS_OK = "ok" +STATUS_FEW_SAMPLES = "few_samples" +STATUS_CANCELLED = "cancelled" + +#: Columns of one audit sample row, in order. A calibration record is only +#: auditable if this layout is fixed: it is what a reopened point is rebuilt from. +SAMPLE_FIELDS = ( + "frame_id", + "t_rel_s", + "fn_raw", + "fx_raw", + "fy_raw", + "fn_n", + "fx_n", + "fy_n", + "valid", +) + +#: Index of each sample column, so callers do not hard-code positions. +SAMPLE_INDEX = {name: index for index, name in enumerate(SAMPLE_FIELDS)} + +#: Cap on stored trace samples per axis; peaks are preserved when reducing. +MAX_TRACE_POINTS = 400 + + +@dataclass(frozen=True) +class AxisStats: + """Mean / standard deviation / valid count for one axis.""" + + mean: float = NA + std: float = NA + n: int = 0 + + def as_dict(self) -> dict: + return {"mean": self.mean, "std": self.std, "n": self.n} + + +@dataclass +class CalibrationPoint: + """One recorded calibration point, with all three axes captured.""" + + channel: ChannelKey + reference_load_n: float + direction: str = "load" + round_index: int = 0 + note: str = "" + started_at: str = "" + ended_at: str = "" + duration_s: float = 0.0 + requested_duration_s: float = 0.0 + n_total: int = 0 + n_valid: int = 0 + raw: dict[str, AxisStats] = field(default_factory=dict) + newtons: dict[str, AxisStats] = field(default_factory=dict) + status: str = STATUS_OK + #: Downsampled per-axis raw trace, so a row can be inspected after the fact. + trace: dict[str, list[float]] = field(default_factory=dict) + trace_dt_s: float = 0.0 + #: **Audit** series: every sample of the point, in :data:`SAMPLE_FIELDS` order. + #: The trace above is a preview and must never be the only surviving copy. + samples: list[tuple] = field(default_factory=list) + #: Diagnostics, all operator-controlled. Excluded points leave the fit; + #: validation points are held out of it and used to test it. + excluded: bool = False + is_validation: bool = False + #: Filled in by :meth:`CalibrationRecorder.fit`. + residual_n: float = NA + + def loaded_stats(self) -> AxisStats: + return self.raw.get(self.channel, AxisStats()) + + def axis_mean(self, channel: str) -> float: + stats = self.raw.get(channel) + return stats.mean if stats is not None else NA + + def state_key(self) -> str: + if self.excluded: + return "cal.state_excluded" + if self.is_validation: + return "cal.state_validation" + return "cal.state_ok" + + def as_dict(self) -> dict: + return { + "channel": self.channel, + "reference_load_n": self.reference_load_n, + # Only the loaded axis has a known reference load; the other two are + # intentionally absent rather than zero. + "direction": self.direction, + "round_index": self.round_index, + "note": self.note, + "started_at": self.started_at, + "ended_at": self.ended_at, + "duration_s": self.duration_s, + "requested_duration_s": self.requested_duration_s, + "n_total": self.n_total, + "n_valid": self.n_valid, + "status": self.status, + "excluded": self.excluded, + "is_validation": self.is_validation, + "residual_n": self.residual_n, + "raw": {key: stats.as_dict() for key, stats in self.raw.items()}, + "newtons": {key: stats.as_dict() for key, stats in self.newtons.items()}, + # The audit series is part of the point's identity, not a UI extra: + # without it the only surviving copy of the samples is the + # downsampled trace. + "sample_fields": list(SAMPLE_FIELDS), + "n_samples": len(self.samples), + "samples": [list(row) for row in self.samples], + } + + +def trace_from(valid_block: np.ndarray) -> dict[str, list[float]]: + """Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``. + + One downsampling rule, whether the point was just recorded or read back from + a saved session. + """ + if valid_block.size == 0: + return {} + traces: dict[str, list[float]] = {} + for key, column in (("fn", 2), ("fx", 3), ("fy", 4)): + values = valid_block[:, column] + values = values[np.isfinite(values)] + if values.size == 0: + continue + if values.size > MAX_TRACE_POINTS: + # Keep the shape of the trace, including its extremes. + edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int) + reduced = [ + values[start:max(start + 1, stop)] + for start, stop in zip(edges[:-1], edges[1:]) + ] + values = np.asarray( + [ + bucket[int(np.argmax(np.abs(bucket)))] + for bucket in reduced + if bucket.size + ] + ) + traces[key] = [float(value) for value in values] + return traces + + +def trace_dt(valid_block: np.ndarray) -> float: + """The sample interval of a trace, from the block it was taken from.""" + if valid_block.shape[0] < 2: + return 0.0 + span = float(valid_block[-1, 1] - valid_block[0, 1]) + return span / max(1, valid_block.shape[0] - 1) diff --git a/shared/src/orisys_app_shared/force/session.py b/shared/src/orisys_app_shared/force/session.py index f5eac44..726cece 100644 --- a/shared/src/orisys_app_shared/force/session.py +++ b/shared/src/orisys_app_shared/force/session.py @@ -29,6 +29,8 @@ from typing import Iterable, Iterator, Optional import numpy as np +from orisys_app_shared.record.sample import plottable_axis_mask + #: Stands in for "this value is not available / not valid". NA = float("nan") @@ -127,14 +129,20 @@ class SessionSlice: def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """``(times, values, mask)`` for drawing, under one shared rule. - A point is drawn only when the sample is **valid** *and* the value for the - requested unit is finite. Filtering on finiteness alone let a stale - number — left behind by a failed compute step — reappear in one view - after another view had already excluded it. + ``mask`` is per axis ``(n, 3)``: a slice is drawn only where the sample is + **valid** *and that axis* is finite. Filtering on finiteness alone let a + stale number — left behind by a failed compute step — reappear in one view + after another view had already excluded it, and an all-axes rule hid a + valid FN whenever the device had no shear calibration. """ values = self.value_for_unit(unit) - mask = self.valid & np.isfinite(values).all(axis=1) - return self.times[mask], values[mask], mask + return self.times, values, plottable_axis_mask(values, self.valid) + + def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]: + """``(times, values)`` for one channel, with the other axes ignored.""" + values = self.value_for_unit(unit) + mask = plottable_axis_mask(values, self.valid)[:, index] + return self.times[mask], values[mask, index] @dataclass(frozen=True) diff --git a/shared/src/orisys_app_shared/force/source.py b/shared/src/orisys_app_shared/force/source.py new file mode 100644 index 0000000..8b1192b --- /dev/null +++ b/shared/src/orisys_app_shared/force/source.py @@ -0,0 +1,228 @@ +"""One interface for reading curve data, whatever it is stored in. + +The live charts read a bounded in-memory session store; the replay reads a +recording on disk. Both have to answer the same questions — what is the extent, +what is in this interval, what does the whole session look like as an envelope — +so the plotting code asks a *source* instead of knowing which one it has. + +A source never hands over more than the caller's point budget, and it always +reports what it had to do to fit: ``decimated`` means the values are a view, not +every sample. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + +import numpy as np + +from orisys_app_shared.force.session import NA, SessionSlice + + +@dataclass(frozen=True) +class SourceStats: + """What the selection readout shows, for any source.""" + + duration_s: float = 0.0 + samples: int = 0 + peaks: tuple[float, float, float] = (NA, NA, NA) + means: tuple[float, float, float] = (NA, NA, NA) + stds: tuple[float, float, float] = (NA, NA, NA) + + +@dataclass +class SourceWindow: + """Rows for one interval, in the shape the charts already consume.""" + + times: np.ndarray + values: np.ndarray + valid: np.ndarray + frame_ids: np.ndarray + unit: str = "raw" + decimated: bool = False + + def __len__(self) -> int: + return int(self.times.size) + + def plottable_axis(self, index: int): + from orisys_app_shared.record.sample import plottable_axis_mask + + mask = plottable_axis_mask(self.values, self.valid)[:, index] + return self.times[mask], self.values[mask, index] + + def statistics(self) -> SourceStats: + mask = np.isfinite(self.values) & self.valid[:, None] + peaks: list[float] = [] + means: list[float] = [] + stds: list[float] = [] + for index in range(3): + column = self.values[:, index][mask[:, index]] + if column.size == 0: + peaks.append(NA) + means.append(NA) + stds.append(NA) + continue + peaks.append(float(np.nanmax(column))) + means.append(float(np.nanmean(column))) + stds.append(float(np.nanstd(column))) + span = float(self.times[-1] - self.times[0]) if self.times.size > 1 else 0.0 + return SourceStats( + duration_s=span, + samples=int(mask.any(axis=1).sum()), + peaks=tuple(peaks), + means=tuple(means), + stds=tuple(stds), + ) + + +class CurveSource(Protocol): + """What the charts need from wherever the data lives.""" + + kind: str + + def extent(self) -> tuple[float, float]: ... + + def window(self, t0: float, t1: float, *, max_points: int | None = None) -> SourceWindow: ... + + def envelope(self, *, buckets: int = 1200) -> SourceWindow: ... + + def duration_s(self) -> float: ... + + +class StoreCurveSource: + """The live session store: already in memory, bounded by its own blocks.""" + + kind = "memory" + + def __init__(self, store=None) -> None: + self.store = store + + def set_store(self, store) -> None: + self.store = store + + def extent(self) -> tuple[float, float]: + if self.store is None: + return (0.0, 0.0) + return self.store.extent() + + def duration_s(self) -> float: + if self.store is None: + return 0.0 + first, last = self.store.extent() + return max(0.0, last - first) + + def _slice(self, t0: float, t1: float, max_points: int | None, unit: str) -> SessionSlice: + if self.store is None: + empty = np.empty(0) + return SessionSlice( + times=empty, + raw=np.empty((0, 3)), + newtons=np.empty((0, 3)), + valid=np.empty(0, dtype=bool), + frame_ids=np.empty(0, dtype=np.int64), + ) + return self.store.visible( + t0, t1, max_points=max_points or 0, unit=unit + ) + + def window( + self, + t0: float, + t1: float, + *, + max_points: int | None = None, + unit: str = "raw", + ) -> SourceWindow: + slice_ = self._slice(t0, t1, max_points, unit) + return SourceWindow( + times=slice_.times, + values=slice_.value_for_unit(unit), + valid=slice_.valid, + frame_ids=slice_.frame_ids, + unit=unit, + decimated=bool(max_points and slice_.times.size >= int(max_points)), + ) + + def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow: + """The whole session, decimated — the store's own bounded view.""" + if self.store is None: + return self.window(0.0, 0.0) + first, last = self.store.extent() + return self.window(first, last, max_points=int(buckets) * 2, unit=unit) + + def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats: + if self.store is None: + return SourceStats() + stats = self.store.statistics(t0, t1, unit=unit) + return SourceStats( + duration_s=stats.duration_s, + samples=stats.samples, + peaks=stats.peaks, + means=stats.means, + stds=stats.stds, + ) + + +class RecordingCurveSource: + """A recording on disk: interval reads plus a cached envelope.""" + + kind = "disk" + + def __init__(self, reader) -> None: + self.reader = reader + + def extent(self) -> tuple[float, float]: + return self.reader.index().extent() + + def duration_s(self) -> float: + return self.reader.index().duration_s() + + def window( + self, + t0: float, + t1: float, + *, + max_points: int | None = None, + unit: str = "raw", + ) -> SourceWindow: + window = self.reader.window(t0, t1, unit=unit, max_points=max_points) + return SourceWindow( + times=window.times, + values=window.value_for_unit(unit), + valid=window.valid, + frame_ids=window.frame_ids, + unit=unit, + decimated=window.decimated, + ) + + def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow: + """The whole-session envelope, from the index's cached overview.""" + overview = self.reader.overview(unit=unit, buckets=int(buckets)) + keep = ~np.isnan(overview.times) + times = overview.times[keep] + if times.size == 0: + return self.window(0.0, 0.0, unit=unit) + # The envelope is drawn as a band: the low and high of each bucket. It is + # reported as a plain window so callers need only one shape. + values = np.where( + np.isnan(overview.highs[keep]), overview.lows[keep], overview.highs[keep] + ) + return SourceWindow( + times=times, + values=values, + valid=np.ones(times.size, dtype=bool), + frame_ids=np.full(times.size, -1, dtype=np.int64), + unit=unit, + decimated=True, + ) + + def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats: + stats = self.reader.statistics(t0, t1, unit=unit) + return SourceStats( + duration_s=stats.duration_s, + samples=stats.samples, + peaks=stats.peaks, + means=stats.means, + stds=stats.stds, + ) diff --git a/shared/src/orisys_app_shared/force/units.py b/shared/src/orisys_app_shared/force/units.py index 10bcd34..8f26eb1 100644 --- a/shared/src/orisys_app_shared/force/units.py +++ b/shared/src/orisys_app_shared/force/units.py @@ -1,46 +1,104 @@ -"""Force unit selection for display.""" +"""Force unit selection for display. + +The decision is made **per axis** from the standardised measurement +(:class:`~orisys_app_shared.record.sample.RecordSample`), not from a device-level +``force_calibrated`` flag. A device can legitimately have only its normal-force +axis calibrated: the old rule then either showed every axis in raw (losing the +one axis that really was in newtons) or, worse, showed raw counts in the newton +panels. An axis without a curve is now simply **unavailable** in newton mode and +reported as such. +""" from __future__ import annotations from dataclasses import dataclass +from orisys_app_shared.record.sample import AXES + +#: Index of each axis inside the triplets, matching ``AXES``. +AXIS_INDEX: dict[str, int] = {axis: index for index, axis in enumerate(AXES)} + @dataclass(frozen=True) class DisplayForces: - fn: float - fx: float - fy: float + """What the live view should show for one sample.""" + + #: One value per axis, in ``AXES`` order. NaN means "not available". + values: tuple[float, float, float] + #: The unit the values actually are (``N`` only when they are newtons). unit: str + #: Per axis: is this value a real measurement in ``unit``? + available: tuple[bool, bool, bool] + #: The requested unit was newton but no axis had a curve, so raw is shown. using_raw_fallback: bool + #: What the operator asked for (``N``/``raw``), for the fallback message. + requested_unit: str + + def value(self, axis: str) -> float: + return self.values[AXIS_INDEX[axis]] + + def is_available(self, axis: str) -> bool: + return self.available[AXIS_INDEX[axis]] + + def unavailable_axes(self) -> tuple[str, ...]: + return tuple( + axis for axis in AXES if not self.available[AXIS_INDEX[axis]] + ) -def select_display_forces( - *, - units_pref: str, - force_calibrated: bool, - fn: float, - fx: float, - fy: float, - fn_raw: float, - fx_raw: float, - fy_raw: float, -) -> DisplayForces: - """Return values for plots/labels given newton/raw preference.""" +def _finite(value: float) -> bool: + try: + return value == value and value not in (float("inf"), float("-inf")) + except TypeError: # pragma: no cover - defensive + return False + + +def select_display_forces(*, units_pref: str, sample) -> DisplayForces: + """Pick the values to display for one standardised measurement. + + ``sample`` is a + :class:`~orisys_app_shared.record.sample.RecordSample`, so the same conversion + that feeds the CSV and the session store also decides what the live charts + show: there is no second interpretation of what a value means. + """ want_newton = str(units_pref or "newton").lower().startswith("n") - if want_newton and force_calibrated: - return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False) - if want_newton and not force_calibrated: + raw = tuple(sample.raw_for(axis) for axis in AXES) + newton = tuple(sample.newton_for(axis) for axis in AXES) + requested = "N" if want_newton else "raw" + + if not want_newton: return DisplayForces( - fn=fn_raw, - fx=fx_raw, - fy=fy_raw, + values=raw, unit="raw", + available=tuple(_finite(value) for value in raw), + using_raw_fallback=False, + requested_unit=requested, + ) + + values: list[float] = [] + available: list[bool] = [] + for index, _axis in enumerate(AXES): + if _finite(newton[index]): + values.append(newton[index]) + available.append(True) + else: + # Not "zero" and not the raw count: there is no newton value at all. + values.append(float("nan")) + available.append(False) + + if not any(available): + # Nothing is calibrated: showing raw is honest as long as it is labelled. + return DisplayForces( + values=raw, + unit="raw", + available=tuple(_finite(value) for value in raw), using_raw_fallback=True, + requested_unit=requested, ) return DisplayForces( - fn=fn_raw, - fx=fx_raw, - fy=fy_raw, - unit="raw", + values=tuple(values), + unit="N", + available=tuple(available), using_raw_fallback=False, + requested_unit=requested, ) diff --git a/shared/src/orisys_app_shared/record/__init__.py b/shared/src/orisys_app_shared/record/__init__.py index 9bdbd9b..1f3e13b 100644 --- a/shared/src/orisys_app_shared/record/__init__.py +++ b/shared/src/orisys_app_shared/record/__init__.py @@ -1,5 +1,9 @@ """Experiment recording (video + data + metadata) and replay.""" +from orisys_app_shared.record.calibration_snapshot import ( + list_calibration_snapshots, + store_calibration_snapshot, +) from orisys_app_shared.record.depth import DepthRecomputer, DepthResult from orisys_app_shared.record.experiment import ( EVENTS_HEADER, @@ -34,5 +38,7 @@ __all__ = [ "RecordingInfo", "RecordingReader", "format_value", + "list_calibration_snapshots", "list_recordings", + "store_calibration_snapshot", ] \ No newline at end of file diff --git a/shared/src/orisys_app_shared/record/calibration_snapshot.py b/shared/src/orisys_app_shared/record/calibration_snapshot.py new file mode 100644 index 0000000..d7eff92 --- /dev/null +++ b/shared/src/orisys_app_shared/record/calibration_snapshot.py @@ -0,0 +1,60 @@ +"""Store the coefficients a recording was made with, next to the recording. + +The hash of a calibration tells you *which* coefficients were applied; it does not +let you check what they were, and the original export file can be overwritten or +deleted afterwards. So the recording keeps its own copy: + +``` +outputs/recordings/_session/ + calibration/ + force_calibration_.json +``` + +The copy is content-addressed by the digest, so applying the same calibration +twice in one session stores one file, and two different calibrations can never +collide. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +logger = logging.getLogger(__name__) + +#: Directory inside a recording that holds the coefficient snapshots. +SNAPSHOT_DIRNAME = "calibration" + + +def snapshot_name(version: str, source: Path) -> str: + """The snapshot's file name: content-addressed, with the original extension.""" + digest = str(version or "").strip() + stem = f"force_calibration_{digest[:12]}" if digest else f"force_calibration_{source.stem}" + return f"{stem}{source.suffix or '.json'}" + + +def store_calibration_snapshot(directory: str | Path, source: str | Path, version: str) -> str: + """Copy ``source`` into ``directory/calibration``. Returns the relative name. + + Raises on I/O failure; the caller reports it and keeps recording. + """ + base = Path(directory) / SNAPSHOT_DIRNAME + base.mkdir(parents=True, exist_ok=True) + origin = Path(source) + target = base / snapshot_name(version, origin) + if not target.is_file() or target.stat().st_size != origin.stat().st_size: + # Same content-addressed name means the same coefficients; a size check + # is enough to notice a truncated previous copy. + shutil.copy2(origin, target) + return f"{SNAPSHOT_DIRNAME}/{target.name}" + + +def list_calibration_snapshots(directory: str | Path) -> list[str]: + """Every snapshot already stored in a recording, sorted.""" + base = Path(directory) / SNAPSHOT_DIRNAME + if not base.is_dir(): + return [] + return sorted( + f"{SNAPSHOT_DIRNAME}/{path.name}" for path in base.iterdir() if path.is_file() + ) diff --git a/shared/src/orisys_app_shared/record/depth.py b/shared/src/orisys_app_shared/record/depth.py index fe4ba4d..6ec93e0 100644 --- a/shared/src/orisys_app_shared/record/depth.py +++ b/shared/src/orisys_app_shared/record/depth.py @@ -25,6 +25,7 @@ from typing import Optional import numpy as np +from orisys_app_shared.record.depth_backends import open_replay from orisys_app_shared.record.playback import RecordingReader logger = logging.getLogger(__name__) @@ -40,25 +41,72 @@ class DepthResult: t_rel_s: float = 0.0 #: What a replay could not reproduce exactly (never silently empty). warnings: tuple[str, ...] = () + #: Which recording this answer belongs to (the replay window compares it). + record_id: str = "" @property def ok(self) -> bool: return self.depth is not None and not self.error +#: The replay started at a **reset**: the algorithm's state was cleared there, so +#: the frames since that point determine it exactly. +STATE_EXACT = "exact" +#: The replay is preceded by the recorded pre-roll: the intended reconstruction. +STATE_PREROLL = "preroll" +#: Neither: the state entering the target frame is unknown and may differ from +#: what the live run had. +STATE_APPROXIMATE = "approximate" + + +@dataclass(frozen=True) +class ReplayStep: + """One leg of a replay: read ``source`` up to ``to_frame``. + + ``reset_after`` is the frame at which the algorithm's state was cleared live + (an operator ``zeroing``). The replay reaches that frame, then calls the + processor's ``reset()`` — which is what the live run did — and keeps reading. + """ + + source: Path + to_frame: int + reset_after: Optional[int] = None + + +#: How many SDK reads a faithful replay may cost before it is declared +#: unrecoverable (5 minutes at 30 fps). A replay that re-reads an hour of video +#: to answer one cursor move is not a feature; saying so is. +MAX_REPLAY_FRAMES = 9000 + + @dataclass(frozen=True) class ReplayPlan: """Where the SDK must start reading to reproduce one frame's state.""" segment: Path - #: Frame within ``segment`` to step to (1-based, as the SDK reads them). - stop_at: int - #: Frames to feed before ``stop_at`` to rebuild the algorithm's state. + #: The legs of the replay, in order. The last one ends at ``stop_at``. + steps: tuple[ReplayStep, ...] = () + #: Frame within the final segment the caller asked for (1-based). + stop_at: int = 0 + #: Frames to feed from another file before the first step. preroll: Path | None = None preroll_frames: int = 0 + #: The ``zeroing`` frame the plan is anchored to, when there is one. + reset_frame_id: Optional[int] = None + #: ``reset`` / ``preroll`` / ``segment_start``. + anchor: str = "segment_start" + #: :data:`STATE_EXACT`, :data:`STATE_PREROLL` or :data:`STATE_APPROXIMATE`. + state: str = STATE_APPROXIMATE warnings: tuple[str, ...] = () error: str = "" + @property + def frames_to_read(self) -> int: + """How many SDK reads a faithful recomputation costs.""" + return int(self.preroll_frames) + sum( + max(0, int(step.to_frame)) for step in self.steps + ) + class DepthRecomputer: """Recompute depth maps for one recording, on demand.""" @@ -69,6 +117,12 @@ class DepthRecomputer: self._segment: Optional[Path] = None #: How many frames the open sensor has already consumed in ``_segment``. self._position = 0 + #: The depth of ``_position``, so asking for the same frame twice is a + #: cache hit instead of a second (impossible) read. + self._last_depth: Optional[np.ndarray] = None + #: True when an earlier frame had to be re-read without a reset boundary + #: to clean up after it, so the state may not match the live run. + self._rewound_without_anchor = False self.last_error = "" self.last_warnings: list[str] = [] @@ -121,46 +175,87 @@ class DepthRecomputer: """Decide where replay must start so ``frame`` is reproducible. The SDK's depth is the output of a stateful chain (optical-flow tracker, - temporal filters), so a frame cannot be recomputed in isolation. Two - things make the state recoverable: + temporal filters), so a frame cannot be recomputed in isolation. What the + plan states explicitly: - * an operator **reset** clears the tracker, so replaying from the reset - frame reproduces the state exactly — no earlier history needed; - * the **pre-roll** recorded with the session holds the frames acquired - just before recording started, which is the only history a reset-free - start has. + * **where** to start (``start_at``, plus a pre-roll file when there is one); + * which **reset boundary** the replay is anchored to, if any; + * whether the state is actually recoverable — :data:`STATE_EXACT` (the + replay starts at a reset), :data:`STATE_PREROLL` (the recorded pre-roll + precedes it) or :data:`STATE_APPROXIMATE` (neither: the depth map is + still produced, and the caller is told it may differ from live). - Anything else is replayed as faithfully as possible *and reported*: the - caller is told the state may differ instead of being shown a depth map - that pretends to be the live one. + Finding a reset event is **not** by itself a recovered state: the reset + frame has to exist in the index *and* in the video, and it has to be in + the same segment, because the SDK cannot carry its state across a segment + boundary. """ warnings: list[str] = [] segment_path = self.reader.segment_path(frame.segment) if segment_path is None: - return ReplayPlan( - segment=Path(), stop_at=0, error="segment_missing" - ) + return ReplayPlan(segment=Path(), error="segment_missing") + stop_at = max(1, int(frame.segment_frame)) reset_frame = self.reader.last_reset_frame(frame.frame_id) if reset_frame is not None: origin = self.reader.frame_of(reset_frame) - if origin is not None and origin.segment == frame.segment: - # Exact: the tracker state at ``frame`` is fully determined by - # the frames since the reset in this same segment. - return ReplayPlan( + if origin is None: + warnings.append("reset_frame_not_indexed") + elif origin.dropped or not origin.in_video: + # The boundary frame is not in the video, so the state at that + # point cannot be rebuilt: replay from here is an approximation. + warnings.append("reset_frame_not_in_video") + elif origin.segment == frame.segment: + plan = ReplayPlan( segment=segment_path, - stop_at=max(1, int(frame.segment_frame)), - warnings=(), + steps=( + ReplayStep( + source=segment_path, + to_frame=stop_at, + reset_after=int(origin.segment_frame), + ), + ), + stop_at=stop_at, + reset_frame_id=int(reset_frame), + anchor="reset", + state=STATE_EXACT, ) - if origin is not None: - # The reset is in an earlier segment. The SDK opens one video per - # sensor, so the state cannot be carried across the boundary; the - # replay restarts at this segment and says so. - warnings.append("state_across_segment") + if plan.frames_to_read > MAX_REPLAY_FRAMES: + warnings.append("state_too_far_back") + else: + return plan + else: + # The reset is in an earlier segment. The processor survives a + # source swap, so the state *can* be carried: read the tail of + # the reset's segment, then continue in the target's. + reset_segment = self.reader.segment_path(origin.segment) + last_of_reset_segment = self._last_segment_frame(origin.segment) + if reset_segment is None or last_of_reset_segment is None: + warnings.append("state_across_segment") + else: + plan = ReplayPlan( + segment=segment_path, + steps=( + ReplayStep( + source=reset_segment, + to_frame=int(last_of_reset_segment), + reset_after=int(origin.segment_frame), + ), + ReplayStep(source=segment_path, to_frame=stop_at), + ), + stop_at=stop_at, + reset_frame_id=int(reset_frame), + anchor="reset", + state=STATE_EXACT, + ) + if plan.frames_to_read <= MAX_REPLAY_FRAMES: + return plan + # Too expensive: report it rather than freeze the window. + warnings.append("state_too_far_back") start = self.reader.segment_start_frame(frame.segment) if start is None: - return ReplayPlan(segment=Path(), stop_at=0, error="frame_not_indexed") + return ReplayPlan(segment=Path(), error="frame_not_indexed") preroll = self.reader.preroll_segment() preroll_frames = self.reader.preroll_frames() if preroll is not None else 0 @@ -169,19 +264,39 @@ class DepthRecomputer: # one are the only history that can rebuild the tracker. return ReplayPlan( segment=segment_path, - stop_at=max(1, int(frame.segment_frame)), + steps=(ReplayStep(source=segment_path, to_frame=stop_at),), + stop_at=stop_at, preroll=preroll, preroll_frames=int(preroll_frames), + anchor="preroll", + state=STATE_PREROLL, warnings=tuple(warnings), ) - if int(frame.segment_frame) > 1: - warnings.append("state_approximate") + # No reset and no pre-roll: the state entering this frame is unknown. + warnings.append("state_approximate") return ReplayPlan( segment=segment_path, - stop_at=max(1, int(frame.segment_frame)), - warnings=tuple(warnings), + steps=( + ReplayStep( + source=segment_path, + to_frame=stop_at, + ), + ), + stop_at=stop_at, + anchor="segment_start", + state=STATE_APPROXIMATE, + warnings=tuple(dict.fromkeys(warnings)), ) + def _last_segment_frame(self, segment: int) -> Optional[int]: + """The highest indexed frame number inside ``segment``.""" + frames = [ + int(entry.segment_frame) + for entry in self.reader.info().frames + if int(entry.segment) == int(segment) + ] + return max(frames) if frames else None + # ----------------------------------------------------------------- recompute def depth_at(self, t_rel_s: float) -> DepthResult: """Recompute the depth map for the frame at ``t_rel_s``.""" @@ -219,32 +334,95 @@ class DepthRecomputer: logger.exception("Depth recomputation failed") self.last_error = f"{type(exc).__name__}: {exc}" return DepthResult( - error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s + error=self.last_error, + frame_id=frame.frame_id, + t_rel_s=frame.t_rel_s, + warnings=plan.warnings, + record_id=self.record_id(), ) self.last_warnings = list(plan.warnings) + if self._rewound_without_anchor and "state_approximate" not in self.last_warnings: + # The plan looked recoverable, but the read path had to restart a file + # without a reset boundary to clear the tracker afterwards. + self.last_warnings.append("state_approximate_after_rewind") + warnings = tuple(dict.fromkeys(self.last_warnings)) if depth is None: self.last_error = "depth_not_available" return DepthResult( error=self.last_error, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s, - warnings=plan.warnings, + warnings=warnings, + record_id=self.record_id(), ) self.last_error = "" return DepthResult( depth=depth, frame_id=frame.frame_id, t_rel_s=frame.t_rel_s, - warnings=plan.warnings, + warnings=warnings, + record_id=self.record_id(), ) + def record_id(self) -> str: + """Which recording these answers belong to.""" + return str(getattr(self.reader, "directory", "") or "") + + # ------------------------------------------------------------------ playback def _replay(self, plan: ReplayPlan) -> Optional[np.ndarray]: - """Read the pre-roll (if any), then step to the planned frame.""" + """Execute the plan's steps, keeping the processor and swapping inputs. + + The **processor is kept** across a source change: the SDK's tracker lives + on the object, so disposing of it would dispose of the state the pre-roll + (or the reset boundary) just rebuilt. A reset boundary is re-applied by + calling ``reset()`` where the operator's ``zeroing`` happened. + """ + self._rewound_without_anchor = False if plan.preroll is not None and plan.preroll_frames > 0: self._feed(plan.preroll, int(plan.preroll_frames)) - if self._segment != plan.segment or self._position > plan.stop_at: - self._reopen_segment(plan.segment) - return self._advance_to(int(plan.stop_at)) + depth: Optional[np.ndarray] = None + for step in plan.steps: + depth = self._run_step(plan, step) + return depth + + def _run_step(self, plan: ReplayPlan, step: ReplayStep) -> Optional[np.ndarray]: + target = max(1, int(step.to_frame)) + if self._sensor is None or self._segment != step.source: + if self._sensor is None or not self._swap_source(step.source): + self._open_segment(step.source) + elif self._position > target: + # Re-reading an earlier frame can only be done from the start of the + # file. Without a reset boundary to clean up after it, the tracker has + # now seen frames it should not have. + if not self._swap_source(step.source): + self._open_segment(step.source) + if plan.anchor != "reset": + self._rewound_without_anchor = True + + last: Optional[np.ndarray] = ( + self._last_depth if self._position == target else None + ) + while self._position < target: + depth = self._read_one() + if depth is None: + break + last = depth + if step.reset_after is not None and self._position == int(step.reset_after): + # The operator's zeroing: the live run cleared the tracker here. + if not self._reset_processor(): + self.last_warnings.append("reset_not_applied") + return last + + def _reset_processor(self) -> bool: + replay = self._sensor + reset = getattr(replay, "reset", None) + if not callable(reset): + return False + try: + return bool(reset()) + except Exception: # noqa: BLE001 - reported as a replay warning + logger.exception("Could not re-apply the reset boundary") + return False def _depth_from_segment( self, segment: Path, segment_frame: int @@ -253,28 +431,55 @@ class DepthRecomputer: The SDK's video source only advances, so stepping costs one read per frame — and the position is tracked, so asking for frame 2 and then - frame 3 reads two frames and then one, instead of two more. + frame 3 reads two frames and then one, instead of two more. Asking twice + for the *same* frame is answered from the cached depth of that frame. """ target = max(1, int(segment_frame)) - if self._segment != segment or self._position > target: - self._reopen_segment(segment) - return self._advance_to(target) + if self._segment != segment: + self._open_segment(segment) + return self._run_step( + ReplayPlan(segment=Path(segment), stop_at=target), + ReplayStep(source=Path(segment), to_frame=target), + ) # --------------------------------------------------------------------- video def sensor_type(self) -> str: device = self.reader.session().get("device") or {} return str(device.get("sensor_type") or "box") - def _reopen_segment(self, segment: Path) -> None: + def _open_segment(self, segment: Path) -> None: + """Open a segment on a **new** processor (the previous one is closed).""" self._close() - if self.sensor_type() == "finger": - self._sensor = _FingerReplay( - segment, self.resolved_config(), self._finger_export_dir() - ) - else: - self._sensor = _BoxReplay(segment, self.resolved_config()) + self._sensor = open_replay( + self.sensor_type(), + Path(segment), + self.resolved_config(), + self._finger_export_dir(), + ) self._segment = Path(segment) self._position = 0 + self._last_depth = None + + def _swap_source(self, segment: Path) -> bool: + """Replace only the video source of the open processor. + + Returns False when the backend cannot do it, in which case the caller + falls back to a full reopen (and the algorithm state is lost, which the + plan already reports as approximate). + """ + replay = self._sensor + if replay is None: + return False + try: + if not replay.set_source(Path(segment)): + return False + except Exception: # noqa: BLE001 - reported by the plan's warnings + logger.exception("Could not swap the replayed video source") + return False + self._segment = Path(segment) + self._position = 0 + self._last_depth = None + return True def _finger_export_dir(self): """The finger pipeline assets, resolved exactly as the live run did.""" @@ -292,29 +497,17 @@ class DepthRecomputer: """Consume ``frames`` frames from another file to rebuild state.""" if frames <= 0: return - if self._sensor is None or self._segment != segment: - self._reopen_segment(segment) + if self._sensor is None: + self._open_segment(segment) + elif self._segment != segment: + if not self._swap_source(segment): + self._open_segment(segment) for _ in range(int(frames)): if self._read_one() is None: break - def _advance_to(self, target: int) -> Optional[np.ndarray]: - last_depth = None - while self._position < int(target): - depth = self._read_one() - if depth is None: - break - last_depth = depth - return last_depth - def _read_one(self) -> Optional[np.ndarray]: - """Read one frame through the **live** processing chain. - - ``get_img``/``read_source`` alone is not the live pipeline: the SDK's - depth map is produced by ``compute_deformation`` + ``compute_contact`` - (and, for the finger, the unrolling step), so skipping them would replay - a different algorithm than the one that produced the recording. - """ + """Read one frame through the **live** processing chain (see ``process_chain``).""" replay = self._sensor if replay is None: return None @@ -322,12 +515,14 @@ class DepthRecomputer: if depth is None: return None self._position += 1 + self._last_depth = depth return depth def _close(self) -> None: replay, self._sensor = self._sensor, None self._segment = None self._position = 0 + self._last_depth = None if replay is None: return try: @@ -343,97 +538,3 @@ class DepthRecomputer: def __exit__(self, *_exc) -> None: self.close() - - -class _BoxReplay: - """The box sensor's live chain, opened on one video file.""" - - def __init__(self, segment: Path, config_ref: str) -> None: - import orisys - - self._sensor = orisys.Sensor( - str(segment), - config_name=config_ref, - verbose=False, - backend="auto", - ) - - def read(self) -> Optional[np.ndarray]: - image = self._sensor.get_img() - if image is None: - return None - compute_deformation = getattr(self._sensor, "compute_deformation", None) - compute_contact = getattr(self._sensor, "compute_contact", None) - if callable(compute_deformation) and callable(compute_contact): - # Same calls and arguments as the acquisition loop. - compute_deformation(decompose=True, check_motion=False) - compute_contact() - else: - logger.warning( - "Sensor %r has no processing chain; replay depth is not the live algorithm", - type(self._sensor).__name__, - ) - info = self._sensor.info - if not hasattr(info, "DEPTH"): - return None - value = self._sensor.read_info(info.DEPTH) - return None if value is None else np.asarray(value) - - def close(self) -> None: - self._sensor.disconnect() - - -class _FingerReplay: - """The finger's live chain: unroll the source frame, then process the tactile.""" - - def __init__(self, segment: Path, config_ref: str, export_dir) -> None: - from orisys.finger import FingerRuntime - - from orisys_app_shared.acquisition.constants import ( - FORCE_SAMPLE_RATE_HZ, - LOWPASS_CUTOFF_HZ, - ) - - kwargs: dict = { - "config": config_ref, - "video": str(segment), - "verbose": False, - } - if export_dir is not None: - kwargs["export_dir"] = str(export_dir) - self._runtime = FingerRuntime.open(**kwargs) - self._runtime.warmup(color=False) - tactile = getattr(self._runtime, "tactile", None) - if tactile is not None and hasattr(tactile, "configure"): - try: - # The same filter settings the acquisition loop applies. - tactile.configure( - lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, - lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ, - ) - except Exception: # noqa: BLE001 - the defaults still work - logger.exception("Could not configure the replayed tactile sensor") - - def read(self) -> Optional[np.ndarray]: - ok, source_frame = self._runtime.read_source() - if not ok or source_frame is None: - return None - unrolled = self._runtime.process_frame(source_frame, color=False) - if unrolled is None: - return None - tactile = self._runtime.tactile - compute_deformation = getattr(tactile, "compute_deformation", None) - compute_contact = getattr(tactile, "compute_contact", None) - if callable(compute_deformation) and callable(compute_contact): - compute_deformation(decompose=True, check_motion=False) - compute_contact() - else: - logger.warning("Finger tactile sensor has no processing chain") - info = tactile.info - if not hasattr(info, "DEPTH"): - return None - value = tactile.read_info(info.DEPTH) - return None if value is None else np.asarray(value) - - def close(self) -> None: - self._runtime.close() diff --git a/shared/src/orisys_app_shared/record/depth_backends.py b/shared/src/orisys_app_shared/record/depth_backends.py new file mode 100644 index 0000000..27ff463 --- /dev/null +++ b/shared/src/orisys_app_shared/record/depth_backends.py @@ -0,0 +1,194 @@ +"""Replay backends: the live processing chain, pointed at a video file. + +Each backend keeps **one** processor for its lifetime. The tracker state lives on +that object, so a replay must be able to change the *input source* without +throwing the processor away — that is what makes a pre-roll, or a zeroing that +happened in an earlier segment, reproducible at all. + +The processing itself is not duplicated here: the reads go through +``acquisition.sdk_worker.process_chain``, the same function the live workers run. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Optional + +import numpy as np + +logger = logging.getLogger(__name__) + + +class BoxReplay: + """The box sensor's live chain, opened on one video file.""" + + def __init__(self, segment: Path, config_ref: str) -> None: + import orisys + + self._config_ref = config_ref + self._sensor = orisys.Sensor( + str(segment), + config_name=config_ref, + verbose=False, + backend="auto", + ) + + def set_source(self, segment: Path) -> bool: + """Point the same processor at another file. + + The tracker lives on the ``Sensor`` object, so re-opening the sensor would + throw away the state the pre-roll just rebuilt. Only the capture is + replaced, and the tracker's reference frame is preserved. + """ + sensor = self._sensor + open_source = getattr(sensor, "_open_video_source", None) + if not callable(open_source): + return False + initial = getattr(sensor, "initial_img", None) + try: + import orisys + + sensor.vid_src = orisys.capture.normalize_source(str(segment)) + except Exception: # noqa: BLE001 - fall back to assigning the path + sensor.vid_src = str(segment) + sensor._is_live_camera = False + open_source(False, None) + if initial is not None: + # The tracker's reference frame belongs to the session, not to the + # file: replacing it would silently reset what "no contact" means. + sensor.initial_img = initial + return True + + def read(self) -> Optional[np.ndarray]: + from orisys_app_shared.acquisition.sdk_worker import process_chain + + image = self._sensor.get_img() + if image is None: + return None + # The same function the acquisition loop runs. + depth, error = process_chain(self._sensor) + if error: + logger.warning("Replay processing chain reported: %s", error) + return depth + + def reset(self) -> bool: + """Clear the tracker, exactly as the operator's zeroing did live.""" + reset = getattr(self._sensor, "reset", None) + if not callable(reset): + return False + reset() + return True + + def close(self) -> None: + self._sensor.disconnect() + + +class FingerReplay: + """The finger's live chain: unroll the source frame, then process the tactile.""" + + def __init__(self, segment: Path, config_ref: str, export_dir) -> None: + from orisys.finger import FingerRuntime + + from orisys_app_shared.acquisition.constants import ( + FORCE_SAMPLE_RATE_HZ, + LOWPASS_CUTOFF_HZ, + ) + + self._config_ref = config_ref + self._export_dir = export_dir + kwargs: dict = { + "config": config_ref, + "video": str(segment), + "verbose": False, + } + if export_dir is not None: + kwargs["export_dir"] = str(export_dir) + self._runtime = FingerRuntime.open(**kwargs) + self._runtime.warmup(color=False) + tactile = getattr(self._runtime, "tactile", None) + if tactile is not None and hasattr(tactile, "configure"): + try: + # The same filter settings the acquisition loop applies. + tactile.configure( + lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, + lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ, + ) + except Exception: # noqa: BLE001 - the defaults still work + logger.exception("Could not configure the replayed tactile sensor") + + def set_source(self, segment: Path) -> bool: + """Point the same runtime at another video, keeping the tactile pipeline.""" + try: + from orisys.capture import ( + CaptureConfig, + ReconnectPolicy, + is_live_camera, + normalize_source, + open_capture, + ) + + runtime = self._runtime + video = normalize_source(str(segment)) + camera = dict(getattr(runtime.geometry, "config", {}).get("camera", {})) + capture_config = CaptureConfig.from_camera_dict( + video, + camera, + reconnect=ReconnectPolicy(enabled=False), + verbose=False, + background=False, + ) + capture_source = open_capture(capture_config, start_background=False) + if not capture_source.is_open(): + return False + if is_live_camera(video): + capture_source.start_background() + previous = runtime.capture_source + runtime.capture_source = capture_source + runtime.capture = capture_source.cap + runtime.capture_worker = capture_source + if previous is not None: + try: + previous.close() + except Exception: # noqa: BLE001 + logger.exception("Could not close the previous finger source") + return True + except Exception: # noqa: BLE001 - reported as an approximate replay + logger.exception("Could not swap the finger video source") + return False + + def read(self) -> Optional[np.ndarray]: + from orisys_app_shared.acquisition.sdk_worker import process_chain + + ok, source_frame = self._runtime.read_source() + if not ok or source_frame is None: + return None + unrolled = self._runtime.process_frame(source_frame, color=False) + if unrolled is None: + return None + depth, error = process_chain(self._runtime.tactile) + if error: + logger.warning("Replay processing chain reported: %s", error) + return depth + + def reset(self) -> bool: + """Clear the tactile tracker, as the operator's zeroing did live.""" + tactile = getattr(self._runtime, "tactile", None) + target = tactile if callable(getattr(tactile, "reset", None)) else None + if target is None: + sensor = getattr(tactile, "raw_sensor", None) + target = sensor if callable(getattr(sensor, "reset", None)) else None + if target is None: + return False + target.reset() + return True + + def close(self) -> None: + self._runtime.close() + + +def open_replay(sensor_type: str, segment: Path, config_ref: str, export_dir=None): + """The backend for a recorded sensor type.""" + if str(sensor_type) == "finger": + return FingerReplay(segment, config_ref, export_dir) + return BoxReplay(segment, config_ref) diff --git a/shared/src/orisys_app_shared/record/experiment.py b/shared/src/orisys_app_shared/record/experiment.py index 04dc743..bb503ee 100644 --- a/shared/src/orisys_app_shared/record/experiment.py +++ b/shared/src/orisys_app_shared/record/experiment.py @@ -41,6 +41,7 @@ import cv2 import numpy as np from orisys_app_shared.record.sample import RecordSample, to_record_sample +from orisys_app_shared.record.video_writer import VideoSegments logger = logging.getLogger(__name__) @@ -87,7 +88,8 @@ MAX_CONSECUTIVE_ERRORS = 5 ALARM_EVERY_N_DROPS = 1000 #: How many lost-row gaps may be remembered for the frame index. GAP_BACKLOG = 4096 -#: How many data rows may be parked while the item queue is full of video. +#: Hard cap on the ordered stream. Past this the producer has to give up a row, +#: and it says so by writing a gap marker instead. ROW_BACKLOG = 8192 @@ -115,6 +117,9 @@ class RecordConfig: fps: float = 30.0 codec: str = "mp4v" segment_seconds: float = 300.0 + #: Stream depth at which **video** starts being shed. Data rows are not + #: dropped by this limit: a row is tiny, and losing one because the encoder + #: fell behind is exactly the defect this bound must not reintroduce. max_queue: int = 2048 #: Byte budget for the video queue. The item count alone is not a bound: at #: 1920x1080 BGR, 2048 queued frame copies would be ~11.9 GiB. @@ -206,14 +211,16 @@ class ExperimentRecorder: def __init__(self, config: Optional[RecordConfig] = None) -> None: self.config = config or RecordConfig() - self._queue: queue.Queue = queue.Queue(maxsize=max(16, self.config.max_queue)) - #: Bounded side channel for frames whose data row was lost to a full - #: queue. It is tiny, so it never grows with the recording. + #: One ordered stream for rows and events. It is **unbounded** on count: + #: the producer checks ``ROW_BACKLOG``/``max_queue`` itself, so a slow + #: encoder can no longer make the *queue* quietly delete a data row, and + #: rows are written in the order they were acquired. + self._queue: queue.Queue = queue.Queue() + #: Stream positions of frames whose data row was lost. Kept out of the + #: stream because there is no room for them there; flushed in order. self._gaps: deque = deque(maxlen=GAP_BACKLOG) - #: Rows that did not fit in the item queue because it was full of video. - #: They are tiny, so a small backlog covers any realistic stall. - self._row_backlog: deque = deque(maxlen=ROW_BACKLOG) self._queued_bytes = 0 + self._seq = 0 self._thread: Optional[threading.Thread] = None self._stop = threading.Event() self._lock = threading.Lock() @@ -242,6 +249,8 @@ class ExperimentRecorder: self._preroll_segment: Optional[str] = None #: Problems the writer found while closing, carried into the report. self._writer_problems: list[str] = [] + #: Coefficient files stored inside the recording directory. + self._calibration_snapshots: list[dict] = [] # ------------------------------------------------------------------ state @property @@ -318,7 +327,20 @@ class ExperimentRecorder: self._state = STATE_STARTING stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") directory = Path(root) / f"{stamp}_session{session_id:04d}" - directory.mkdir(parents=True, exist_ok=True) + try: + directory.mkdir(parents=True, exist_ok=True) + except Exception as exc: # noqa: BLE001 - reported to the caller + # A directory that cannot be created is a *terminal* failure, not a + # stuck ``starting``: the operator can fix the path and press record + # again, and the recorder must be willing to try. + with self._lock: + self._stats.failed = True + self._stats.last_error = f"could not create {directory}: {exc}" + self._state = STATE_FAILED + self._accepting = False + self._directory = None + self._alarm(f"could not create the recording directory: {exc}") + raise with self._lock: self._directory = directory self._stats = RecordStats() @@ -335,9 +357,10 @@ class ExperimentRecorder: self._preroll_frames = 0 self._preroll_segment = None self._writer_problems = [] + self._calibration_snapshots = [] # Stale bookkeeping from a previous run must not leak into this one. self._gaps.clear() - self._row_backlog.clear() + self._seq = 0 self._queued_bytes = 0 # Accept immediately: see ``_accepts_packets``. self._accepting = True @@ -370,26 +393,26 @@ class ExperimentRecorder: self._state = STATE_STOPPING self._accepting = False self._stop.set() - try: - self._queue.put_nowait(("stop", None)) - except queue.Full: # pragma: no cover - writer will drain and see the flag - pass + # The stop marker is an ordinary item in the stream: everything acquired + # before it is written before it, and the writer stops after it. + self._enqueue(("stop", self._next_seq(), None)) thread = self._thread if wait and thread is not None and thread.is_alive(): thread.join(timeout=timeout) if thread is not None and thread.is_alive(): - # Still writing: do not claim the experiment was recorded. - report = RecordReport( + # Either we were asked not to wait, or the writer missed the deadline. + # A finished recording is only ever reported by the thread that closed + # the files, so this is explicitly *not* a completion — and it is not + # cached as the report either, so a later stop() still sees the real + # one (``recorder.report`` stays None until then). + return RecordReport( directory=self._directory, stats=self.stats(), problems=[ - "the recording writer did not stop within " - f"{timeout:g}s; the files are not closed yet" + "the recording writer has not finished closing the files yet" ], completed=False, ) - self._report = report - return report self._thread = None return self._report or self._finalise() @@ -398,13 +421,34 @@ class ExperimentRecorder: A session can outlive the coefficients it started with, so the version is per packet, not per session: the acquisition thread stamps every packet - with the calibration that was loaded when it was acquired. + with the calibration that was loaded when it was acquired. An empty + version is meaningful — it says the device has no calibration loaded. """ with self._lock: self._calibration_version = str(version or "") if self.is_recording: self.log_event("calibration_version", self._calibration_version) + def note_calibration_snapshot( + self, + *, + version: str, + name: str, + frame_id: int = -1, + mono_t: Optional[float] = None, + ) -> None: + """Remember a coefficient file stored inside the recording directory.""" + entry = { + "version": str(version or ""), + "file": str(name or ""), + "frame_id": int(frame_id), + "mono_t": None if mono_t is None else round(float(mono_t), 6), + "at": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), + } + with self._lock: + self._calibration_snapshots.append(entry) + logger.info("Calibration snapshot stored: %s", name) + def log_event( self, kind: str, @@ -433,21 +477,29 @@ class ExperimentRecorder: "kind": str(kind), "detail": str(detail or ""), } - try: - self._queue.put_nowait(("event", event)) - except queue.Full: + # Events ride the same ordered stream as the rows, so an operator action + # cannot overtake the samples it was taken between. + if self._queue.qsize() >= ROW_BACKLOG: + # The stream is saturated: an event is the cheapest thing to give up, + # but the loss is counted rather than silent. self._note_drop("event") + return + self._enqueue(("event", self._next_seq(), event)) # ----------------------------------------------------------- sink interface def on_packet(self, packet) -> None: """Called on the acquisition thread: enqueue, never do I/O here. - A data row is small and a frame is large, so video is what gives way. - When the byte budget is exhausted the **frame** is shed, and when even - the item queue is full the frame is shed too and the row is parked in a - small backlog. Losing the row because the video could not keep up is what - made a video overflow look like a video-only loss while CSV rows quietly - disappeared. + A data row is small and a frame is large, so video is what gives way — + and the two are decided **separately**: + + * the rows go into one ordered, unbounded stream (bounded only by + ``ROW_BACKLOG``, at which point the loss is counted and marked); + * the frames are bounded by a byte budget, and a frame that would exceed + it is shed *before* it is queued, so nothing has to be rolled back. + + The stream being ordered is what makes the files ordered: rows are written + in the order they were acquired, no matter how far behind the encoder is. """ if not self._accepts_packets(): with self._lock: @@ -462,36 +514,53 @@ class ExperimentRecorder: with self._lock: self._last_t = sample.mono_t + # Depth is only consulted to decide the *video*: a row is never refused + # because the encoder is behind, only when the stream itself is full. + depth = self._queue.qsize() + if depth >= ROW_BACKLOG: + self._note_gap(sample) + return + frame = None if self.config.video or self.config.lossless_frames: candidate = self._capture_frame(packet) - size = 0 if candidate is None else int(candidate.nbytes) - with self._lock: - affordable = (self._queued_bytes + size) <= max( - 1024, int(self.config.max_queue_bytes) - ) - if candidate is None: - pass - elif affordable: - frame = candidate - with self._lock: - self._queued_bytes += size - else: - self._shed_video("video byte budget reached") + if candidate is not None: + size = int(candidate.nbytes) + if depth >= max(16, int(self.config.max_queue)): + self._shed_video("record queue overflow") + elif not self._affordable(size): + self._shed_video("video byte budget reached") + else: + frame = candidate + # Reserved here and released exactly once, by the writer that + # consumes it (see ``_handle_frame``). + with self._lock: + self._queued_bytes += size + self._enqueue(("frame", self._next_seq(), sample, frame)) + + def _next_seq(self) -> int: + with self._lock: + self._seq += 1 + return self._seq + + def _affordable(self, size: int) -> bool: + with self._lock: + return (self._queued_bytes + int(size)) <= max( + 1024, int(self.config.max_queue_bytes) + ) + + def _enqueue(self, item) -> bool: + """Append to the ordered stream. False when the stream is full. + + The queue itself is unbounded — its depth is bounded by ``ROW_BACKLOG`` + and checked by the callers — so a slow writer can never make the producer + drop data behind its back. + """ try: - self._queue.put_nowait(("frame", (sample, frame))) - except queue.Full: - if frame is not None: - # The queue is full of video: the row is tiny and must survive. - frame = None - self._shed_video("record queue overflow") - with self._lock: - room = len(self._row_backlog) < self._row_backlog.maxlen - if room: - self._row_backlog.append(sample) - if not room: - # Even the row backlog is full: the hole is at least marked. - self._note_gap(sample) + self._queue.put_nowait(item) + return True + except queue.Full: # pragma: no cover - the queue is unbounded + return False def _shed_video(self, reason: str) -> None: with self._lock: @@ -505,14 +574,26 @@ class ExperimentRecorder: ) def _note_gap(self, sample) -> None: - """Remember a frame whose row was lost, so the replay can show the hole.""" + """Remember a frame whose row was lost, so the replay can show the hole. + + The gap carries the stream position it happened at. That is what lets the + writer put the hole in the *right place* in ``frames.csv`` instead of + appending it after newer frames (which would make the index unsorted). + """ with self._lock: self._stats.dropped_data += 1 if len(self._gaps) >= self._gaps.maxlen: # The gap backlog is bounded, so a long overflow loses the marker. self._stats.unrecorded_gaps += 1 return - self._gaps.append((sample.frame_id, sample.mono_t, sample.wall_t)) + self._gaps.append( + ( + self._seq + 1, + sample.frame_id, + sample.mono_t, + sample.wall_t, + ) + ) @staticmethod def _capture_frame(packet): @@ -574,6 +655,7 @@ class ExperimentRecorder: session_id=self._session_id, calibration_id=self._calibration_id, preroll=self._preroll, + on_problem=self._record_failure, ) # Written once the pre-roll file exists, so session.json never names # a file that was not created. @@ -583,26 +665,40 @@ class ExperimentRecorder: self._preroll = [] self._write_session_json(final=False) with self._lock: - self._state = STATE_RECORDING - self._accepting = True + # A stop *or a failure* that arrived while the files were opening + # must not be undone here: accepting packets again after ``stop`` + # would make a finished recording look alive, and overwriting + # ``failed`` with ``recording`` would hide a dead writer. The + # stream is still drained — those rows were accepted. + if self._stop.is_set() or self._state in ( + STATE_STOPPING, + STATE_FAILED, + ): + self._accepting = False + else: + self._state = STATE_RECORDING + self._accepting = True while True: - # Parked rows and gaps first: a stop request must not strand the - # data this session already accepted. - self._drain_rows(writer) - self._drain_gaps(writer) + # Gaps first, but only those that happened *before* the item about + # to be written: the hole must appear in the frame index where it + # belongs, not after newer frames. if self._stop.is_set() and self._queue.empty(): + self._flush_gaps(writer, None) break try: - kind, payload = self._queue.get(timeout=0.2) + item = self._queue.get(timeout=0.2) except queue.Empty: writer.maybe_flush() continue + kind = item[0] + seq = item[1] + self._flush_gaps(writer, seq) if kind == "stop": break if kind == "event": - self._handle_event(writer, payload) + self._handle_event(writer, item[2]) continue - sample, frame = payload + _kind, _seq, sample, frame = item self._handle_frame(writer, sample, frame) writer.maybe_flush() except Exception as exc: # noqa: BLE001 - reported through stats @@ -617,32 +713,31 @@ class ExperimentRecorder: self._accepting = False problems: list[str] = [] if writer is not None: - # A packet accepted just before the stop flag may have left its - # row or gap behind; write those before the file is closed. - self._drain_rows(writer) - self._drain_gaps(writer) + # A row or gap accepted just before the stop flag may still be in + # the stream; write those before the file is closed. + self._flush_gaps(writer, None) try: problems = writer.close() except Exception as exc: # noqa: BLE001 problems = [f"close failed: {type(exc).__name__}: {exc}"] with self._lock: - self._stats.write_errors += writer.write_errors self._stats.segments = writer.segments self._stats.video_frames = writer.video_frames self._stats.bytes_written = writer.bytes_written + # Closing problems are failures of the same recording and are counted + # exactly once, through the same path as a failed row, flush or event. + for problem in problems: + self._record_failure(problem) + self._alarm(problem) with self._lock: - if problems or failure: - self._stats.failed = True - if not self._stats.last_error: - self._stats.last_error = problems[0] if problems else failure + if failure and not self._stats.last_error: + self._stats.last_error = failure # The writer's own findings (a flush that failed, a pre-roll that # could not be stored) belong in the report: an operator reading # only the terminal report must not have to grep the log. self._writer_problems = list(problems) failed = self._stats.failed self._state = STATE_FAILED if failed else STATE_STOPPED - for problem in problems: - self._alarm(problem) # Only now, with the files closed, is the report trustworthy. self._report = self._finalise(completed=True) @@ -657,6 +752,10 @@ class ExperimentRecorder: def _handle_frame(self, writer, sample, frame) -> None: try: if frame is not None: + # The one release for the reservation taken in ``on_packet``. + # A frame is enqueued once and consumed once, so this cannot + # double-count; doing it *before* the write also means a failed + # write does not leak the budget for a frame that is gone. with self._lock: self._queued_bytes = max(0, self._queued_bytes - int(frame.nbytes)) writer.write_sample(sample, frame) @@ -668,22 +767,21 @@ class ExperimentRecorder: except Exception as exc: # noqa: BLE001 self._record_error(exc) - def _drain_rows(self, writer) -> None: - """Write rows parked while the item queue was full of video.""" - while True: - with self._lock: - if not self._row_backlog: - return - sample = self._row_backlog.popleft() - self._handle_frame(writer, sample, None) + def _flush_gaps(self, writer, upto_seq) -> None: + """Write the frame-index rows for frames whose data row was lost. - def _drain_gaps(self, writer) -> None: - """Write the frame-index rows for frames whose data row was lost.""" + Gaps are written in stream order and only up to ``upto_seq`` (``None`` + flushes the rest), so ``frames.csv`` stays sorted by time even when the + loss happened while the writer was far behind. + """ while True: with self._lock: if not self._gaps: return - frame_id, mono_t, wall_t = self._gaps.popleft() + seq, frame_id, mono_t, wall_t = self._gaps[0] + if upto_seq is not None and seq > upto_seq: + return + self._gaps.popleft() try: writer.write_gap(frame_id, mono_t, wall_t) with self._lock: @@ -693,18 +791,32 @@ class ExperimentRecorder: return def _record_error(self, exc: Exception) -> None: + self._record_failure(f"{type(exc).__name__}: {exc}") + + def _record_failure(self, message: str, *, fatal: bool = False) -> None: + """One failed write, flush, close or metadata update. + + Every failure enters the state machine here, so the counters, the fatal + transition and the report cannot disagree about what happened. A writer + that kept its own count (a failed ``maybe_flush`` used to) left the + recording looking healthy until the very end. + + ``fatal`` marks a failure that ends the recording on its own: a recording + whose ``session.json`` could not be written is not a recording anyone can + identify afterwards. + """ with self._lock: self._stats.write_errors += 1 - self._stats.last_error = f"{type(exc).__name__}: {exc}" + self._stats.last_error = str(message) self._consecutive_errors += 1 - fatal = self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS + fatal = fatal or self._consecutive_errors >= MAX_CONSECUTIVE_ERRORS if fatal: self._stats.failed = True # Stop accepting immediately: consuming packets that cannot be # written only hides the loss behind a "recording" state. self._accepting = False self._state = STATE_FAILED - self._alarm(f"recording write failed: {exc}") + self._alarm(f"recording write failed: {message}") if fatal: self._stop.set() @@ -712,6 +824,10 @@ class ExperimentRecorder: with self._lock: if self._t0 is not None and self._last_t is not None: self._stats.duration_s = round(float(self._last_t - self._t0), 6) + # Metadata **before** the report is assembled: a session.json that could + # not be written is part of what happened, and used to be discovered only + # after the report had already been built from the older statistics. + self._write_session_json(final=completed) stats = self.stats() problems: list[str] = [] if stats.dropped_data or stats.dropped_video: @@ -734,8 +850,6 @@ class ExperimentRecorder: for problem in self._writer_problems: if problem not in problems: problems.append(problem) - # ``ended_at`` is only written once the files are actually closed. - self._write_session_json(final=completed) return RecordReport( directory=self._directory, stats=stats, @@ -763,10 +877,11 @@ class ExperimentRecorder: ) temporary.replace(target) except Exception as exc: # noqa: BLE001 - with self._lock: - self._stats.write_errors += 1 - self._stats.last_error = f"session.json: {type(exc).__name__}: {exc}" - self._alarm(f"could not write session.json: {exc}") + # The same single failure path as a write: it is counted, it ends the + # recording, and it reaches the report. + self._record_failure( + f"session.json: {type(exc).__name__}: {exc}", fatal=True + ) def _build_metadata(self, *, final: bool) -> dict[str, Any]: with self._lock: @@ -783,6 +898,9 @@ class ExperimentRecorder: "video": self.config.as_dict(), "events": events, "stats": stats, + # The coefficients the session actually ran with: the file itself is + # copied into the recording, so a re-export cannot rewrite history. + "calibration_snapshots": [dict(entry) for entry in self._calibration_snapshots], # The replay needs these to know whether the algorithm state at the # start of the recording can be rebuilt at all. "preroll_frames": int(self._preroll_frames), @@ -802,16 +920,36 @@ class ExperimentRecorder: class _SessionWriter: """Owns the file handles. Only ever touched by the writer thread.""" + def _note_problem(self, message: str) -> None: + """Record a failure here *and* hand it to the recorder at once.""" + self.write_errors += 1 + if len(self._problems) < 5: + self._problems.append(message) + if self._on_problem is not None: + try: + self._on_problem(message) + except Exception: # noqa: BLE001 - reporting must not break writing + logger.exception("Recording problem handler failed") + def __init__( - self, *, directory, config, session_id, calibration_id, preroll=() + self, + *, + directory, + config, + session_id, + calibration_id, + preroll=(), + on_problem=None, ) -> None: self.directory = Path(directory) self.config = config self.session_id = session_id self.calibration_id = calibration_id + #: ``on_problem(message)`` reports a failure to the recorder's state + #: machine *immediately*. A private counter here was invisible until the + #: files were closed, so a failing flush looked like a healthy recording. + self._on_problem = on_problem self.write_errors = 0 - self.segments = 0 - self.video_frames = 0 self.png_frames = 0 self.gap_rows = 0 self.bytes_written = 0 @@ -820,12 +958,14 @@ class _SessionWriter: self.preroll_segment: Optional[str] = None self._last_flush = time.perf_counter() self._t0: Optional[float] = None - self._segment: Optional[cv2.VideoWriter] = None - self._segment_index = 0 - self._segment_frames = 0 - self._segment_started = 0.0 - self._frame_shape: Optional[tuple[int, int]] = None self._problems: list[str] = [] + #: The video side, in its own component: one rolling segment at a time. + self._video = VideoSegments( + self.directory, + codec=self.config.codec, + fps=self.config.fps, + segment_seconds=self.config.segment_seconds, + ) self._forces = self._open_csv("forces.csv", FORCES_HEADER) self._frames = self._open_csv("frames.csv", FRAMES_HEADER) @@ -863,22 +1003,8 @@ class _SessionWriter: kept.append(frame) used += size kept.reverse() - self._frame_shape = (int(kept[0].shape[1]), int(kept[0].shape[0])) - name = "preroll_0001.mp4" - path = self.directory / name - fourcc = cv2.VideoWriter_fourcc(*self.config.codec) - writer = cv2.VideoWriter( - str(path), fourcc, max(1.0, float(self.config.fps)), self._frame_shape - ) - if not writer.isOpened(): - raise OSError(f"could not open video writer for {name}") - try: - for frame in kept: - writer.write(frame) - self.preroll_frames += 1 - finally: - writer.release() - self.preroll_segment = name if self.preroll_frames else None + self.preroll_frames = self._video.write_sequence(kept) + self.preroll_segment = "preroll_0001.mp4" if self.preroll_frames else None def _open_csv(self, name: str, header): handle = (self.directory / name).open("w", newline="", encoding="utf-8") @@ -957,16 +1083,15 @@ class _SessionWriter: self.gap_rows += 1 def _write_video_frame(self, frame) -> tuple[int, int]: - if self._frame_shape is None: - self._frame_shape = (int(frame.shape[1]), int(frame.shape[0])) - if self._should_roll(): - self._close_segment() - if self._segment is None: - self._open_segment() - self._segment.write(frame) - self._segment_frames += 1 - self.video_frames += 1 - return (self._segment_index, self._segment_frames) + return self._video.write(frame) + + @property + def video_frames(self) -> int: + return self._video.video_frames + + @property + def segments(self) -> int: + return self._video.segments def _write_lossless(self, frame) -> None: self.png_frames += 1 @@ -989,33 +1114,6 @@ class _SessionWriter: ) ) - # ------------------------------------------------------------------ segments - def _should_roll(self) -> bool: - if self._segment is None: - return False - if self.config.segment_seconds <= 0: - return False - return (time.perf_counter() - self._segment_started) >= self.config.segment_seconds - - def _open_segment(self) -> None: - self._segment_index += 1 - path = self.directory / f"raw_{self._segment_index:04d}.mp4" - fourcc = cv2.VideoWriter_fourcc(*self.config.codec) - fps = max(1.0, float(self.config.fps)) - writer = cv2.VideoWriter(str(path), fourcc, fps, self._frame_shape) - if not writer.isOpened(): - raise OSError(f"could not open video writer for {path.name}") - self._segment = writer - self._segment_frames = 0 - self._segment_started = time.perf_counter() - self.segments = self._segment_index - - def _close_segment(self) -> None: - if self._segment is None: - return - self._segment.release() - self._segment = None - def maybe_flush(self) -> None: if (time.perf_counter() - self._last_flush) < self.config.flush_interval_s: return @@ -1024,23 +1122,22 @@ class _SessionWriter: try: handle.flush() except Exception as exc: # noqa: BLE001 - self.write_errors += 1 - if len(self._problems) < 5: - self._problems.append(f"flush failed: {exc}") + self._note_problem(f"flush failed: {exc}") def close(self) -> list[str]: - self._close_segment() + try: + self._video.close() + except Exception as exc: # noqa: BLE001 - the CSVs still have to close + self._note_problem(f"video close failed: {exc}") for handle, _writer in (self._forces, self._frames, self._events): try: handle.flush() handle.close() except Exception as exc: # noqa: BLE001 - self.write_errors += 1 - if len(self._problems) < 5: - self._problems.append(f"close failed: {exc}") + self._note_problem(f"close failed: {exc}") try: self.bytes_written = sum( - path.stat().st_size for path in self.directory.glob("*") if path.is_file() + path.stat().st_size for path in self.directory.rglob("*") if path.is_file() ) except Exception: # noqa: BLE001 self.bytes_written = 0 diff --git a/shared/src/orisys_app_shared/record/index.py b/shared/src/orisys_app_shared/record/index.py new file mode 100644 index 0000000..83d3cf5 --- /dev/null +++ b/shared/src/orisys_app_shared/record/index.py @@ -0,0 +1,453 @@ +"""A time index over a recorded ``forces.csv``, so history is read on demand. + +The replay used to load every row of a recording into Python lists and then into +arrays. That is fine for a minute and hopeless for the multi-hour recordings the +store is designed for, and it made the charts the only place where "the whole +session" had to exist in memory at once. + +This index keeps the *small* things in memory — one time per row, one validity +flag, one frame id, plus periodic byte offsets — and reads the *values* only for +the interval being drawn. It also builds a multi-level overview (per-bucket +min/max/count) while streaming the file once, which is what a whole-session strip +needs and what it cannot get by sampling a few thousand rows. + +Nothing here touches Qt, and nothing here trusts the file to be in order: a +legacy recording is sorted at load time (see ``playback.RecordingReader``). +""" + +from __future__ import annotations + +import csv +import logging +from bisect import bisect_left, bisect_right +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable, Iterator, Optional + +import numpy as np + +logger = logging.getLogger(__name__) + +#: How many rows to read between two recorded byte offsets. Larger means a +#: cheaper interval read but a longer scan to reach the interval. +CHECKPOINT_ROWS = 4096 +#: Default number of overview buckets for the whole-session strip. +DEFAULT_BUCKETS = 1200 + +_FORCE_COLUMNS = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N") + + +def _parse(text: str) -> float: + text = (text or "").strip() + if not text: + # An empty cell means "not available"; it must never become 0. + return float("nan") + try: + return float(text) + except ValueError: + return float("nan") + + +@dataclass +class ForceWindow: + """Rows inside one interval — the same shape a session slice has.""" + + times: np.ndarray + raw: np.ndarray + newtons: np.ndarray + valid: np.ndarray + frame_ids: np.ndarray + #: True when the rows were decimated to satisfy the caller's point budget. + decimated: bool = False + + def __len__(self) -> int: + return int(self.times.size) + + def value_for_unit(self, unit: str) -> np.ndarray: + if str(unit).upper().startswith("N"): + return self.newtons + return self.raw + + def plottable(self, unit: str): + """``(times, values, mask)`` with a per-axis mask — the shared rule.""" + from orisys_app_shared.record.sample import plottable_axis_mask + + values = self.value_for_unit(unit) + return self.times, values, plottable_axis_mask(values, self.valid) + + def plottable_axis(self, unit: str, index: int): + from orisys_app_shared.record.sample import plottable_axis_mask + + values = self.value_for_unit(unit) + mask = plottable_axis_mask(values, self.valid)[:, index] + return self.times[mask], values[mask, index] + + def statistics(self, unit: str) -> "WindowStats": + values = self.value_for_unit(unit) + mask = np.isfinite(values) & self.valid[:, None] + peaks: list[float] = [] + means: list[float] = [] + stds: list[float] = [] + for index in range(3): + column = values[:, index][mask[:, index]] + if column.size == 0: + peaks.append(float("nan")) + means.append(float("nan")) + stds.append(float("nan")) + continue + peaks.append(float(np.nanmax(column))) + means.append(float(np.nanmean(column))) + stds.append(float(np.nanstd(column))) + span = 0.0 + if self.times.size > 1: + span = float(self.times[-1] - self.times[0]) + return WindowStats( + duration_s=span, + samples=int(mask.any(axis=1).sum()), + peaks=tuple(peaks), + means=tuple(means), + stds=tuple(stds), + ) + + +@dataclass(frozen=True) +class WindowStats: + duration_s: float = 0.0 + samples: int = 0 + peaks: tuple[float, float, float] = (float("nan"),) * 3 + means: tuple[float, float, float] = (float("nan"),) * 3 + stds: tuple[float, float, float] = (float("nan"),) * 3 + + +@dataclass +class Overview: + """A whole-session envelope, cheap enough to keep in memory.""" + + times: np.ndarray + lows: np.ndarray # (buckets, 3) + highs: np.ndarray # (buckets, 3) + counts: np.ndarray # (buckets,) + unit: str = "raw" + + def __len__(self) -> int: + return int(self.times.size) + + +@dataclass +class _Checkpoint: + row: int + time: float + offset: int + + +class RecordingIndex: + """Index one recording directory's ``forces.csv`` (and its validity flags).""" + + def __init__(self, directory: str | Path, *, checkpoint_rows: int = CHECKPOINT_ROWS): + self.directory = Path(directory) + self.checkpoint_rows = max(1, int(checkpoint_rows)) + self.times = np.empty(0, dtype=np.float64) + self.valid = np.empty(0, dtype=bool) + self.frame_ids = np.empty(0, dtype=np.int64) + self._checkpoints: list[_Checkpoint] = [] + self._rows = 0 + self._byte_size = 0 + self._built = False + + # ------------------------------------------------------------------ building + @property + def path(self) -> Path: + return self.directory / "forces.csv" + + @property + def rows(self) -> int: + return self._rows + + @property + def byte_size(self) -> int: + """Size of the data file the index describes.""" + return self._byte_size + + def build(self) -> "RecordingIndex": + """One streaming pass: times, flags, frame ids and byte offsets.""" + path = self.path + times: list[float] = [] + valid: list[bool] = [] + frame_ids: list[int] = [] + checkpoints: list[_Checkpoint] = [] + if path.is_file(): + with path.open("rb") as handle: + header = handle.readline().decode("utf-8", "replace").strip() + columns = {name: index for index, name in enumerate(header.split(","))} + time_column = columns.get("t_rel_s", 2) + valid_column = columns.get("valid", 9) + frame_column = columns.get("frame_id", 1) + offset = handle.tell() + for row, line in enumerate(handle): + next_offset = handle.tell() + text = line.decode("utf-8", "replace").rstrip("\r\n") + if row % self.checkpoint_rows == 0: + checkpoints.append( + _Checkpoint(row=row, time=_time_of(text), offset=offset) + ) + fields = next(csv.reader([text])) if text else [] + if len(fields) > max(time_column, valid_column, frame_column): + try: + times.append(float(fields[time_column])) + valid.append(fields[valid_column].strip() == "1") + frame_ids.append(int(fields[frame_column])) + except (TypeError, ValueError): + times.append(float("nan")) + valid.append(False) + frame_ids.append(-1) + offset = next_offset + self.times = np.asarray(times, dtype=np.float64) + self.valid = np.asarray(valid, dtype=bool) + self.frame_ids = np.asarray(frame_ids, dtype=np.int64) + self._checkpoints = checkpoints + self._rows = int(self.times.size) + try: + self._byte_size = path.stat().st_size + except OSError: + self._byte_size = 0 + self._built = True + return self + + def ensure_built(self) -> "RecordingIndex": + if not self._built: + self.build() + return self + + # ------------------------------------------------------------------- queries + def extent(self) -> tuple[float, float]: + if self._rows == 0: + return (0.0, 0.0) + return (float(self.times[0]), float(self.times[-1])) + + def duration_s(self) -> float: + first, last = self.extent() + return max(0.0, last - first) + + def row_range(self, t0: float, t1: float) -> tuple[int, int]: + """Half-open row range covering ``[t0, t1]``.""" + self.ensure_built() + if self._rows == 0: + return (0, 0) + if float(t1) < float(t0): + t0, t1 = t1, t0 + start = max(0, bisect_left(self.times.tolist(), float(t0)) - 1) + stop = min(self._rows, bisect_right(self.times.tolist(), float(t1))) + return (start, max(start, stop)) + + def _checkpoint_before(self, row: int) -> _Checkpoint: + chosen = _Checkpoint(row=0, time=0.0, offset=0) + for checkpoint in self._checkpoints: + if checkpoint.row <= row: + chosen = checkpoint + else: + break + return chosen + + def read_rows( + self, + start: int, + stop: int, + *, + max_points: int | None = None, + unit: str = "raw", + ) -> ForceWindow: + """Read ``[start, stop)`` by seeking to the nearest checkpoint. + + With ``max_points`` the rows are decimated **while streaming**: each + bucket contributes the extreme row of every axis in ``unit``, so a long + interval never has to exist in memory in full. The extremes are taken in + both the raw and the requested unit's axis, which is why a shear spike + survives a flat normal channel. + """ + self.ensure_built() + start = max(0, min(int(start), self._rows)) + stop = max(start, min(int(stop), self._rows)) + if stop <= start: + return _empty_window() + path = self.path + if not path.is_file(): + return _empty_window() + + newton_unit = str(unit).upper().startswith("N") + budget = int(max_points) if max_points else 0 + total = stop - start + bucket = ( + max(1, total // max(1, budget // 6)) + if budget and total > budget * 2 + else 1 + ) + + checkpoint = self._checkpoint_before(start) + pending: list[tuple] = [] + picked: list[tuple] = [] + + def flush() -> None: + if not pending: + return + if bucket == 1: + picked.extend(pending) + else: + values = np.asarray( + [row[3] if newton_unit else row[2] for row in pending], + dtype=np.float64, + ) + for axis in range(3): + column = values[:, axis] + finite = np.isfinite(column) + if not finite.any(): + continue + ordered = np.where(finite, column, -np.inf) + for index in {int(np.argmin(ordered)), int(np.argmax(ordered))}: + picked.append(pending[index]) + pending.clear() + + with path.open("rb") as handle: + # Every checkpoint offset points at a *data* row: the build records + # ``tell()`` after the header, so there is nothing to skip here. + handle.seek(checkpoint.offset) + for row, line in enumerate(handle, start=checkpoint.row): + if row >= stop: + break + if row < start: + continue + fields = next(csv.reader([line.decode("utf-8", "replace")])) + if len(fields) < 11: + continue + try: + t_rel = float(fields[2]) + frame_id = int(fields[1]) + except (TypeError, ValueError): + continue + values = [_parse(fields[index]) for index in (3, 4, 5, 6, 7, 8)] + pending.append( + ( + frame_id, + t_rel, + tuple(values[:3]), + tuple(values[3:]), + fields[9].strip() == "1", + ) + ) + if len(pending) >= bucket: + flush() + flush() + + if not picked: + return _empty_window() + # A row can be the extreme of several axes; keep one copy, in time order. + unique = {row[0]: row for row in picked} + rows = sorted(unique.values(), key=lambda row: row[1]) + return ForceWindow( + times=np.asarray([row[1] for row in rows], dtype=np.float64), + raw=np.asarray([row[2] for row in rows], dtype=np.float64).reshape(-1, 3), + newtons=np.asarray([row[3] for row in rows], dtype=np.float64).reshape(-1, 3), + valid=np.asarray([row[4] for row in rows], dtype=bool), + frame_ids=np.asarray([row[0] for row in rows], dtype=np.int64), + decimated=bucket > 1, + ) + + def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]: + """Yield ``(row, frame_id, t_rel_s, raw3, newtons3, valid)`` in file order.""" + self.ensure_built() + path = self.path + if not path.is_file(): + return + stop = self._rows if stop is None else min(int(stop), self._rows) + with path.open("rb") as handle: + handle.readline() # header + for row, line in enumerate(handle): + if row >= stop: + return + if row < start: + continue + fields = next(csv.reader([line.decode("utf-8", "replace")])) + if len(fields) < 11: + continue + try: + frame_id = int(fields[1]) + t_rel = float(fields[2]) + except (TypeError, ValueError): + continue + values = [_parse(fields[index]) for index in (3, 4, 5, 6, 7, 8)] + yield ( + row, + frame_id, + t_rel, + tuple(values[:3]), + tuple(values[3:]), + fields[9].strip() == "1", + ) + + # ------------------------------------------------------------------ overview + def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview: + """A per-bucket envelope of the whole recording, in the active unit. + + Built by streaming the file once and folding each row into its bucket, so + a peak in a bucket the strip would otherwise skip is still visible. + """ + self.ensure_built() + buckets = max(1, int(buckets)) + newton = str(unit).upper().startswith("N") + if self._rows == 0: + return Overview( + times=np.empty(0), + lows=np.empty((0, 3)), + highs=np.empty((0, 3)), + counts=np.empty(0, dtype=np.int64), + unit="N" if newton else "raw", + ) + first, last = self.extent() + span = max(1e-9, last - first) + lows = np.full((buckets, 3), np.nan) + highs = np.full((buckets, 3), np.nan) + counts = np.zeros(buckets, dtype=np.int64) + starts = np.full(buckets, np.nan) + for _row, _frame_id, t_rel, raw, newtons, is_valid in self.iter_rows(): + if not is_valid: + continue + bucket = int((t_rel - first) / span * (buckets - 1) + 0.5) + bucket = max(0, min(buckets - 1, bucket)) + values = newtons if newton else raw + for axis in range(3): + value = values[axis] + if value != value: # NaN + continue + current_low = lows[bucket, axis] + current_high = highs[bucket, axis] + lows[bucket, axis] = value if current_low != current_low else min(current_low, value) + highs[bucket, axis] = value if current_high != current_high else max(current_high, value) + if starts[bucket] != starts[bucket]: + starts[bucket] = t_rel + counts[bucket] += 1 + return Overview( + times=starts, + lows=lows, + highs=highs, + counts=counts, + unit="N" if newton else "raw", + ) + + +def _time_of(text: str) -> float: + fields = next(csv.reader([text])) if text else [] + if len(fields) > 2: + try: + return float(fields[2]) + except (TypeError, ValueError): + return float("nan") + return float("nan") + + +def _empty_window() -> ForceWindow: + return ForceWindow( + times=np.empty(0, dtype=np.float64), + raw=np.empty((0, 3)), + newtons=np.empty((0, 3)), + valid=np.empty(0, dtype=bool), + frame_ids=np.empty(0, dtype=np.int64), + ) diff --git a/shared/src/orisys_app_shared/record/playback.py b/shared/src/orisys_app_shared/record/playback.py index 9cfd224..5801f3e 100644 --- a/shared/src/orisys_app_shared/record/playback.py +++ b/shared/src/orisys_app_shared/record/playback.py @@ -21,6 +21,15 @@ from typing import Iterator, Optional import numpy as np +from orisys_app_shared.record.index import ( + CHECKPOINT_ROWS, + DEFAULT_BUCKETS, + ForceWindow, + Overview, + RecordingIndex, +) +from orisys_app_shared.record.sample import plottable_axis_mask + logger = logging.getLogger(__name__) #: Column names as written by the recorder (index == position in forces.csv). @@ -65,9 +74,21 @@ class RecordingInfo: frame_ids: np.ndarray dropped_rows: int = 0 frames_dropped: int = 0 + #: The index rows were in time order as written (False = a legacy record). + frames_out_of_order: bool = True + #: The force rows were in time order as written. + forces_out_of_order: bool = True + #: True when the arrays above are a decimated view of a long recording. + decimated: bool = False + #: Total rows in ``forces.csv`` (the decimated view has fewer). + total_rows: int = 0 + #: The recording's real duration, from the frame index, never from the view. + total_duration_s: float = 0.0 @property def duration_s(self) -> float: + if self.total_duration_s: + return float(self.total_duration_s) if self.times.size == 0: return 0.0 return float(self.times[-1]) @@ -78,26 +99,51 @@ class RecordingInfo: return self.raw def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """``(times, values, mask)`` — the same validity rule the live charts use. + """``(times, values, mask)`` — the same rule the live charts use. The replay used to filter on finiteness alone, so a stale value left by a failed compute step could reappear here after another view had already - excluded it. + excluded it. ``mask`` is per axis: a single-axis calibration is normal and + must not hide the axis that *is* calibrated. """ values = self.value_for_unit(unit) - mask = self.valid & np.isfinite(values).all(axis=1) - return self.times[mask], values[mask], mask + return self.times, values, plottable_axis_mask(values, self.valid) + + def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]: + """``(times, values)`` for one channel, with the other axes ignored.""" + values = self.value_for_unit(unit) + mask = plottable_axis_mask(values, self.valid)[:, index] + return self.times[mask], values[mask, index] class RecordingReader: - """Open one experiment directory and serve replay queries from it.""" + """Open one experiment directory and serve replay queries from it. - def __init__(self, directory: str | Path) -> None: + ``max_rows`` bounds what :meth:`info` keeps in memory: a long recording is + loaded as a decimated view (peaks preserved) and marked ``decimated``, while + :meth:`window` and :meth:`overview` read the interval being drawn from disk + through :class:`~orisys_app_shared.record.index.RecordingIndex`. That is what + lets the replay show an hour-long recording without holding all of it. + """ + + #: Above this many rows ``info()`` decimates instead of loading everything. + DEFAULT_MAX_ROWS = 200_000 + + def __init__( + self, + directory: str | Path, + *, + max_rows: int | None = None, + checkpoint_rows: int = CHECKPOINT_ROWS, + ) -> None: self.directory = Path(directory) if not self.directory.is_dir(): raise FileNotFoundError(f"Not a recording directory: {self.directory}") self._info: Optional[RecordingInfo] = None self._events: Optional[list[dict]] = None + self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS) + self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows) + self._overviews: dict[tuple[str, int], Overview] = {} # ------------------------------------------------------------------ loading def info(self) -> RecordingInfo: @@ -105,6 +151,10 @@ class RecordingReader: self._info = self._load() return self._info + def index(self) -> RecordingIndex: + """The time index, built once and shared with the windows.""" + return self._index.ensure_built() + def session(self) -> dict: path = self.directory / "session.json" if not path.is_file(): @@ -116,8 +166,30 @@ class RecordingReader: return {} def _load(self) -> RecordingInfo: - frames = self._load_frames() - times, raw, newtons, valid, frame_ids, dropped = self._load_forces() + frames, frames_ordered = self._load_frames() + index = self.index() + total_rows = index.rows + decimated = total_rows > self.max_rows > 0 + if decimated: + window = self.window_all( + max_points=self.max_rows + ) + times = window.times + raw = window.raw + newtons = window.newtons + valid = window.valid + frame_ids = window.frame_ids + forces_ordered = True + dropped = 0 + else: + (times, raw, newtons, valid, frame_ids, dropped, forces_ordered) = ( + self._load_forces() + ) + total_duration = 0.0 + if frames: + total_duration = float(frames[-1].t_rel_s) + elif times.size: + total_duration = float(times[-1]) return RecordingInfo( directory=self.directory, session=self.session(), @@ -129,12 +201,26 @@ class RecordingReader: frame_ids=frame_ids, dropped_rows=dropped, frames_dropped=sum(1 for frame in frames if frame.dropped), + frames_out_of_order=frames_ordered, + forces_out_of_order=forces_ordered, + decimated=decimated, + total_rows=total_rows, + total_duration_s=total_duration, ) - def _load_frames(self) -> list[RecordingFrame]: + def _load_frames(self) -> tuple[list[RecordingFrame], bool]: + """Load the frame index in time order. + + Recordings made before the ordered-write fix (see + ``ExperimentRecorder``) can contain a frame index whose rows are not in + time order, because the row backlog used to be drained before the queue. + ``frame_at`` does a binary search over these times, so an unsorted index + would silently answer with the wrong frame. The rows are sorted here and + the fact is reported as a replay warning. + """ path = self.directory / "frames.csv" if not path.is_file(): - return [] + return [], False rows: list[RecordingFrame] = [] with path.open(encoding="utf-8", newline="") as handle: for row in csv.DictReader(handle): @@ -152,13 +238,26 @@ class RecordingReader: ) except (KeyError, TypeError, ValueError): continue - return rows + ordered = _is_time_ordered([frame.t_rel_s for frame in rows]) + if not ordered: + rows.sort(key=lambda frame: frame.t_rel_s) + return rows, ordered def _load_forces(self): path = self.directory / "forces.csv" if not path.is_file(): + # A directory with no data (an empty or half-written recording) is + # readable: it simply has no samples, and nothing is out of order. empty = np.empty(0, dtype=np.float64) - return empty, np.empty((0, 3)), np.empty((0, 3)), np.empty(0, bool), np.empty(0, int), 0 + return ( + empty, + np.empty((0, 3)), + np.empty((0, 3)), + np.empty(0, bool), + np.empty(0, int), + 0, + True, + ) def parse(text: str) -> float: text = (text or "").strip() @@ -191,23 +290,42 @@ class RecordingReader: (parse(row.get("FN_N")), parse(row.get("FX_N")), parse(row.get("FY_N"))) ) valid.append((row.get("valid") or "0").strip() == "1") - return ( - np.asarray(times, dtype=np.float64), - np.asarray(raw, dtype=np.float64).reshape(-1, 3), - np.asarray(newtons, dtype=np.float64).reshape(-1, 3), - np.asarray(valid, dtype=bool), - np.asarray(frame_ids, dtype=np.int64), - dropped, - ) + times_arr = np.asarray(times, dtype=np.float64) + raw_arr = np.asarray(raw, dtype=np.float64).reshape(-1, 3) + newtons_arr = np.asarray(newtons, dtype=np.float64).reshape(-1, 3) + valid_arr = np.asarray(valid, dtype=bool) + ids_arr = np.asarray(frame_ids, dtype=np.int64) + + # Older recordings can have rows out of time order (the writer used to + # drain a backlog before the queue). Every window query below assumes + # sorted time, so sort once here and report it instead of answering + # ``searchsorted`` on unsorted input. + ordered = _is_time_ordered(times) + if not ordered and times_arr.size: + order = np.argsort(times_arr, kind="stable") + times_arr = times_arr[order] + raw_arr = raw_arr[order] + newtons_arr = newtons_arr[order] + valid_arr = valid_arr[order] + ids_arr = ids_arr[order] + return times_arr, raw_arr, newtons_arr, valid_arr, ids_arr, dropped, ordered # ------------------------------------------------------------------ queries def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]: - """The recorded frame at or just before ``t_rel_s``.""" + """The recorded frame at or just before ``t_rel_s``. + + The index is sorted at load time, so the binary search is valid; the + fallback walk keeps a hand-edited or truncated file from answering with + the wrong frame. + """ frames = self.info().frames if not frames: return None - times = [frame.t_rel_s for frame in frames] - index = int(np.searchsorted(np.asarray(times), float(t_rel_s), side="right")) - 1 + times = np.asarray([frame.t_rel_s for frame in frames], dtype=np.float64) + if not _is_time_ordered(times.tolist()): + candidates = [frame for frame in frames if frame.t_rel_s <= float(t_rel_s)] + return max(candidates, key=lambda frame: frame.t_rel_s) if candidates else frames[0] + index = int(np.searchsorted(times, float(t_rel_s), side="right")) - 1 index = max(0, min(index, len(frames) - 1)) return frames[index] @@ -318,6 +436,47 @@ class RecordingReader: mask = (info.times >= float(t0)) & (info.times <= float(t1)) return info.times[mask], info.raw[mask] + # ------------------------------------------------------ on-demand history + def window( + self, + t0: float, + t1: float, + *, + unit: str = "raw", + max_points: int | None = None, + ) -> ForceWindow: + """The recorded rows inside ``[t0, t1]``, read from disk. + + This is the interval read: it seeks to the nearest checkpoint in the + index and scans forward, so asking for ten seconds of an hour-long + recording costs ten seconds of parsing, not an hour. ``max_points`` + decimates *while* reading, so the interval never has to exist in memory + in full. + """ + start, stop = self.index().row_range(t0, t1) + return self.index().read_rows( + start, stop, max_points=max_points, unit=unit + ) + + def window_all(self, *, unit: str = "raw", max_points: int | None = None) -> ForceWindow: + """The whole recording as one window, decimated while streaming.""" + return self.window( + *self.index().extent(), unit=unit, max_points=max_points + ) + + def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview: + """A whole-session envelope, cached per (unit, bucket count).""" + key = (str(unit), int(buckets)) + if key not in self._overviews: + self._overviews[key] = self.index().overview( + unit=str(unit), buckets=int(buckets) + ) + return self._overviews[key] + + def statistics(self, t0: float, t1: float, *, unit: str = "raw"): + """Statistics of an interval, computed from the interval's own rows.""" + return self.window(t0, t1, unit=unit).statistics(unit) + # ------------------------------------------------------------------ policy def depth_policy(self) -> dict: return dict(self.info().session.get("depth") or {}) @@ -372,6 +531,8 @@ class RecordingReader: warnings.append("rows_skipped") if self.info().frames_dropped: warnings.append("frames_dropped") + if not self.info().frames_out_of_order or not self.info().forces_out_of_order: + warnings.append("out_of_order") return warnings def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]: @@ -400,8 +561,56 @@ class RecordingReader: yield np.asarray(times), np.asarray(values).reshape(-1, 6) +def _is_time_ordered(values) -> bool: + """True when ``values`` never decreases (an empty or single value is ordered).""" + previous = None + for value in values: + if previous is not None and float(value) < float(previous): + return False + previous = value + return True + + +def decimate_window(window: ForceWindow, max_points: int, *, unit: str = "raw") -> ForceWindow: + """Reduce a window to about ``max_points``, keeping every axis' extremes. + + The same rule the live store uses: each bucket contributes the extreme row of + **every plotted axis** in the active unit, so a shear spike survives a flat + normal channel. The result is marked ``decimated``. + """ + total = len(window) + max_points = max(4, int(max_points)) + if total <= max_points: + return window + values = window.value_for_unit(unit) + def index_of(row: int) -> int: + return int(window.frame_ids[row]) if total else row + bucket = max(1, total // max(1, max_points // 6)) + picked: set[int] = set() + for start in range(0, total, bucket): + stop = min(total, start + bucket) + for axis in range(3): + column = values[start:stop, axis] + finite = np.isfinite(column) + if not finite.any(): + continue + ordered = np.where(finite, column, -np.inf) + picked.add(start + int(np.argmin(ordered))) + picked.add(start + int(np.argmax(ordered))) + if not picked: + return window + rows = sorted(picked) + return ForceWindow( + times=window.times[rows], + raw=window.raw[rows], + newtons=window.newtons[rows], + valid=window.valid[rows], + frame_ids=window.frame_ids[rows], + decimated=True, + ) + + def list_recordings(root: str | Path) -> list[Path]: - """Every recording directory under ``root``, newest first.""" base = Path(root) if not base.is_dir(): return [] diff --git a/shared/src/orisys_app_shared/record/sample.py b/shared/src/orisys_app_shared/record/sample.py index 26e63f8..fb1a252 100644 --- a/shared/src/orisys_app_shared/record/sample.py +++ b/shared/src/orisys_app_shared/record/sample.py @@ -140,11 +140,16 @@ def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample: valid = bool(getattr(packet, "valid", True)) reason = str(getattr(packet, "invalid_reason", "") or "") stale = bool(getattr(packet, "stale_forces", False)) - # The packet's own version wins: it was stamped by the acquisition thread at - # the moment of acquisition, so it is the only value that stays true when a - # calibration is applied in the middle of a session. - version = str( - getattr(packet, "calibration_version", "") or calibration_version or "" + # A packet that *carries* a version owns it, including the empty one: the + # acquisition thread stamps every frame, and an empty stamp is the device + # saying "no calibration loaded". Falling back to the session default there + # would resurrect coefficients that are no longer in effect. A packet from an + # older producer that has no version field at all still gets the default. + packet_version = getattr(packet, "calibration_version", None) + version = ( + str(calibration_version or "") + if packet_version is None + else str(packet_version) ) axes = calibrated_axes_of(packet) @@ -214,3 +219,20 @@ def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool] bool(is_valid) and math.isfinite(float(value)) for value, is_valid in zip(values, valid) ] + + +def plottable_axis_mask(values, valid) -> "np.ndarray": + """``(n, axes)`` mask: one column per axis, decided for that axis alone. + + The rule has to be per axis. A single-axis calibration is legitimate — the + device may have a normal-force curve and no shear curves — and an all-axes + rule hid a perfectly good FN because FX and FY had no newton value. + """ + import numpy as np + + block = np.asarray(values, dtype=np.float64).reshape(-1, 3) + validity = np.asarray(valid, dtype=bool).reshape(-1) + finite = np.isfinite(block) + if validity.size != block.shape[0]: + return finite + return finite & validity[:, None] diff --git a/shared/src/orisys_app_shared/record/video_writer.py b/shared/src/orisys_app_shared/record/video_writer.py new file mode 100644 index 0000000..c7b85d2 --- /dev/null +++ b/shared/src/orisys_app_shared/record/video_writer.py @@ -0,0 +1,97 @@ +"""The video side of a recording: one rolling MP4 segment at a time. + +Kept apart from the CSV writer on purpose. The two have different failure modes — +a full disk breaks both, but a codec that refuses to open only breaks this one — +and the recorder's byte budget is about *these* frames, not about the rows. +""" + +from __future__ import annotations + +import logging +import time +from pathlib import Path +from typing import Optional + +import cv2 + +logger = logging.getLogger(__name__) + + +class VideoSegments: + """Writes raw frames into ``raw_NNNN.mp4`` files, rolling on a time budget.""" + + def __init__(self, directory: Path, *, codec: str, fps: float, segment_seconds: float): + self.directory = Path(directory) + self.codec = str(codec) + self.fps = max(1.0, float(fps)) + self.segment_seconds = float(segment_seconds) + self.segments = 0 + self.video_frames = 0 + self.frame_shape: Optional[tuple[int, int]] = None + self._writer: Optional[cv2.VideoWriter] = None + self._index = 0 + self._frames_in_segment = 0 + self._started = 0.0 + + # ------------------------------------------------------------------ writing + def write(self, frame) -> tuple[int, int]: + """Append one frame. Returns ``(segment, frame_in_segment)``.""" + if self.frame_shape is None: + self.frame_shape = (int(frame.shape[1]), int(frame.shape[0])) + if self._should_roll(): + self._close_segment() + if self._writer is None: + self._open_segment() + self._writer.write(frame) + self._frames_in_segment += 1 + self.video_frames += 1 + return (self._index, self._frames_in_segment) + + def write_sequence(self, frames) -> int: + """Write a run of frames as one standalone segment. Returns the count.""" + frames = [frame for frame in frames if frame is not None] + if not frames: + return 0 + self.frame_shape = (int(frames[0].shape[1]), int(frames[0].shape[0])) + name = "preroll_0001.mp4" + writer = self._open_writer(self.directory / name) + written = 0 + try: + for frame in frames: + writer.write(frame) + written += 1 + finally: + writer.release() + return written + + # ----------------------------------------------------------------- segments + def _should_roll(self) -> bool: + if self._writer is None: + return False + if self.segment_seconds <= 0: + return False + return (time.perf_counter() - self._started) >= self.segment_seconds + + def _open_writer(self, path: Path) -> cv2.VideoWriter: + fourcc = cv2.VideoWriter_fourcc(*self.codec) + writer = cv2.VideoWriter(str(path), fourcc, self.fps, self.frame_shape) + if not writer.isOpened(): + raise OSError(f"could not open video writer for {path.name}") + return writer + + def _open_segment(self) -> None: + self._index += 1 + path = self.directory / f"raw_{self._index:04d}.mp4" + self._writer = self._open_writer(path) + self._frames_in_segment = 0 + self._started = time.perf_counter() + self.segments = self._index + + def _close_segment(self) -> None: + if self._writer is None: + return + self._writer.release() + self._writer = None + + def close(self) -> None: + self._close_segment() diff --git a/shared/tests/test_calibration_identity.py b/shared/tests/test_calibration_identity.py new file mode 100644 index 0000000..25ef583 --- /dev/null +++ b/shared/tests/test_calibration_identity.py @@ -0,0 +1,280 @@ +"""Calibration identity and operator events as the acquisition thread sees them. + +The review's P1-7: the application event was written before the device had loaded +anything, the recorded ``calibration_id`` never changed mid-session, and an +uncalibrated device could keep claiming the coefficients of an earlier session. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker +from orisys_app_shared.record import ExperimentRecorder, RecordConfig +from orisys_app_shared.record.calibration_snapshot import ( + list_calibration_snapshots, + snapshot_name, + store_calibration_snapshot, +) + +SCRATCH = Path(__file__).resolve().parent / "_scratch" + + +def _root() -> Path: + shutil.rmtree(SCRATCH, ignore_errors=True) + SCRATCH.mkdir(parents=True, exist_ok=True) + return SCRATCH + + +class _Sensor: + """A device with a swappable calibration, like the SDK's Sensor.""" + + def __init__(self, calibration=None, path=None): + self.calibration = calibration + self.calibration_path = path + self.applied: list[str] = [] + self.resets = 0 + + def set_calibration(self, path): + if str(path) == "reject-me": + raise ValueError("bad document") + self.calibration = object() + self.calibration_path = Path(path) + self.applied.append(str(path)) + + def reset(self): + self.resets += 1 + + +def _calibration_file(root: Path, name: str, body: str = '{"unit": "N"}') -> Path: + path = root / name + path.write_text(body, encoding="utf-8") + return path + + +def _worker(**kwargs) -> SdkAcquisitionWorker: + for key in ("on_calibration_applied", "on_reset_applied"): + kwargs.setdefault(key, None) + return SdkAcquisitionWorker(**kwargs) + + +# ------------------------------------------------------------------ worker state + + +def test_a_new_session_does_not_inherit_the_previous_version(): + """An uncalibrated device must report no calibration, not the last one.""" + root = _root() + worker = _worker() + worker._calibration_version = "previous-session-hash" + worker._stop.set() + worker.start("clip.mp4", config_ref=str(_calibration_file(root, "sensor.json"))) + assert worker._calibration_version == "", ( + "the new session kept the previous session's calibration version" + ) + worker.stop(wait=True) + + +def test_the_applied_version_is_the_content_hash_and_carries_the_frame(): + root = _root() + applied: list[tuple] = [] + worker = _worker(on_calibration_applied=lambda *args: applied.append(args)) + path = _calibration_file(root, "force_calibration.json") + sensor = _Sensor() + worker._session_id = 4 + worker.set_pending_calibration(path) + worker._consume_pending_calibration(sensor, frame_id=17, mono_t=0.5) + + assert len(applied) == 1 + got_path, version, frame_id, mono_t, session = applied[0] + assert got_path == str(path) + assert version and version == worker._calibration_version + assert version != str(path), "the path was used as the version" + assert (frame_id, mono_t, session) == (17, 0.5, 4) + assert sensor.applied == [str(path)] + + +def test_a_rejected_calibration_keeps_the_version_the_device_still_has(): + root = _root() + applied: list[tuple] = [] + worker = _worker(on_calibration_applied=lambda *args: applied.append(args)) + sensor = _Sensor() + worker._session_id = 1 + worker._calibration_version = "loaded-earlier" + worker.set_pending_calibration("reject-me") + with pytest.raises(ValueError): + worker._consume_pending_calibration(sensor, frame_id=3, mono_t=0.1) + assert not applied, "a rejected document was reported as applied" + assert worker._calibration_version == "loaded-earlier", ( + "the device still has those coefficients, so the version is still true" + ) + + +def test_a_zeroing_is_applied_by_the_worker_and_reported_with_its_frame(): + root = _root() + events: list[tuple] = [] + worker = _worker(on_reset_applied=lambda *args: events.append(args)) + sensor = _Sensor() + worker._session_id = 2 + worker.reset() # a request, not the zeroing itself + assert sensor.resets == 0, "the zeroing ran on the caller's thread" + worker._consume_pending_reset(sensor, frame_id=42, mono_t=1.4) + assert sensor.resets == 1 + assert events == [(42, 1.4, 2)] + # The request is consumed once. + worker._consume_pending_reset(sensor, frame_id=43, mono_t=1.5) + assert events == [(42, 1.4, 2)] + + +def test_a_started_session_drops_a_stale_zeroing_request(): + root = _root() + events: list[tuple] = [] + worker = _worker(on_reset_applied=lambda *args: events.append(args)) + worker._stop.set() + worker.start("clip.mp4", config_ref=str(_calibration_file(root, "sensor.json"))) + sensor = _Sensor() + worker._consume_pending_reset(sensor, frame_id=1, mono_t=0.0) + assert sensor.resets == 0, "a reset requested before this session fired into it" + assert not events + worker.stop(wait=True) + + +# ------------------------------------------------------------------- snapshots + + +def test_a_snapshot_is_content_addressed_and_listed(): + root = _root() + directory = root / "session" + source = _calibration_file(root, "force_calibration_box.json", '{"a": 1}') + name = store_calibration_snapshot(directory, source, "abcdef1234567890") + assert name == "calibration/force_calibration_abcdef123456.json" + assert (directory / name).is_file() + assert (directory / name).read_text(encoding="utf-8") == '{"a": 1}' + # Applying the same coefficients twice stores one file. + again = store_calibration_snapshot(directory, source, "abcdef1234567890") + assert again == name + assert list_calibration_snapshots(directory) == [name] + # A different calibration cannot collide with it. + other = _calibration_file(root, "force_calibration_box.json", '{"a": 2}') + second = store_calibration_snapshot(directory, other, "ffffff000000") + assert second != name + assert len(list_calibration_snapshots(directory)) == 2 + assert snapshot_name("", source).startswith("force_calibration_") + + +def test_the_recording_metadata_names_the_coefficients_it_ran_with(): + root = _root() + recorder = ExperimentRecorder(RecordConfig(video=False)) + directory = recorder.start(root=root, session_id=1, metadata={}) + source = _calibration_file(root, "force_calibration_box.json", '{"a": 1}') + name = store_calibration_snapshot(directory, source, "abcdef1234567890") + recorder.note_calibration_snapshot( + version="abcdef1234567890", name=name, frame_id=12, mono_t=0.4 + ) + recorder.log_event( + "calibration_applied", name, mono_t=0.4, frame_id=12 + ) + recorder.stop() + + meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8")) + snapshots = meta["calibration_snapshots"] + assert snapshots and snapshots[0]["version"] == "abcdef1234567890" + assert snapshots[0]["file"] == name + assert snapshots[0]["frame_id"] == 12 + assert snapshots[0]["mono_t"] == pytest.approx(0.4) + event = next(e for e in meta["events"] if e["kind"] == "calibration_applied") + assert event["frame_id"] == 12 + assert event["mono_t"] == pytest.approx(0.4), "the event lost its acquisition time" + assert event["at"], "the event lost its wall clock" + + # The CSV carries the same acquisition time in the session's relative base. + # An event that predates the first frame has no base yet, so its relative + # time is empty — the absolute (session-relative) time is in the metadata. + with (Path(directory) / "events.csv").open(encoding="utf-8", newline="") as handle: + import csv as _csv + + rows = list(_csv.DictReader(handle)) + applied = next(row for row in rows if row["kind"] == "calibration_applied") + assert applied["frame_id"] == "12" + assert applied["t_rel_s"] == "", "an event before the first frame invented a base" + + +def test_an_event_during_the_recording_gets_the_session_relative_time(): + import csv as _csv + + from orisys_app_shared.acquisition.sdk_worker import SdkPacket + + root = _root() + recorder = ExperimentRecorder(RecordConfig(video=False)) + directory = recorder.start(root=root, session_id=1, metadata={}) + frame = np.zeros((4, 4, 3), np.uint8) + recorder.on_packet( + SdkPacket( + raw_frame=frame, + processed_frame=frame, + timestamp=1000.0, + mono_t=0.5, + fps=30.0, + process_ms=1.0, + frame_id=7, + ) + ) + recorder.log_event("zeroing", "tracker reset", mono_t=0.9, frame_id=9) + recorder.stop() + + with (Path(directory) / "events.csv").open(encoding="utf-8", newline="") as handle: + rows = list(_csv.DictReader(handle)) + zeroing = next(row for row in rows if row["kind"] == "zeroing") + assert float(zeroing["t_rel_s"]) == pytest.approx(0.4, abs=1e-6) + assert zeroing["frame_id"] == "9" + + +def test_an_event_without_a_mono_time_still_gets_a_wall_clock(): + root = _root() + recorder = ExperimentRecorder(RecordConfig(video=False)) + directory = recorder.start(root=root, session_id=1, metadata={}) + recorder.log_event("operator_note", "no frame attached") + recorder.stop() + meta = json.loads((Path(directory) / "session.json").read_text(encoding="utf-8")) + event = next(e for e in meta["events"] if e["kind"] == "operator_note") + assert event["at"], "the event lost its wall clock" + assert event["frame_id"] == -1 + + +def test_the_recorded_row_uses_the_packet_version_not_the_session_default(): + from orisys_app_shared.record.sample import to_record_sample + + class Packet: + frame_id = 3 + mono_t = 0.2 + wall_t = 0.2 + valid = True + calibration_version = "" + calibrated_axes = ("fn",) + force_calibrated = True + raw_available = True + + # An empty packet version is the device saying "no calibration": the session + # default must not be pasted in behind its back. + assert to_record_sample(Packet(), calibration_version="session-default").calibration_version == "" + + class LegacyPacket(Packet): + calibration_version = None # an older producer without the field + + # ...but a packet that has no version field at all still gets the default. + assert ( + to_record_sample(LegacyPacket(), calibration_version="session-default").calibration_version + == "session-default" + ) + + class StampedPacket(Packet): + calibration_version = "packet-hash" + + assert ( + to_record_sample(StampedPacket(), calibration_version="session-default").calibration_version + == "packet-hash" + ) diff --git a/shared/tests/test_calibration_model.py b/shared/tests/test_calibration_model.py index d411fc4..c75126a 100644 --- a/shared/tests/test_calibration_model.py +++ b/shared/tests/test_calibration_model.py @@ -8,6 +8,9 @@ model. from __future__ import annotations +import shutil +from pathlib import Path + import pytest from orisys_app_shared.force.calibration_io import ( @@ -17,10 +20,19 @@ from orisys_app_shared.force.calibration_io import ( build_force_calibration_v2, merge_samples, predict_channel, + write_force_calibration, ) from orisys_app_shared.force.calibration_recorder import CalibrationRecorder from orisys_app_shared.force.session import SessionSample +SCRATCH = Path(__file__).resolve().parent / "_scratch" + + +def _root() -> Path: + shutil.rmtree(SCRATCH, ignore_errors=True) + SCRATCH.mkdir(parents=True, exist_ok=True) + return SCRATCH + def _point(recorder, load: float, raw: float, *, channel: str = "fn"): recorder.start(channel=channel, reference_load_n=load, duration_s=0.05) @@ -151,3 +163,84 @@ def test_a_channel_with_one_point_is_not_exported(): store.aggregate("fn", CalSample(weight_n=1.0, raw_mean=10.0, n=10)) with pytest.raises(ValueError): build_force_calibration_v2(store) + + +# ---------------------------------------------------------------- one preparation + + +def _session_with_duplicates_and_validation(): + recorder = CalibrationRecorder() + points = [ + _point(recorder, 1.0, 100.0), + _point(recorder, 2.0, 200.0), + # A deliberate repeat of load 2.0 at a different raw level. + _point(recorder, 2.0, 240.0), + _point(recorder, 4.0, 400.0), + _point(recorder, 3.0, 290.0), + ] + recorder.set_validation(points[4], True) + return recorder, points + + +def test_duplicate_loads_are_merged_before_the_model_is_built(): + _recorder, _points = _session_with_duplicates_and_validation() + prepared = _recorder.prepared() + assert prepared.samples["fn"], "no training samples" + loads = [sample.weight_n for sample in prepared.samples["fn"]] + assert loads == sorted(set(loads)), f"duplicate loads were not merged: {loads}" + assert len(loads) == 3 + merged = next(sample for sample in prepared.samples["fn"] if sample.weight_n == 2.0) + assert merged.n == 16, "the repeat's samples were not combined" + assert merged.raw_mean == pytest.approx((200.0 + 240.0) / 2.0) + assert prepared.points_in_fit("fn") == 3 + + +def test_the_exported_file_predicts_exactly_what_the_panel_shows(): + """Read the written document back and compare it with the diagnostics. + + The panel's numbers must describe the file that is actually written, so the + test loads the file with the SDK (not with the prepared object) and predicts + every training and validation point from it. + """ + import orisys.calibration as sdk_calibration + + recorder, points = _session_with_duplicates_and_validation() + prepared = recorder.prepared(meta={"sensor_type": "box", "sn": "SN9"}) + assert prepared.ok + + document = prepared.to_document() + path = _root() / "force_calibration.json" + write_force_calibration(document, path) + from_file = sdk_calibration.load(path, strict=True) + + for point in points: + raw = float(point.loaded_stats().mean) + predicted = float(from_file.get("fn").convert(raw)) + value = prepared.predict("fn", raw) + assert predicted == pytest.approx(value, abs=1e-9) + if point.is_validation: + assert prepared.validation_errors[id(point)] == pytest.approx( + abs(predicted - point.reference_load_n), abs=1e-9 + ) + else: + assert prepared.residuals[id(point)] == pytest.approx( + predicted - point.reference_load_n, abs=1e-9 + ) + + # The validation point is measured, never built in. + assert prepared.validation_points("fn") == 1 + assert prepared.max_validation_error("fn") > 0.0 + exported_raws = sorted(point[0] for point in document["channels"]["normal"]["points"]) + assert exported_raws == [100.0, 220.0, 400.0], exported_raws + + +def test_the_recorder_fit_and_the_preparation_agree(): + recorder, _points = _session_with_duplicates_and_validation() + prepared = recorder.prepared() + fit = recorder.fit("fn", prepared=prepared) + assert fit.n_points == prepared.points_in_fit("fn") + assert fit.breakpoints == prepared.breakpoints("fn") + assert fit.max_residual_n == pytest.approx(prepared.max_residual("fn")) + assert fit.validation_error_n == pytest.approx(prepared.max_validation_error("fn")) + # ...and a fit that prepares on its own gets the same numbers. + assert recorder.fit("fn").as_dict() == fit.as_dict() diff --git a/shared/tests/test_calibration_record.py b/shared/tests/test_calibration_record.py index 2841350..06bf66e 100644 --- a/shared/tests/test_calibration_record.py +++ b/shared/tests/test_calibration_record.py @@ -31,15 +31,15 @@ def _root() -> Path: return SCRATCH -def _recorded_point(index_offset: float = 0.0): +def _recorded_point(load: float = 2.0, offset: float = 0.0): recorder = CalibrationRecorder() - recorder.start(channel="fn", reference_load_n=2.0, duration_s=0.05) + recorder.start(channel="fn", reference_load_n=load, duration_s=0.05) for index in range(6): recorder.feed( SessionSample( index, index * 1.0 / 30.0, - fn_raw=float(index) + index_offset, + fn_raw=float(index) + offset, fx_raw=1.0, fy_raw=2.0, fn_n=float(index) * 10.0, @@ -126,9 +126,9 @@ def test_a_newer_format_is_loaded_with_a_warning(): def test_the_recorder_can_reopen_a_saved_session(): points = [ - _recorded_point(), - _recorded_point(5.0), - _recorded_point(9.0), + _recorded_point(1.0), + _recorded_point(2.0, 5.0), + _recorded_point(3.0, 9.0), ] path = write_calibration_session(_root() / "session.json", points) restored = read_calibration_session(path).points @@ -141,3 +141,76 @@ def test_the_recorder_can_reopen_a_saved_session(): fit = recorder.fit("fn") assert fit.validation_points == 1 assert fit.n_points == 2 + + +def test_reopened_points_at_one_load_are_one_calibrated_load(): + """Three points at the same reference load are one load, not a curve. + + They are merged (n-weighted) before the build, so a session that only ever + sampled one load cannot produce a model — which is the honest answer. + """ + recorder = CalibrationRecorder() + recorder.load_points( + [_recorded_point(2.0), _recorded_point(2.0, 5.0), _recorded_point(2.0, 9.0)], + replace=True, + ) + prepared = recorder.prepared() + assert len(prepared.training) == 3 + assert len(prepared.samples["fn"]) == 1 + assert prepared.samples["fn"][0].n == 15, "the valid counts were not summed" + assert prepared.model is None, "a single load produced a model" + assert recorder.fit("fn").n_points == 1 + + +# ------------------------------------------------------- reopened diagnostics + + +def test_a_reopened_record_keeps_its_device_identity(): + """Device A's record stays A's, whatever is connected now.""" + points = [_recorded_point(1.0), _recorded_point(2.0, 5.0)] + path = write_calibration_session( + _root() / "session.json", + points, + meta={ + "sensor_type": "box", + "sn": "SN-A", + "config_ref": "D:/lab/deviceA/sensor.json", + }, + ) + record = read_calibration_session(path) + identity = record.identity() + assert identity["sensor_type"] == "box" + assert identity["sn"] == "SN-A" + assert identity["config_ref"] == "D:/lab/deviceA/sensor.json" + assert identity["created_at"] == record.created_at + assert "SN-A" in record.describe() + + +def test_the_three_axis_trace_is_rebuilt_from_the_samples(): + """A record whose trace was never written must still be inspectable.""" + import json + + point = _recorded_point(1.0) + document = build_session_document([point]) + # Simulate a document that only kept the audit series (or an older writer + # that stored a truncated trace). + document["points"][0]["trace"] = {} + document["points"][0]["duration_s"] = 0.0 + path = _root() / "session.json" + path.write_text(json.dumps(document), encoding="utf-8") + + restored = read_calibration_session(path).points[0] + assert set(restored.trace) == {"fn", "fx", "fy"}, restored.trace + assert len(restored.trace["fn"]) == 5 # the valid samples + assert restored.trace_dt_s == pytest.approx(1.0 / 30.0, abs=1e-6) + assert restored.duration_s == pytest.approx(5.0 / 30.0, abs=1e-6) + + +def test_a_stored_trace_is_not_overwritten_on_reopen(): + point = _recorded_point(1.0) + document = build_session_document([point]) + document["points"][0]["trace"] = {"fn": [1.0, 2.0]} + path = _root() / "session.json" + path.write_text(json.dumps(document), encoding="utf-8") + restored = read_calibration_session(path).points[0] + assert restored.trace["fn"] == [1.0, 2.0] diff --git a/shared/tests/test_depth_replay.py b/shared/tests/test_depth_replay.py index e94cae9..cec0a90 100644 --- a/shared/tests/test_depth_replay.py +++ b/shared/tests/test_depth_replay.py @@ -16,7 +16,12 @@ from unittest.mock import patch import numpy as np import pytest -from orisys_app_shared.record.depth import DepthRecomputer +from orisys_app_shared.record.depth import ( + STATE_APPROXIMATE, + STATE_EXACT, + STATE_PREROLL, + DepthRecomputer, +) SCRATCH = Path(__file__).resolve().parent / "_scratch" @@ -36,6 +41,12 @@ class _CountingSensor: _CountingSensor.opened.append({"args": args, "kwargs": kwargs}) self.n = 0 self.chain_calls = 0 + self.initial_img = "reference-frame" + # The real Sensor opens its source in the constructor, so the first + # source is part of its history too. + self.vid_src = str(args[0]) if args else "" + self.sources: list[str] = [self.vid_src] if self.vid_src else [] + self.resets = 0 self.info = SimpleNamespace(DEPTH=1) def get_img(self): @@ -48,9 +59,17 @@ class _CountingSensor: def compute_contact(self): self.chain_calls += 1 + def reset(self): + self.resets += 1 + def read_info(self, _channel): return np.array([self.n]) + def _open_video_source(self, _override, _box): + self.sources.append(str(self.vid_src)) + # A real reopen restarts the file from its first frame. + self.n = 0 + def disconnect(self): return None @@ -116,13 +135,18 @@ def test_stepping_continues_from_the_current_position(): computer.close() -def test_stepping_backwards_reopens_the_segment(): +def test_stepping_backwards_reopens_the_source_but_keeps_the_processor(): _CountingSensor.opened.clear() computer = DepthRecomputer(_reader(_session("/cfg/sensor.json"))) with patch("orisys.Sensor", _CountingSensor): assert computer._depth_from_segment(Path("raw_0001.mp4"), 5).tolist() == [5] + # Reading an earlier frame restarts the file; the processor object (and + # therefore the tracker) is not thrown away. assert computer._depth_from_segment(Path("raw_0001.mp4"), 2).tolist() == [2] - assert len(_CountingSensor.opened) == 2, "seeking backwards needs a fresh source" + sensor = computer._sensor._sensor + assert len(_CountingSensor.opened) == 1, "the processor was recreated" + assert len(sensor.sources) == 2 + assert all(source.endswith("raw_0001.mp4") for source in sensor.sources) computer.close() @@ -165,11 +189,44 @@ def test_the_plan_starts_at_the_last_reset_in_the_same_segment(): computer = DepthRecomputer(reader) plan = computer.plan_for(frames[8]) assert plan.stop_at == 9 + assert plan.anchor == "reset" + assert plan.state == STATE_EXACT + assert plan.reset_frame_id == 6 + assert len(plan.steps) == 1 + step = plan.steps[0] + assert step.source == reader.directory / "raw_0001.mp4" + assert step.to_frame == 9 + assert step.reset_after == 7, "the reset boundary is not re-applied by the plan" + assert plan.frames_to_read == 9 assert plan.preroll is None assert plan.warnings == () computer.close() +def test_a_reset_that_is_not_in_the_video_is_not_a_recovered_state(): + """Finding the event is not the same as being able to start there.""" + frames = [ + SimpleNamespace( + frame_id=index, + t_rel_s=index / 30.0, + segment=1, + segment_frame=index + 1, + in_video=index != 6, # the reset frame never reached the video + dropped=False, + ) + for index in range(10) + ] + reader = _reader(_session("/cfg/sensor.json"), frames=frames) + reader.last_reset_frame = lambda frame_id: 6 + computer = DepthRecomputer(reader) + plan = computer.plan_for(frames[8]) + assert plan.anchor != "reset" + assert plan.state == STATE_APPROXIMATE + assert "reset_frame_not_in_video" in plan.warnings + assert "state_approximate" in plan.warnings + computer.close() + + def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording(): frames = [ SimpleNamespace( @@ -195,6 +252,8 @@ def test_the_plan_uses_the_pre_roll_for_the_start_of_a_recording(): plan = computer.plan_for(frames[3]) assert plan.preroll is not None assert plan.preroll_frames == 30 + assert plan.anchor == "preroll" + assert plan.state == STATE_PREROLL assert plan.warnings == () computer.close() @@ -215,10 +274,127 @@ def test_a_segment_without_history_is_flagged_approximate(): reader = _reader(_session("/cfg/sensor.json"), frames=frames) computer = DepthRecomputer(reader) plan = computer.plan_for(frames[2]) + assert plan.state == STATE_APPROXIMATE + assert plan.anchor == "segment_start" assert "state_approximate" in plan.warnings computer.close() +def test_a_reset_in_an_earlier_segment_is_carried_across_by_swapping_the_source(): + """The processor survives the swap, so the state *can* cross a segment.""" + frames = [ + SimpleNamespace( + frame_id=index, + t_rel_s=index / 30.0, + segment=1 if index < 3 else 2, + segment_frame=index + 1 if index < 3 else index - 2, + in_video=True, + dropped=False, + ) + for index in range(6) + ] + reader = _reader( + _session("/cfg/sensor.json"), + segments=("raw_0001.mp4", "raw_0002.mp4"), + frames=frames, + ) + reader.last_reset_frame = lambda frame_id: 1 + computer = DepthRecomputer(reader) + plan = computer.plan_for(frames[5]) + assert plan.anchor == "reset" + assert plan.state == STATE_EXACT + assert "state_across_segment" not in plan.warnings + assert [step.source.name for step in plan.steps] == ["raw_0001.mp4", "raw_0002.mp4"] + assert plan.steps[0].reset_after == 2 + assert plan.steps[1].to_frame == 3 + computer.close() + + +def test_a_replay_that_would_read_too_far_back_is_reported_not_attempted(): + frames = [ + SimpleNamespace( + frame_id=index, + t_rel_s=index / 30.0, + segment=1, + segment_frame=index + 1, + in_video=True, + dropped=False, + ) + for index in range(20) + ] + reader = _reader(_session("/cfg/sensor.json"), frames=frames) + reader.last_reset_frame = lambda frame_id: 1 + computer = DepthRecomputer(reader) + with patch("orisys_app_shared.record.depth.MAX_REPLAY_FRAMES", 5): + plan = computer.plan_for(frames[10]) + assert "state_too_far_back" in plan.warnings + assert plan.state == STATE_APPROXIMATE + computer.close() + + +def test_the_same_frame_is_answered_from_the_cache(): + """A second request for the frame just read is a cache hit, not None.""" + _CountingSensor.opened.clear() + computer = DepthRecomputer(_reader(_session("/cfg/sensor.json"))) + with patch("orisys.Sensor", _CountingSensor): + first = computer._depth_from_segment(Path("raw_0001.mp4"), 3) + second = computer._depth_from_segment(Path("raw_0001.mp4"), 3) + sensor = computer._sensor._sensor + assert first.tolist() == [3] + assert second is not None and second.tolist() == [3] + assert sensor.n == 3, "the cached answer re-read the stream" + computer.close() + + +def test_the_processor_survives_a_pre_roll_to_segment_switch(): + """Only the video source is replaced; the tracker state must survive.""" + _CountingSensor.opened.clear() + reader = _reader(_session("/cfg/sensor.json")) + preroll = reader.directory / "preroll_0001.mp4" + preroll.write_bytes(b"") + computer = DepthRecomputer(reader) + with patch("orisys.Sensor", _CountingSensor): + computer._feed(preroll, 2) + sensor = computer._sensor._sensor + assert sensor.n == 2, "the pre-roll frames were not fed" + # Feed the segment: same processor, new source, position restarted. + computer._feed(reader.directory / "raw_0001.mp4", 1) + assert len(_CountingSensor.opened) == 1, "the processor was recreated" + assert computer._sensor._sensor is sensor + assert len(sensor.sources) == 2 + assert sensor.sources[0].endswith("preroll_0001.mp4") + assert sensor.sources[1].endswith("raw_0001.mp4") + assert sensor.initial_img == "reference-frame", "the tracker reference was reset" + computer.close() + + +def test_a_reset_boundary_is_re_applied_during_the_replay(): + """The live zeroing cleared the tracker; the replay must do the same.""" + _CountingSensor.opened.clear() + frames = [ + SimpleNamespace( + frame_id=index, + t_rel_s=index / 30.0, + segment=1, + segment_frame=index + 1, + in_video=True, + dropped=False, + ) + for index in range(10) + ] + reader = _reader_with_config(frames) + reader.last_reset_frame = lambda frame_id: 6 + reader.frame_at = lambda _t: frames[8] + computer = DepthRecomputer(reader) + with patch("orisys.Sensor", _CountingSensor): + result = computer.depth_at(frames[8].t_rel_s) + sensor = computer._sensor._sensor + assert result.ok, result.error + assert sensor.resets == 1, "the reset boundary was not re-applied" + assert result.warnings == (), result.warnings + computer.close() + + def test_a_dropped_frame_is_reported_not_substituted(): """The index marks the hole; replay must say so instead of showing a neighbour.""" frames = [ diff --git a/shared/tests/test_display_units.py b/shared/tests/test_display_units.py new file mode 100644 index 0000000..3cec0f9 --- /dev/null +++ b/shared/tests/test_display_units.py @@ -0,0 +1,139 @@ +"""Display unit selection and the per-axis drawing rule. + +Two defects are covered here: + +* the live charts chose their unit from the device-level ``force_calibrated`` + flag, so a device with only its normal-force axis calibrated either showed + every axis in raw counts or showed raw counts inside newton panels; +* the drawing rule asked for **all three** axes to be finite, so a valid FN + disappeared from the chart whenever FX/FY had no calibrated value. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from orisys_app_shared.acquisition.sdk_worker import SdkPacket +from orisys_app_shared.force import select_display_forces +from orisys_app_shared.force.session import SessionSample, SessionStore +from orisys_app_shared.record.sample import to_record_sample + +NAN = float("nan") + + +def _packet(**overrides) -> SdkPacket: + frame = np.zeros((4, 4, 3), np.uint8) + kwargs = dict( + raw_frame=frame, + processed_frame=frame, + timestamp=1000.0, + fps=30.0, + process_ms=1.0, + normal_force=1.5, + shear_x=0.25, + shear_y=-0.25, + normal_force_raw=123.0, + shear_x_raw=2.0, + shear_y_raw=-2.0, + force_calibrated=True, + calibrated_axes=("fn", "fx", "fy"), + frame_id=0, + ) + kwargs.update(overrides) + return SdkPacket(**kwargs) + + +def test_newton_mode_marks_uncalibrated_axes_unavailable(): + sample = to_record_sample(_packet(calibrated_axes=("fn",))) + display = select_display_forces(units_pref="newton", sample=sample) + assert display.unit == "N" + assert display.values[0] == pytest.approx(1.5) + assert math.isnan(display.values[1]) and math.isnan(display.values[2]) + assert display.available == (True, False, False) + assert display.unavailable_axes() == ("fx", "fy") + assert not display.using_raw_fallback + + +def test_raw_mode_shows_the_raw_counts_for_every_axis(): + sample = to_record_sample(_packet(calibrated_axes=("fn",))) + display = select_display_forces(units_pref="raw", sample=sample) + assert display.unit == "raw" + assert display.values == (123.0, 2.0, -2.0) + assert display.available == (True, True, True) + assert not display.using_raw_fallback + + +def test_an_uncalibrated_device_falls_back_to_raw_and_says_so(): + sample = to_record_sample( + _packet(force_calibrated=False, calibrated_axes=()) + ) + display = select_display_forces(units_pref="newton", sample=sample) + assert display.unit == "raw" + assert display.using_raw_fallback, "the fallback must be announceable" + assert display.requested_unit == "N" + assert display.values == (123.0, 2.0, -2.0) + + +def test_a_failed_readout_has_no_available_axis(): + sample = to_record_sample( + _packet(valid=False, invalid_reason="compute failed", stale_forces=True) + ) + for unit in ("N", "raw"): + display = select_display_forces(units_pref=unit, sample=sample) + assert display.available == (False, False, False) + assert all(math.isnan(value) for value in display.values) + + +def _store_with_one_calibrated_axis() -> SessionStore: + store = SessionStore() + for index in range(5): + store.append( + SessionSample( + index, + index / 30.0, + fn_raw=100.0 + index, + fx_raw=2.0, + fy_raw=-2.0, + fn_n=1.0 + index * 0.1, + fx_n=NAN, + fy_n=NAN, + valid=True, + calibrated_axes=("fn",), + ) + ) + return store + + +def test_a_valid_axis_is_drawn_even_when_the_others_have_no_value(): + """The all-axes rule hid a good FN because FX/FY were uncalibrated.""" + slice_ = _store_with_one_calibrated_axis().data() + times, values, mask = slice_.plottable("N") + assert mask.shape == (5, 3) + assert mask[:, 0].all(), "the calibrated axis was hidden" + assert not mask[:, 1].any() + assert not mask[:, 2].any() + + fn_times, fn_values = slice_.plottable_axis("N", 0) + assert fn_times.size == 5 == fn_values.size + assert float(fn_values.max()) == pytest.approx(1.4) + fx_times, _fx_values = slice_.plottable_axis("N", 1) + assert fx_times.size == 0 + + +def test_the_raw_triplet_is_still_complete_for_the_same_samples(): + slice_ = _store_with_one_calibrated_axis().data() + for index in range(3): + times, values = slice_.plottable_axis("raw", index) + assert times.size == 5 + assert np.isfinite(values).all() + + +def test_an_invalid_sample_is_excluded_from_every_axis(): + store = _store_with_one_calibrated_axis() + store.append(SessionSample(9, 9.0, fn_n=NAN, valid=False)) + slice_ = store.data() + _times, _values, mask = slice_.plottable("N") + assert not mask[-1].any(), "an invalid sample reached a drawing path" diff --git a/shared/tests/test_history_source.py b/shared/tests/test_history_source.py new file mode 100644 index 0000000..79e1fae --- /dev/null +++ b/shared/tests/test_history_source.py @@ -0,0 +1,218 @@ +"""Long-history browsing: interval reads, envelopes and one curve interface. + +The replay used to load every row of a recording before drawing anything, so a +multi-hour session could not be shown at all. These tests pin the two things that +make it possible — an interval read bounded by the interval, and a whole-session +envelope built while streaming the file once — and that the charts can read from +either a session store or a recording through the same interface. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from orisys_app_shared.acquisition.sdk_worker import SdkPacket +from orisys_app_shared.force.session import SessionSample, SessionStore +from orisys_app_shared.force.source import RecordingCurveSource, StoreCurveSource +from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordingReader +from orisys_app_shared.record.index import RecordingIndex + +SCRATCH = Path(__file__).resolve().parent / "_scratch_history" + +ROWS = 12_000 +SPIKE_ROW = 9_000 +SPIKE_VALUE = 999.0 + + +def _root() -> Path: + shutil.rmtree(SCRATCH, ignore_errors=True) + SCRATCH.mkdir(parents=True, exist_ok=True) + return SCRATCH + + +def _packet(index: int) -> SdkPacket: + frame = np.zeros((4, 4, 3), np.uint8) + fx = SPIKE_VALUE if index == SPIKE_ROW else 0.5 + return SdkPacket( + raw_frame=frame, + processed_frame=frame, + timestamp=1000.0 + index / 30.0, + mono_t=index / 30.0, + fps=30.0, + process_ms=1.0, + normal_force=1.0, + shear_x=fx, + shear_y=-0.5, + normal_force_raw=10.0 + (index % 7) * 0.01, + shear_x_raw=fx, + shear_y_raw=-0.5, + force_unit="N", + force_calibrated=True, + frame_id=index, + ) + + +def _recording() -> Path: + """A recording of ``ROWS`` rows with no drops: the writer is allowed to keep up.""" + import time + + root = _root() + recorder = ExperimentRecorder(RecordConfig(video=False)) + directory = recorder.start(root=root, session_id=1, metadata={}) + for index in range(ROWS): + recorder.on_packet(_packet(index)) + if index % 256 == 0: + # Feeding 12000 rows in a tight loop would (correctly) overflow the + # ordered stream and be recorded as gaps; this test is about history + # browsing, so let the writer drain. + deadline = time.monotonic() + 5.0 + while recorder._queue.qsize() > 512 and time.monotonic() < deadline: + time.sleep(0.001) + report = recorder.stop() + assert report.stats.dropped_data == 0, report.problems + assert report.stats.frames == ROWS + return Path(directory) + + +def test_the_index_reads_only_the_interval_it_is_asked_for(): + directory = _recording() + index = RecordingIndex(directory) + index.build() + assert index.rows == ROWS + assert index.duration_s() == pytest.approx((ROWS - 1) / 30.0, abs=1e-6) + + # The whole range keeps every row, including the very first one. + whole = index.read_rows(0, ROWS) + assert len(whole) == ROWS + assert whole.frame_ids[0] == 0 + assert whole.frame_ids[-1] == ROWS - 1 + + start, stop = index.row_range(300.0, 300.5) + # One row before the interval is included on purpose: a cursor exactly on a + # sample still wants the row it follows. + assert 15 <= stop - start <= 20, (start, stop) + window = index.read_rows(start, stop) + assert 15 <= len(window) <= 20 + assert window.times[0] >= 300.0 - 1 / 30.0 + assert window.times[-1] <= 300.5 + + +def test_a_large_recording_is_loaded_as_a_marked_decimated_view(): + directory = _recording() + reader = RecordingReader(directory, max_rows=500) + info = reader.info() + assert info.decimated, "a 12000-row recording was loaded in full" + assert info.total_rows == ROWS + assert len(info.times) <= 500 + # The duration comes from the frame index, never from the decimated view. + assert info.duration_s == pytest.approx((ROWS - 1) / 30.0, abs=0.05) + assert len(info.frames) == ROWS + + +def test_a_small_recording_is_not_decimated(): + directory = _recording() + reader = RecordingReader(directory, max_rows=50_000) + info = reader.info() + assert not info.decimated + assert len(info.times) == ROWS + assert info.total_rows == ROWS + + +def test_an_interval_read_sees_every_row_including_a_single_peak(): + directory = _recording() + reader = RecordingReader(directory, max_rows=500) + spike_t = SPIKE_ROW / 30.0 + window = reader.window(spike_t - 0.05, spike_t + 0.05) + assert window.times.size >= 3 + assert float(np.nanmax(window.value_for_unit("N")[:, 1])) == pytest.approx(SPIKE_VALUE) + # ...and it is the *exact* interval, not the decimated whole-session view. + assert not window.decimated + assert window.times[0] >= spike_t - 0.1 + + +def test_a_decimated_window_still_keeps_the_peak(): + directory = _recording() + reader = RecordingReader(directory, max_rows=500) + window = reader.window(0.0, (ROWS - 1) / 30.0, max_points=300, unit="N") + assert window.decimated + assert len(window) <= 600 + assert float(np.nanmax(window.value_for_unit("N")[:, 1])) == pytest.approx(SPIKE_VALUE) + + +def test_the_envelope_covers_the_whole_session_in_few_points(): + directory = _recording() + reader = RecordingReader(directory, max_rows=500) + overview = reader.overview(unit="N", buckets=200) + assert 0 < len(overview) <= 200 + assert not np.isnan(overview.highs[:, 1]).all() + # The spike is inside a bucket the strip would otherwise skip. + assert float(np.nanmax(overview.highs[:, 1])) == pytest.approx(SPIKE_VALUE) + assert float(overview.counts.sum()) > 0 + # Cached: asking again does not re-stream the file. + assert reader.overview(unit="N", buckets=200) is overview + + +def test_the_two_sources_answer_the_same_questions(): + directory = _recording() + reader = RecordingReader(directory, max_rows=ROWS + 1) + recording = RecordingCurveSource(reader) + + store = SessionStore() + for index in range(ROWS): + sample = SessionSample( + index, + index / 30.0, + fn_raw=10.0 + (index % 7) * 0.01, + fx_raw=SPIKE_VALUE if index == SPIKE_ROW else 0.5, + fy_raw=-0.5, + fn_n=1.0, + fx_n=SPIKE_VALUE if index == SPIKE_ROW else 0.5, + fy_n=-0.5, + ) + store.append(sample) + memory = StoreCurveSource(store) + + for source in (recording, memory): + first, last = source.extent() + assert first == pytest.approx(0.0, abs=1e-6) + assert last == pytest.approx((ROWS - 1) / 30.0, abs=1e-6) + assert source.duration_s() == pytest.approx((ROWS - 1) / 30.0, abs=1e-6) + assert recording.kind == "disk" and memory.kind == "memory" + + # The same interval, read from disk and from memory, agrees on the values. + # (The disk read also includes the row just before the interval.) + disk = recording.window(200.0, 210.0, unit="N") + cached = memory.window(200.0, 210.0, unit="N") + inside = (disk.times >= 200.0) & (disk.times <= 210.0) + assert inside.sum() == len(cached) > 0 + assert np.allclose(disk.values[inside], cached.values, equal_nan=True) + # The CSV keeps six decimals; the in-memory store keeps the raw float. + assert np.allclose(disk.times[inside], cached.times, atol=1e-6) + + # ...and so do the statistics of that interval (the disk read may include the + # one row that precedes it). + a = recording.statistics(200.0, 210.0, unit="N") + b = memory.statistics(200.0, 210.0, unit="N") + assert abs(a.samples - b.samples) <= 1 + assert a.peaks[1] == pytest.approx(b.peaks[1]) + assert a.means[1] == pytest.approx(b.means[1], abs=1e-6) + + +def test_the_whole_session_can_be_read_without_holding_it_all(): + """The point of the index: peak value with a bounded number of rows held.""" + directory = _recording() + reader = RecordingReader(directory, max_rows=500) + overview = reader.overview(unit="N", buckets=100) + # What a whole-session strip needs is one band per bucket, not 12000 rows. + assert len(overview) <= 100 + assert len(reader.info().times) <= 500 + assert reader.info().total_rows == ROWS + # A single sample can still be read exactly, on demand. + spike_t = SPIKE_ROW / 30.0 + exact = reader.window(spike_t, spike_t + 1 / 30.0) + assert 1 <= len(exact) <= 3, len(exact) + assert float(np.nanmax(exact.newtons[:, 1])) == pytest.approx(SPIKE_VALUE) diff --git a/shared/tests/test_module_boundaries.py b/shared/tests/test_module_boundaries.py new file mode 100644 index 0000000..a0577b4 --- /dev/null +++ b/shared/tests/test_module_boundaries.py @@ -0,0 +1,105 @@ +"""The module boundaries the responsibility split is supposed to create. + +A pure move is only worth anything if the dependency directions actually change: +the finger worker must not need the box worker to describe a packet, the session +file format must not need the sampling state machine, and the models must not +import either. These assertions are cheap and they fail loudly if someone +re-introduces the cycle. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +SHARED = Path(__file__).resolve().parents[1] / "src" +ACQUISITION = SHARED / "orisys_app_shared" / "acquisition" +FORCE = SHARED / "orisys_app_shared" / "force" +RECORD = SHARED / "orisys_app_shared" / "record" + + +def _imports(path: Path) -> set[str]: + """Every module name imported by ``path`` (``from x import y`` counts as x).""" + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + found.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + found.add(node.module) + # ``from . import models`` also names the submodule. + for alias in node.names: + found.add(f"{node.module}.{alias.name}" if node.module else alias.name) + return found + + +def _imports_any(path: Path, needles) -> bool: + found = _imports(path) + for needle in needles: + if any(entry == needle or entry.endswith(f".{needle}") for entry in found): + return True + return False + + +def test_the_packet_model_does_not_import_a_worker(): + assert not _imports_any( + ACQUISITION / "models.py", {"sdk_worker", "finger_worker"} + ), "acquisition.models depends on a worker" + + +def test_the_finger_worker_takes_the_packet_from_models(): + """It used to import the box worker just to describe a frame.""" + source = (ACQUISITION / "finger_worker.py").read_text(encoding="utf-8") + tree = ast.parse(source) + from_sdk_worker: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and (node.module or "").endswith("sdk_worker"): + from_sdk_worker.update(alias.name for alias in node.names) + assert "SdkPacket" not in from_sdk_worker, "the finger worker still borrows the packet" + assert "FixedCameraMode" not in from_sdk_worker + assert "load_fixed_mode" not in from_sdk_worker + # ...and the helpers it genuinely shares still come from there. + assert "process_chain" in from_sdk_worker + + +def test_the_calibration_models_do_not_import_the_sampler_or_the_session_file(): + for path in (FORCE / "models.py",): + assert not _imports_any( + path, {"calibration_recorder", "calibration_session", "session"} + ), f"{path.name} depends on a higher layer" + + +def test_the_session_format_does_not_import_the_sampling_state_machine(): + assert not _imports_any( + FORCE / "calibration_session.py", {"calibration_recorder"} + ), "the session file format still depends on the sampler" + + +def test_the_moved_models_are_the_same_objects_everywhere(): + from orisys_app_shared.acquisition import finger_worker, models, sdk_worker + from orisys_app_shared.force import calibration_recorder, calibration_session, models as force_models + + assert sdk_worker.SdkPacket is models.SdkPacket is finger_worker.SdkPacket + assert sdk_worker.FixedCameraMode is models.FixedCameraMode + assert calibration_recorder.CalibrationPoint is force_models.CalibrationPoint + assert calibration_recorder.AxisStats is force_models.AxisStats + assert calibration_session.SAMPLE_FIELDS is force_models.SAMPLE_FIELDS + # The recorder still offers the trace helpers it used to own. + assert callable(calibration_recorder.trace_from) + assert callable(calibration_recorder.trace_dt) + + +def test_the_recording_pieces_are_separate_modules(): + from orisys_app_shared.record import depth, experiment + from orisys_app_shared.record.depth_backends import BoxReplay, FingerReplay + from orisys_app_shared.record.video_writer import VideoSegments + + # The depth replay no longer defines its backends inline... + assert not hasattr(depth, "BoxReplay") + assert not hasattr(depth, "_BoxReplay") + # ...and the writer delegates video encoding to its own component. + assert not hasattr(experiment, "_open_segment") + assert callable(VideoSegments.write) and callable(VideoSegments.close) + assert BoxReplay.__name__ == "BoxReplay" and FingerReplay.__name__ == "FingerReplay" diff --git a/shared/tests/test_playback.py b/shared/tests/test_playback.py index 01d9423..78d88ac 100644 --- a/shared/tests/test_playback.py +++ b/shared/tests/test_playback.py @@ -137,6 +137,78 @@ def test_reader_window_and_streaming_agree(recording): assert streamed == 61, "streaming must see every row" +def test_an_empty_recording_directory_is_readable(): + """A folder with no CSVs must open (empty), not raise an unpack error.""" + shutil.rmtree(SCRATCH, ignore_errors=True) + SCRATCH.mkdir(parents=True, exist_ok=True) + try: + reader = RecordingReader(SCRATCH) + info = reader.info() + assert info.frames == [] + assert info.times.size == 0 + assert info.duration_s == 0.0 + assert reader.frame_at(1.0) is None + assert reader.replay_warnings(sdk_version="0.5.1") + finally: + shutil.rmtree(SCRATCH, ignore_errors=True) + + +def test_a_new_recording_is_reported_as_time_ordered(recording): + info = RecordingReader(recording).info() + assert info.frames_out_of_order, "the writer no longer produces ordered rows" + assert info.forces_out_of_order + codes = RecordingReader(recording).replay_warnings(sdk_version="0.5.1") + assert "out_of_order" not in codes + + +def test_an_old_out_of_order_recording_is_re_sorted_and_reported(): + """Rows written by the pre-fix writer are not in time order. + + ``frame_at`` binary-searches these times, so an unsorted file would answer + with the wrong frame; the reader sorts and says so instead. + """ + shutil.rmtree(SCRATCH, ignore_errors=True) + SCRATCH.mkdir(parents=True, exist_ok=True) + (SCRATCH / "forces.csv").write_text( + "session_id,frame_id,t_rel_s,FN_raw,FX_raw,FY_raw,FN_N,FX_N,FY_N,valid,calibration_id\n" + "1,0,0.0,1.0,0.1,0.1,2.0,0.1,0.1,1,h\n" + "1,2,0.066,3.0,0.1,0.1,6.0,0.1,0.1,1,h\n" # written before frame 1 + "1,1,0.033,2.0,0.1,0.1,4.0,0.1,0.1,1,h\n", + encoding="utf-8", + ) + (SCRATCH / "frames.csv").write_text( + "frame_id,t_rel_s,acquired_at,segment,segment_frame,in_video,dropped\n" + "0,0.0,2026-01-01T00:00:00.000Z,1,1,1,0\n" + "2,0.066,2026-01-01T00:00:00.066Z,1,3,1,0\n" + "1,0.033,2026-01-01T00:00:00.033Z,1,2,1,0\n", + encoding="utf-8", + ) + (SCRATCH / "session.json").write_text( + json.dumps( + { + "format_version": "1.1", + "device": {"sensor_type": "box", "config_ref": "/cfg/sensor.json"}, + "versions": {"sdk": "0.5.1"}, + "depth": {"stored": False}, + } + ), + encoding="utf-8", + ) + try: + reader = RecordingReader(SCRATCH) + info = reader.info() + assert not info.frames_out_of_order + assert not info.forces_out_of_order + assert [frame.frame_id for frame in info.frames] == [0, 1, 2] + assert info.times.tolist() == pytest.approx([0.0, 0.033, 0.066]) + # The binary search is only valid because the rows were sorted. + assert reader.frame_at(0.05).frame_id == 1 + assert reader.frame_at(0.07).frame_id == 2 + assert "out_of_order" in reader.replay_warnings(sdk_version="0.5.1") + finally: + shutil.rmtree(SCRATCH, ignore_errors=True) + + def test_depth_policy_says_recompute_and_warns_about_drift(recording): reader = RecordingReader(recording) diff --git a/shared/tests/test_record_lifecycle.py b/shared/tests/test_record_lifecycle.py index a054ce2..33eab3a 100644 --- a/shared/tests/test_record_lifecycle.py +++ b/shared/tests/test_record_lifecycle.py @@ -10,11 +10,9 @@ from __future__ import annotations import csv import json -import queue import shutil import threading import time -from collections import deque from pathlib import Path from unittest.mock import patch @@ -23,7 +21,11 @@ import pytest from orisys_app_shared.acquisition.sdk_worker import SdkPacket from orisys_app_shared.record import ExperimentRecorder, RecordConfig -from orisys_app_shared.record.experiment import STATE_FAILED, STATE_STOPPED, _SessionWriter +from orisys_app_shared.record.experiment import ( + STATE_FAILED, + STATE_STOPPED, + _SessionWriter, +) SCRATCH = Path(__file__).resolve().parent / "_scratch" @@ -189,23 +191,30 @@ def test_a_full_queue_keeps_the_rows_and_sheds_the_video(): def test_a_lost_row_is_recorded_as_an_index_gap(): - """A hole in the data must be visible, not silently bridged in replay.""" + """A hole in the data must be visible, not silently bridged in replay. + + The row is only given up when the ordered stream itself is full; the gap + marker then carries the stream position, so it lands in the index where the + hole actually is instead of after newer frames. + """ original = _SessionWriter.write_sample calls = {"count": 0} def slow(self, *args): calls["count"] += 1 if calls["count"] <= 2: - # Stall just long enough for the queue and the tiny backlog to fill. + # Stall just long enough for the stream to pass the (tiny) cap. time.sleep(0.3) return original(self, *args) recorder = ExperimentRecorder(RecordConfig(video=False, max_queue=16)) with patch.object(_SessionWriter, "write_sample", slow): directory = recorder.start(root=_root(), session_id=1, metadata={}) - recorder._row_backlog = deque(recorder._row_backlog, maxlen=2) - for index in range(40): - recorder.on_packet(_packet(index)) + with patch( + "orisys_app_shared.record.experiment.ROW_BACKLOG", 2 + ): + for index in range(40): + recorder.on_packet(_packet(index)) report = recorder.stop() with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle: @@ -218,6 +227,57 @@ def test_a_lost_row_is_recorded_as_an_index_gap(): assert report.stats.dropped_gaps > 0, "the loss was not marked in the index" +def test_the_frame_index_stays_in_time_order_when_rows_are_lost(): + """The old writer drained its backlog before the queue, so the index jumped.""" + original = _SessionWriter.write_sample + calls = {"count": 0} + + def slow(self, *args): + calls["count"] += 1 + if calls["count"] <= 3: + time.sleep(0.2) + return original(self, *args) + + recorder = ExperimentRecorder(RecordConfig(video=True, max_queue=4)) + with patch.object(_SessionWriter, "write_sample", slow): + directory = recorder.start(root=_root(), session_id=1, metadata={}) + with patch("orisys_app_shared.record.experiment.ROW_BACKLOG", 3): + for index in range(60): + recorder.on_packet(_packet(index)) + recorder.stop() + + with (Path(directory) / "frames.csv").open(encoding="utf-8", newline="") as handle: + frame_rows = list(csv.DictReader(handle)) + times = [float(row["t_rel_s"]) for row in frame_rows] + assert times == sorted(times), "frames.csv is not in time order" + ids = [int(row["frame_id"]) for row in frame_rows] + assert ids == sorted(ids), "forces.csv/frames.csv disagree on order" + with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle: + force_rows = list(csv.DictReader(handle)) + force_times = [float(row["t_rel_s"]) for row in force_rows] + assert force_times == sorted(force_times), "forces.csv is not in time order" + + +def test_the_video_byte_budget_is_released_when_a_frame_is_shed(): + """A shed frame must not leak its reservation for the rest of the session.""" + recorder = ExperimentRecorder( + RecordConfig(video=True, max_queue=16, max_queue_bytes=4096) + ) + directory = recorder.start(root=_root(), session_id=1, metadata={}) + # Frames of 8x8x3 = 192 bytes, so the budget holds ~21 of them; without the + # release the counter would climb past the budget and stay there. + for index in range(200): + recorder.on_packet(_packet(index)) + report = recorder.stop() + assert report.stats.frames == 200, "rows were lost to the video budget" + assert abs(recorder._queued_bytes) < 4096, ( + f"the byte budget leaked: {recorder._queued_bytes} bytes still reserved" + ) + with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + assert len(rows) == 200 + + def test_session_json_names_the_pre_roll_when_one_was_written(): frames = [np.full((8, 8, 3), index, np.uint8) for index in range(3)] recorder = ExperimentRecorder(RecordConfig(video=True, fps=5.0)) @@ -241,6 +301,80 @@ def test_a_data_only_recording_says_why_it_has_no_pre_roll(): assert any("pre-roll" in problem for problem in report.problems) +def test_a_stalled_encoder_leaves_a_readable_recording(): + """Order, duration, positioning and the byte counter after a stall. + + Stall the writer, push past the ordered stream's cap, let it recover, then + push again and read the directory back the way the replay does. The frames + acquired *after* the recovery must all be there: an overflow is a temporary + loss, not the end of the recording. + """ + from orisys_app_shared.record import RecordingReader + + original = _SessionWriter.write_sample + started = threading.Event() + release = threading.Event() + calls = {"count": 0} + + def slow(self, *args): + calls["count"] += 1 + if calls["count"] == 2: + started.set() + release.wait(10.0) + return original(self, *args) + + def drained(recorder, timeout: float = 10.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if recorder._queue.qsize() == 0: + return True + time.sleep(0.01) + return False + + recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, max_queue=4)) + with patch.object(_SessionWriter, "write_sample", slow): + directory = recorder.start(root=_root(), session_id=1, metadata={}) + with patch("orisys_app_shared.record.experiment.ROW_BACKLOG", 3): + for index in range(40): + recorder.on_packet(_packet(index)) + assert started.wait(10.0), "the stall never happened" + release.set() + assert drained(recorder), "the writer never caught up" + before = recorder.stats().frames + for index in range(40, 100): + recorder.on_packet(_packet(index)) + time.sleep(0.005) + assert drained(recorder), "the recovered writer never caught up" + report = recorder.stop() + + assert not report.stats.failed, report.problems + assert recorder.stats().frames > before, "recording did not continue after the stall" + + reader = RecordingReader(directory) + info = reader.info() + times = info.times.tolist() + ids = [int(value) for value in info.frame_ids] + assert times == sorted(times), "forces.csv is not in time order" + assert ids == sorted(ids) + frame_times = [frame.t_rel_s for frame in info.frames] + assert frame_times == sorted(frame_times), "frames.csv is not in time order" + + # Everything acquired after the recovery is present. + assert set(range(40, 100)).issubset(set(ids)), ( + f"frames lost after the encoder recovered: {sorted(set(range(40, 100)) - set(ids))}" + ) + assert report.stats.duration_s == pytest.approx(99 / 30.0, abs=0.05) + assert info.duration_s == pytest.approx(99 / 30.0, abs=0.05) + + # Positioning: the frame at 2 s is the frame acquired at 2 s. + frame = reader.frame_at(2.0) + assert frame is not None and frame.frame_id == 60, frame + assert frame.t_rel_s == pytest.approx(2.0, abs=0.05) + + # The byte budget is back to zero once every frame has been consumed. + assert recorder._queued_bytes == 0, recorder._queued_bytes + + def test_stopping_twice_returns_the_same_report(): recorder = ExperimentRecorder(RecordConfig(video=False)) recorder.start(root=_root(), session_id=1, metadata={}) @@ -249,3 +383,146 @@ def test_stopping_twice_returns_the_same_report(): second = recorder.stop() assert first.directory == second.directory assert second.ok + + +# ------------------------------------------------------------ failure handling + + +def test_a_directory_that_cannot_be_created_leaves_a_retryable_state(): + """A failed ``mkdir`` must not strand the recorder in ``starting``.""" + root = _root() + blocker = root / "not-a-directory" + blocker.write_text("", encoding="utf-8") + + recorder = ExperimentRecorder(RecordConfig(video=False)) + with pytest.raises(Exception): + recorder.start(root=blocker, session_id=1, metadata={}) + assert recorder.state == STATE_FAILED + assert not recorder.is_recording + assert recorder.directory is None + assert recorder.stats().last_error + + # Retryable: the operator fixes the path and presses record again. + directory = recorder.start(root=root, session_id=2, metadata={}) + recorder.on_packet(_packet(0)) + report = recorder.stop() + assert report.ok, report.problems + assert Path(directory).is_dir() + + +def test_a_stop_during_initialization_never_resumes_recording(): + """``stop`` that lands while the files open must not be undone afterwards.""" + recorder = ExperimentRecorder(RecordConfig(video=False)) + original = ExperimentRecorder._write_session_json + writing = threading.Event() + release = threading.Event() + calls = {"count": 0} + + def gated(self, *, final: bool) -> None: + calls["count"] += 1 + if calls["count"] == 1: + # This is the writer thread's first metadata write. + writing.set() + release.wait(5.0) + return original(self, final=final) + + with patch.object(ExperimentRecorder, "_write_session_json", gated): + directory = recorder.start(root=_root(), session_id=1, metadata={}) + recorder.on_packet(_packet(0)) + assert writing.wait(5.0) + report = recorder.stop(wait=False) + release.set() + thread = recorder._thread + if thread is not None: + thread.join(5.0) + + # The row accepted before the stop is still written... + with (Path(directory) / "forces.csv").open(encoding="utf-8", newline="") as handle: + rows = list(csv.DictReader(handle)) + assert len(rows) == 1, "rows accepted before the stop were dropped" + # ...but the recording never went back to "recording", and it is finished. + assert not recorder.is_recording + assert recorder.state in (STATE_STOPPED, STATE_FAILED) + assert not report.completed or report.directory is not None + + +def test_stop_without_waiting_does_not_claim_completion(): + started = threading.Event() + release = threading.Event() + original = _SessionWriter.write_sample + + def slow(self, *args): + started.set() + release.wait(10.0) + return original(self, *args) + + recorder = ExperimentRecorder(RecordConfig(video=False)) + with patch.object(_SessionWriter, "write_sample", slow): + recorder.start(root=_root(), session_id=1, metadata={}) + recorder.on_packet(_packet(0)) + assert started.wait(10.0) + report = recorder.stop(wait=False) + try: + assert not report.completed + assert not report.ok + assert recorder.report is None, ( + "an unfinished stop was cached as the terminal report" + ) + finally: + release.set() + recorder._thread.join(5.0) + assert recorder.report is not None and recorder.report.completed + + +def test_a_failing_flush_reaches_the_state_machine_at_once(): + """A flush failure used to live only in the writer's private counter.""" + recorder = ExperimentRecorder(RecordConfig(video=False, flush_interval_s=0.0)) + directory = recorder.start(root=_root(), session_id=1, metadata={}) + + class _BrokenHandle: + def flush(self): + raise OSError("input/output error") + + def close(self): + return None + + writer = _SessionWriter( + directory=Path(directory), + config=recorder.config, + session_id=1, + calibration_id="", + on_problem=recorder._record_failure, + ) + writer._forces = (_BrokenHandle(), None) + writer.maybe_flush() + assert recorder.stats().write_errors == 1, "the flush failure was not reported" + assert "flush failed" in recorder.stats().last_error + + for _ in range(4): + writer.maybe_flush() + assert recorder.stats().failed, "repeated flush failures never failed the record" + assert not recorder.is_recording + recorder.stop() + + +def test_metadata_write_failure_is_part_of_the_report(): + """A session.json that cannot be written is part of what happened.""" + real_write_text = Path.write_text + + def failing_write_text(self, *args, **kwargs): + if self.name.endswith(".tmp"): + raise OSError("read-only filesystem") + return real_write_text(self, *args, **kwargs) + + recorder = ExperimentRecorder(RecordConfig(video=False)) + with patch.object(Path, "write_text", failing_write_text): + recorder.start(root=_root(), session_id=1, metadata={}) + recorder.on_packet(_packet(0)) + report = recorder.stop() + + assert report.stats.write_errors >= 1 + assert not report.ok, "a missing session.json was reported as a good recording" + assert any( + "read-only" in problem or "write error" in problem for problem in report.problems + ), report.problems + assert report.stats.failed