diff --git a/apps/autocal/README.md b/apps/autocal/README.md index 11f48dd..d418f56 100644 --- a/apps/autocal/README.md +++ b/apps/autocal/README.md @@ -26,11 +26,20 @@ Replaces the vendor example program it was ported from: and RTEX parameters. Each family is enabled only when the controller reports that bus type. +## Capture workflow + +The 标定采集 page is one queue, not two panels — a single point is a queue with one +row. `Ready` lists the six facts a run needs and the **primary button follows the +step**, always stating why it is disabled. Alignment is only a request: the move +goes through the guarded pose-move path, and only a completed move unlocks capture. +Recording options (video, codec, lossless frames) are chosen before the session and +written once into `session.json`; an unavailable codec is disabled with its reason, +never silently substituted. [docs/workflow.md](docs/workflow.md) has the detail. + ## Run it PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no -licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6 -for the same reason and no longer uses it.) +licence purchase (PyQt5 is GPLv3-or-commercial). ```powershell # with the environment that already has PySide6 (Anaconda py310, for example) @@ -50,12 +59,9 @@ resolve to `apps/autocal/` rather than the studio's. ### Controller library -The ZMotion runtime is vendored at `shared/resources/zmotion/x64/` (see the -README beside it for provenance). Point `ORISYS_ZMOTION_DLL_DIR` at another -directory to use a different build — on a motion controller, silently loading -the wrong runtime version is the kind of fault that looks like a working machine -until it moves, so the override is authoritative: if it does not contain -`zauxdll.dll`, the app refuses to connect instead of quietly falling back. +The ZMotion runtime is vendored at `shared/resources/zmotion/x64/`. `ORISYS_ZMOTION_DLL_DIR` overrides it, and the +override is authoritative: a directory without `zauxdll.dll` makes the app refuse +to connect rather than quietly load another build. One contract in `shared/src/orisys_app_shared/zmotion/client.py` is worth knowing before touching it: `ZAux_Execute` **must** be given a reply buffer. @@ -71,8 +77,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of 1. **Connect** (`eth` + the controller's IP). On success the app adopts any BAS program the controller is already running from a previous session instead of - claiming the bus is uninitialised; `BUS_TYPE` is read immediately, and - `Bus_InitStatus` on the first poll. + claiming the bus is uninitialised; `BUS_TYPE` is read immediately. 2. **Download only if the bus type is unknown.** `ECAT.bas` / `RTEX.bas` are chosen from `config/bas/` (the vendor requires a non-Chinese path) and written to ROM. A controller that still has the program needs no download. @@ -97,7 +102,7 @@ The vendor sequence, with the checks AutoCal's own state machine adds. P1 of | Range | Family | What the operator should do | |---|---|---| | `0` | success | — | -| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, make sure nothing else holds the connection, then retry. Frequently transient: a first open on a cold route can fail and succeed on the next attempt, which is why an Ethernet connect makes up to 3 attempts before reporting. | +| `20000–29999` | PC-library fault (handshake, socket, address) | check the cable/segment and the IP, then retry; a first open on a cold route is often transient, which is why an Ethernet connect makes up to 3 attempts. | | `30000+` | the auxiliary block `zauxdll2.h` documents | the message names it (`无应答`, `参数错误`, …) | | anything else | the controller's own code | look it up in ZDevelop's table | @@ -158,6 +163,10 @@ apps/autocal/ tests/ ``` +`capture_controller.py` is the capture page's Qt-free step machine (queue, `Ready`, +one primary action, remeasure/skip/stop, poll); `recording_options.py` turns the +video choice into a `RecordConfig` and probes which codecs this machine can write. + `motion/poller.py` is the only place a controller call can originate. It owns the DLL handle on its own thread, so the vendor library's blocking calls (a ROM write, a bus scan) never freeze the window and the ctypes handle is never shared @@ -177,26 +186,18 @@ python -m pytest apps\autocal\tests # offscreen suite ``` Connection and bus initialisation are used once per machine start-up, so they live -in a **modeless device-setup dialog** (`ui/device_setup.py`) rather than the rail: -the rail carries what is read while working — the axis readout and the collapsed -SDO/RTEX monitor. The dialog opens from the status strip's 设备设置 button, from the -connection/bus/init chips (each opens the tab that explains it) or from -文件 → 设备设置, and the strip keeps reporting the live state whether it is open or -shut. +in a **modeless device-setup dialog** (`ui/device_setup.py`), opened from the status +strip, its chips, or 文件 → 设备设置. The rail carries what is read while working: +the axis readout and the collapsed SDO/RTEX monitor. -`preview_snapshot.py` is the one tool that can photograph the **3D pane**, which -`panel_snapshot.py` cannot: the offscreen platform has no OpenGL context at all, so -the pane is empty in those shots. It needs the real platform plugin and a visible -window, and it writes either the pane alone or the whole page with the pane -composited in. What the view draws, and what each colour means, is in +`preview_snapshot.py` is the only tool that can photograph the **3D pane** (the +offscreen platform has no OpenGL context, so the pane is empty there). It needs +the real platform plugin and a visible window. See [docs/finger-preview.md](docs/finger-preview.md). -`layout_mockup.py` is a **wireframe, not the application**: it paints the -proposed single-page layout with the shared theme tokens and imports nothing -from the app, so the layout can be reviewed and marked up without building the -panels. It draws the full page plus previews of the other workspace tabs in one -canvas, and `--page setup` draws the device-setup dialog over the page it opens -from. +`layout_mockup.py` is a **wireframe, not the application**: it paints the proposed +single-page layout with the shared theme tokens and imports nothing from the app, +so the layout can be reviewed without building the panels. `panel_check.py` builds the real window in both themes at 1440×900 and 1920×1080, asserts that no widget escapes its parent, that motion is @@ -207,10 +208,8 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules are skipped rather than failed: a broken environment should not look like a broken application. `tests/test_binding_purity.py` enforces the binding policy statically — no module may import PyQt5/PySide2/PyQt6, no module may use an -unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no -other binding, and the controller interface must stay importable headless. The -studio app enforces the same rules for itself in -`apps/studio/tests/test_binding_purity.py`. +unscoped Qt enumerator, `shared/` may import PySide6 and no other binding, and +the controller interface must stay importable headless. ## Do not ship this app diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index ce8b4da..aac4235 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -230,7 +230,7 @@ "workspace": { "tab_axes": "Axis debug", "tab_sequence": "Test sequence", - "tab_finger": "Finger debug", + "tab_finger": "Geometry & mounting", "tab_sensor": "Calibration capture", "tab_records": "Calibration records", "not_built": "Not built yet", @@ -338,7 +338,6 @@ "remove": "Delete selected", "up": "Move up", "down": "Move down", - "import_points": "Import from finger debug", "align": "Align normal", "reacquire": "Record selected again", "source": "Reference source", @@ -410,7 +409,7 @@ "capture_wait": "recording is not ready; the hold cannot be confirmed yet" }, "finger": { - "title": "Finger calibration-point debug", + "title": "Geometry & mounting", "bundle_path": "Export bundle", "load": "Load bundle…", "reset_view": "Reset view", @@ -421,8 +420,7 @@ "flat_title": "Flat image (click to pick)", "readout_title": "Readouts", "preview_title": "3D preview", - "points_title": "Picked points", - "select_hint": "Click a point on the flat image to pick a calibration point.", + "select_hint": "Click a point on the flat map to try that point's pose.", "no_bundle": "No export bundle loaded.", "no_surface": "Pixel ({x}, {y}) is not on the sensing surface (UV gap).", "row_sensor_point": "Sensor point (mm)", @@ -453,14 +451,7 @@ "sensor_xy": "Sensor X / Y", "sensor_zy": "Sensor Z / yaw", "setup_hint": "How the sensor actually sits on the stack with the machine at zero: X/Y/Z translation and a yaw about Z. Z is the mounting-plate thickness. The plate on this bench is 8 mm.", - "clear_points": "Clear points", - "remove_selected": "Remove selected", - "move_up": "Move up", - "move_down": "Move down", - "selected_count": "{count} selected", - "table_hint": "Tick a row to select it, double-click a sensor coordinate or normal to edit it. The pose columns are solved from those, so they update themselves.", "edit_not_a_number": "That cell needs a number; the previous value was kept.", - "point_count": "{count} points", "residual_value": "{residual}° — {verdict}", "reachable": "this normal can be aligned with Z", "unreachable": "this normal cannot be aligned with Z (yaw travel limit)", @@ -482,28 +473,9 @@ "preview_no_gl": "No OpenGL context: {error}", "preview_draw_failed": "Drawing failed: {error}", "preview_solve_failed": "Solve failed: {error}", - "col_x": "x", - "col_y": "y", - "col_z": "z", - "col_nx": "nx", - "col_ny": "ny", - "col_nz": "nz", - "col_X": "X", - "col_Y": "Y", - "col_Z": "Z", - "col_yaw": "yaw", - "col_pitch": "pitch", - "col_residual": "residual(°)", "loaded": "Bundle loaded: {vertices} vertices · {triangles} triangles · {extent} mm · {scale} mm per unit", "load_failed": "Load failed: {error}", - "picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°", - "export_sites": "Export operator site list…", - "export_sites_tip": "Export all points in displayed order, with UV pixels and sensor/platform millimetres. UV origin matches calibration.", - "export_no_points": "Pick UV sites first", - "export_edited_point": "Point {order} was edited in 3D and no longer matches its UV pixel; pick it again before exporting", - "sites_exported": "Operator site list exported: {path}", - "col_u_px": "U (px)", - "col_v_px": "V (px)" + "picked": "Picked: residual {residual}° · yaw {yaw}° · pitch {pitch}°" }, "sequence": { "title": "Test sequence design", @@ -671,7 +643,8 @@ "guide_incomplete": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect the stop reason and recorded signal. Incomplete attempts are excluded from fitting.", "guide_check": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: select a trial and Check hold windows; adjust the time interval if needed.", "guide_save": "1 Check identity → 2 Select site / trial → 3 Check stable windows → 4 Save\nNext: inspect remaining holds or save current analysis.", - "video": "View recording" + "video": "View recording", + "video_not_recorded": "Video not recorded, per the session settings" }, "quality": { "open": "Repeatability and validation…", @@ -780,6 +753,163 @@ "retry": "Publish summary again", "done": "Summary published again: {path}", "reason_hold_not_found": "This hold attempt is not in the record", - "reason_hold_not_complete": "This hold attempt was discarded by a remeasure" + "reason_hold_not_complete": "This hold attempt was discarded by a remeasure", + "copy_failed": "The summary was published, but the readable copy was not written: {detail}. The pointer still names the published summary and the recording is unchanged." + }, + "cap": { + "check": { + "bundle": "Calibration bundle", + "frames": "Input frames", + "bus": "Bus and axis mapping", + "output": "Output folder writable", + "sn": "Sensor SN", + "max_force": "Maximum force" + }, + "action": { + "start": "Start capture", + "align": "Align this point", + "confirm_target": "At the target — begin the hold", + "confirm_hold": "Hold finished — record this level", + "confirm_liftoff": "Confirm the probe has left the sensor", + "finish": "Finish the session", + "remeasure": "Measure this level again", + "skip": "Skip this point", + "stop": "Stop and keep the data", + "open_session": "Open session folder", + "open_summary": "Open summary", + "copy_path": "Copy path", + "settings": "Change input / bundle…" + }, + "block": { + "closing": "Still writing to disk — wait for the close to finish", + "align_while_recording": "Recording: the platform cannot move to align another point", + "skip_live": "This level is already being recorded and cannot be skipped", + "no_directory": "There is no session folder yet", + "no_summary": "No summary has been published yet", + "not_ready": "Some checks are still unmet — see the Ready list above", + "no_sites": "The queue has no points yet", + "no_session": "The session settings are incomplete (output folder, model, SN, …)", + "no_solver": "No pose solver is connected, so the platform cannot be aligned", + "no_pose": "This point has no usable solved pose", + "hold_running": "The hold time has not elapsed", + "remeasure_unavailable": "No level can be measured again right now", + "stop_unavailable": "There is no running session to stop", + "finish_unavailable": "The session is not finished yet", + "skip_unavailable": "This build has no way to skip the current point, so it cannot be skipped", + "busy": "This action is not available in the current step", + "no_bundle": "No calibration bundle (export) is loaded", + "no_uv": "Click a position on the UV map first", + "no_output": "Choose the output directory first", + "no_sn": "Enter the sensor SN first", + "no_max_force": "Enter the maximum force (N) first", + "queue_busy": "The session is running; the point queue cannot be edited", + "bundle_busy": "The session is running; the calibration bundle cannot be changed", + "no_worker": "The input is not running, so recording cannot start", + "bad_levels": "Load levels must be strictly increasing, above zero and within the maximum force", + "bad_reference": "The measured reference force is not a valid number", + "align_running": "An alignment move is still running; wait for it to finish", + "pose_unreachable": "This point cannot be reached under the current mounting and travel, so aligning is disabled" + }, + "session": { + "active": "Session: {sites} site(s) · {done}/{total} holds\n{path}", + "none": "No session yet. Align the first point, then press Start capture.", + "settings_hint": "Input source, bundle, session parameters and recording options are in \"Change input / bundle…\"; starting a capture does not switch pages." + }, + "queue": { + "title": "Point queue", + "empty": "The queue is empty: click a position on the UV map, set the levels, then press Add current point.", + "add": "Add current point", + "update": "Update selected", + "remove": "Remove selected", + "up": "Move up", + "down": "Move down", + "site_id": "Site id", + "repeats": "Repeats", + "current_uv": "Current UV", + "col_id": "Site", + "col_position": "Sensor position (mm)", + "col_repeats": "Repeats", + "col_reachable": "Reachable", + "col_state": "State", + "reachable": "reachable", + "unreachable": "unreachable", + "state_pending": "pending", + "state_partial": "in progress", + "state_complete": "complete", + "state_skipped": "skipped", + "selected": "Selected: {site}", + "update_short": "Update", + "remove_short": "Remove", + "export": "Export point sheet…", + "export_tip": "Exports U/V, sensor coordinates, platform targets and the mapping identity in queue order; refuses when a coordinate no longer matches the mapping.", + "export_saved": "Point sheet exported: {path}", + "export_no_points": "The queue has no points yet", + "export_no_map": "No UV map is loaded, so the point sheet cannot be exported", + "export_missing_uv": "Point {order} has no UV coordinates, so the point sheet cannot be exported", + "export_no_pose": "Point {order} has no solved pose, so the point sheet cannot be exported", + "export_edited_point": "Point {order}'s sensor coordinates no longer match the current UV map; take the point again before exporting." + }, + "card": { + "title": "Current point", + "no_point": "The queue is empty: add a point first.", + "no_progress": "Progress appears once the session starts.", + "next_site": "Next point: {site}" + }, + "step": { + "align_point": "1. Align the current point", + "start_session": "2. Start capture", + "confirm_target": "3. Target reached - start the hold", + "confirm_hold": "4. Hold finished - record this step", + "confirm_liftoff": "5. Confirm liftoff, go to the next point", + "finish_session": "6. Finish the session", + "closing": "Writing to disk" + }, + "hint": { + "align_point": "Align first: the primary button asks for the move plan; Start capture only appears after the move has been confirmed complete.", + "start_session": "This point is aligned. The primary button opens the session and records the first point step by step.", + "not_ready": "The ✗ checks must pass before starting; hover them to see why.", + "confirm_target": "Load to the current target, enter the measured reference force (or the six-axis components), then press the primary button.", + "confirm_hold": "Holding: {seconds:.1f} s left; do not change the load.", + "confirm_liftoff": "This repeat is recorded. Lift the indenter, confirm it has left the sensor, then press the primary button to go to the next point.", + "finish_session": "Every record is written. Press the primary button to finish the session and publish the summary.", + "closing": "Closing the recording and checking it; nothing else runs until it finishes." + }, + "message": { + "aligning": "Alignment requested for {site}. Start capture stays unavailable until the move completes and is confirmed.", + "aligned": "Alignment confirmed: {site}", + "align_failed": "Alignment did not finish: {detail}. The platform is not confirmed in place; align again.", + "started": "Session started: {path}", + "attach_failed": "Could not start recording: {detail}", + "queue_added": "Point added: {site}", + "queue_removed": "Point removed: {site}", + "skipped": "Point skipped: {site}", + "stopped": "Stopped; the data already written is kept.", + "done": "Session finished: {path}" + }, + "recording": { + "title": "Recording", + "video": "Record video", + "codec": "Video codec", + "lossless": "Also store lossless frames", + "unavailable": "{codec}: unavailable on this machine, disabled", + "probe_failed": "The codec probe did not run: {detail}", + "off": "Video off, per the session settings", + "estimate": "Estimated {size} ({seconds}); {free} free on disk", + "disk_short": "Less than twice the estimated size is free on disk", + "no_plan": "The queue is empty, so no size can be estimated yet", + "locked": "Recording options are locked once the session has started", + "summary": "Recording: {codec}" + }, + "settings": { + "title": "Capture settings", + "input": "Input source", + "bundle": "Calibration bundle (export)", + "bundle_none": "not loaded", + "bundle_load": "Load export bundle…", + "session": "Session parameters", + "simulate": "Simulate (no camera, no platform motion)", + "close": "Close", + "max_required": "Required; the program does not assume a value" + } } } diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 0ebe649..159158c 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -230,7 +230,7 @@ "workspace": { "tab_axes": "轴调试", "tab_sequence": "测试序列", - "tab_finger": "手指调试", + "tab_finger": "几何与安装", "tab_sensor": "标定采集", "tab_records": "标定记录", "not_built": "尚未实现", @@ -338,7 +338,6 @@ "remove": "删除所选", "up": "上移", "down": "下移", - "import_points": "从手指调试导入", "align": "对齐法向", "reacquire": "补采所选", "source": "参考力来源", @@ -410,7 +409,7 @@ "capture_wait": "录像未就绪,暂时不能确认保持" }, "finger": { - "title": "手指标定点调试", + "title": "几何与安装", "bundle_path": "导出包", "load": "载入导出包…", "reset_view": "复位视角", @@ -421,8 +420,7 @@ "flat_title": "展开平面图(点击选点)", "readout_title": "读数", "preview_title": "三维预览", - "points_title": "已选标定点", - "select_hint": "在展开图上点击一点以选取标定点。", + "select_hint": "在展开图上点击一点以试算该点的姿态。", "no_bundle": "尚未载入导出包。", "no_surface": "像素 ({x}, {y}) 不在传感面上(UV 空隙)。", "row_sensor_point": "传感器坐标 (mm)", @@ -453,14 +451,7 @@ "sensor_xy": "传感器 X / Y", "sensor_zy": "传感器 Z / yaw", "setup_hint": "传感器在转台零位时相对工作台原点的实际安装位姿:X/Y/Z 平移与绕 Z 的偏摆。Z 为安装板厚度,当前安装台是 8 mm。", - "clear_points": "清空标定点", - "remove_selected": "删除所选", - "move_up": "上移", - "move_down": "下移", - "selected_count": "已选 {count} 点", - "table_hint": "勾选复选框选中该点;双击传感器坐标或法向即可修改。位姿列由这些数值解算得出,会自动更新。", "edit_not_a_number": "该单元格需要数字,已保留原值。", - "point_count": "共 {count} 点", "residual_value": "{residual}° — {verdict}", "reachable": "该点法向可与 Z 轴对齐", "unreachable": "该点法向无法与 Z 轴对齐(受 yaw 行程限制)", @@ -482,28 +473,9 @@ "preview_no_gl": "无 OpenGL 上下文:{error}", "preview_draw_failed": "绘制失败:{error}", "preview_solve_failed": "求解失败:{error}", - "col_x": "x", - "col_y": "y", - "col_z": "z", - "col_nx": "nx", - "col_ny": "ny", - "col_nz": "nz", - "col_X": "X", - "col_Y": "Y", - "col_Z": "Z", - "col_yaw": "yaw", - "col_pitch": "pitch", - "col_residual": "残差(°)", "loaded": "已载入导出包:{vertices} 顶点 · {triangles} 三角面 · {extent} mm · 每单位 {scale} mm", "load_failed": "载入失败: {error}", - "picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°", - "export_sites": "导出测点操作表…", - "export_sites_tip": "按当前列表顺序导出全部测点的 U/V 像素、传感器与平台毫米坐标;与标定页共用 UV 原点。", - "export_no_points": "请先在 UV 图上选择测点", - "export_edited_point": "第 {order} 点的三维位置已改动,与原 UV 像素不一致;请重新在图上选点后导出", - "sites_exported": "已导出测点操作表:{path}", - "col_u_px": "U (px)", - "col_v_px": "V (px)" + "picked": "已选点:残差 {residual}° · yaw {yaw}° · pitch {pitch}°" }, "sequence": { "title": "测试序列设计", @@ -671,7 +643,8 @@ "guide_incomplete": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:查看结束原因及已录曲线,决定补采;未完成试验不参与拟合。", "guide_check": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:选择单次试验,点击“检查保持段…”;必要时调整时间区间。", "guide_save": "① 核对身份 → ② 选测点/试验 → ③ 检查稳定段 → ④ 保存\n下一步:继续检查剩余保持段,或保存当前分析结果。", - "video": "查看录像" + "video": "查看录像", + "video_not_recorded": "按设置未录视频" }, "quality": { "open": "重复性与验证…", @@ -780,6 +753,163 @@ "retry": "重新导出汇总", "done": "汇总已重新发布:{path}", "reason_hold_not_found": "记录中找不到这个保持尝试", - "reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面" + "reason_hold_not_complete": "这个保持尝试已被重测废弃,没有可复核的画面", + "copy_failed": "汇总已发布,但可读副本没有写成:{detail}。指针仍指向已发布的汇总,采集结果未改变。" + }, + "cap": { + "check": { + "bundle": "标定包", + "frames": "输入出帧", + "bus": "总线与轴映射", + "output": "输出目录可写", + "sn": "传感器 SN", + "max_force": "最大力" + }, + "action": { + "start": "开始采集", + "align": "对准当前点", + "confirm_target": "已到目标力,开始保持", + "confirm_hold": "保持完成,记录本档", + "confirm_liftoff": "确认压头已脱离", + "finish": "结束会话", + "remeasure": "重测本档", + "skip": "跳过本点", + "stop": "停止并保留数据", + "open_session": "打开会话文件夹", + "open_summary": "打开汇总", + "copy_path": "复制路径", + "settings": "更改输入/标定包…" + }, + "block": { + "closing": "正在写盘,等待收尾完成", + "align_while_recording": "正在录制,不能移动平台去对准其它点", + "skip_live": "这一档已经开始录制,不能跳过", + "no_directory": "还没有会话目录", + "no_summary": "还没有汇总;结束本场标定后自动发布", + "not_ready": "还有未满足的检查项,见上方 Ready 列表", + "no_sites": "队列里还没有测点", + "no_session": "还没有配置会话参数(输出目录、型号、SN 等)", + "no_solver": "没有接上姿态求解器,不能对准", + "no_pose": "这个点还没有解出可用的姿态", + "hold_running": "保持时间还没到", + "remeasure_unavailable": "当前没有可重测的档位", + "stop_unavailable": "没有进行中的会话可停止", + "finish_unavailable": "会话还没有结束,不能点结束", + "skip_unavailable": "程序没有提供跳过当前点的推进接口,本版不能跳过", + "busy": "当前阶段不能执行这个动作", + "no_bundle": "还没有载入标定包(导出包)", + "no_uv": "先在 UV 图上点选位置", + "no_output": "先选择保存目录", + "no_sn": "先填写传感器 SN", + "no_max_force": "先填写最大力 (N)", + "queue_busy": "会话进行中,不能修改测点队列", + "bundle_busy": "会话进行中,不能更换标定包", + "no_worker": "输入没有运行,无法开始录像", + "bad_levels": "加载档位需要严格递增、每档大于 0 且不超过最大力", + "bad_reference": "人工参考力不是有效数字", + "align_running": "对准移动还在进行,请等它完成", + "pose_unreachable": "当前安装或行程下这个点不可达,已禁止对准" + }, + "session": { + "active": "本场标定:{sites} 个测点 · 进度 {done}/{total} 档\n{path}", + "none": "尚未建立本场标定;对准第一点后点「开始采集」创建。", + "settings_hint": "输入源、标定包、会话参数与录像选项都在「更改输入/标定包…」里;开始试验不会切换页面。" + }, + "queue": { + "title": "测点队列", + "empty": "队列里还没有测点:在 UV 图上点选位置,填好档位后点「添加当前点」。", + "add": "添加当前点", + "update": "更新选中", + "remove": "删除选中", + "up": "上移", + "down": "下移", + "site_id": "测点编号", + "repeats": "重复次数", + "current_uv": "当前 UV", + "col_id": "测点", + "col_position": "传感器位置 (mm)", + "col_repeats": "重复进度", + "col_reachable": "可达性", + "col_state": "状态", + "reachable": "可达", + "unreachable": "不可达", + "state_pending": "待采", + "state_partial": "进行中", + "state_complete": "已完成", + "state_skipped": "已跳过", + "selected": "当前选中:{site}", + "update_short": "更新", + "remove_short": "删除", + "export": "导出测点操作表…", + "export_tip": "按当前队列顺序导出 U/V、传感器坐标、平台目标与映射身份;坐标与映射不符时拒绝导出。", + "export_saved": "已导出操作表:{path}", + "export_no_points": "队列里还没有测点", + "export_no_map": "还没有载入 UV 映射,无法导出操作表", + "export_missing_uv": "第 {order} 点没有 UV 坐标,无法导出操作表", + "export_no_pose": "第 {order} 点还没有解出姿态,无法导出操作表", + "export_edited_point": "第 {order} 点的传感器坐标与当前 UV 映射不符;请重新取点后再导出。" + }, + "card": { + "title": "当前点", + "no_point": "队列是空的:先添加测点。", + "no_progress": "开始后显示进度。", + "next_site": "下一测点:{site}" + }, + "step": { + "align_point": "① 对准当前点", + "start_session": "② 开始采集", + "confirm_target": "③ 到达目标力,开始保持", + "confirm_hold": "④ 保持完成,记录本档", + "confirm_liftoff": "⑤ 确认离开,换到下一个点", + "finish_session": "⑥ 结束会话", + "closing": "正在写盘" + }, + "hint": { + "align_point": "先对准:点主按钮确认移动计划,平台到位并确认后才会出现「开始采集」。", + "start_session": "本点已对准。点主按钮建立本场标定,并从第一个点开始逐档记录。", + "not_ready": "标记 ✗ 的检查项满足后才能开始;鼠标停在标记上可看原因。", + "confirm_target": "按当前档位加载到目标力,填写人工参考力(或六维力分量)后点主按钮。", + "confirm_hold": "保持中:剩余 {seconds:.1f} s;请勿换档。", + "confirm_liftoff": "这一轮记录完成。抬起接触头、确认压头已脱离传感器,再点主按钮换到下一个点。", + "finish_session": "全部记录已写入。点主按钮结束本场标定并发布汇总。", + "closing": "正在写盘并检查录像;收尾完成前不能继续。" + }, + "message": { + "aligning": "已发出对准请求:{site}。移动完成并确认后才会允许开始采集。", + "aligned": "已确认对准:{site}", + "align_failed": "对准未完成:{detail}。平台没有确认到位,请重新对准。", + "started": "已开始本场标定:{path}", + "attach_failed": "无法开始录像:{detail}", + "queue_added": "已添加测点 {site}", + "queue_removed": "已删除测点 {site}", + "skipped": "已跳过测点 {site}", + "stopped": "已停止,已写入的数据保留。", + "done": "本场标定已结束:{path}" + }, + "recording": { + "title": "录像", + "video": "录制视频", + "codec": "视频编码", + "lossless": "同时保存无损帧", + "unavailable": "{codec}:本机不可用,已禁用", + "probe_failed": "编码探测没有运行:{detail}", + "off": "按设置不录视频", + "estimate": "预计占用 {size}({seconds});磁盘剩余 {free}", + "disk_short": "磁盘剩余不足估算体积的两倍", + "no_plan": "队列为空,暂时无法估算体积", + "locked": "会话建立后录像选项锁定", + "summary": "录像:{codec}" + }, + "settings": { + "title": "采集设置", + "input": "输入源", + "bundle": "标定包(导出包)", + "bundle_none": "未载入", + "bundle_load": "载入导出包…", + "session": "会话参数", + "simulate": "模拟运行(不接相机、不移动平台)", + "close": "关闭", + "max_required": "必须填写,程序不预设" + } } } diff --git a/apps/autocal/config/preferences.default.json b/apps/autocal/config/preferences.default.json index aa0b84d..d7f62a8 100644 --- a/apps/autocal/config/preferences.default.json +++ b/apps/autocal/config/preferences.default.json @@ -57,5 +57,10 @@ "settle_window_s": 0.5, "repeats": 3, "order": "serpentine" + }, + "recording": { + "video": true, + "codec": "mp4v", + "lossless_frames": false } } diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index 4dad02f..4f211fd 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -15,7 +15,7 @@ | 映射身份 | 保存原点、栅格尺寸和 `mapping_sha256`;哈希覆盖实际位置查找表及有效掩膜 | | 层级 | `session → site → trial → step → samples`;同点重复及中止重试均创建新 trial | -[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md)。本页不会自动移动平台。 +[LF606 规格书](references/lf606-d40-h18-4x-spec.pdf)量程表包含 50 N/2 N·m 档位,但图签为 200 N/8 N·m;实机以铭牌及标定证书核对。设备准备、运动边界和停机沿用 [workflow.md](workflow.md);本页不自动移动平台。 ## 操作员逐步流程 @@ -24,7 +24,7 @@ | 1. 准备 | 填型号、SN、保存位置,载入 finger 导出包和配置 | 首次开始试验时建立本场 session;型号、SN、配置和映射在本场固定 | | 2. 预览 | 选视频或相机编号,启动输入并检查二维 UV 图像 | 两种来源共用 finger worker;输入预览可贯穿多个 trial | | 3. 选点 | 点击 UV 粗选,再修改 U/V 到精确像素 | 显示传感器三维位置;拒绝掩膜外点 | -| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整路径;如 `0 → 5 → 10 → 20 → 30 → 20 → 10 → 5 → 0 N` | +| 4. 设循环 | 输入严格递增的正载荷档位及每档 10–30 s 保持 | 展示完整加载与卸载路径,顺序即记录顺序 | | 5. 开始 | 无载时点击“开始本次试验” | 新建 trial 并连续录像;先提示初始零载 | | 6. 保持 | 到目标后输入实际参考力,点击“已到目标力,开始保持” | 当前目标不变,倒计时期间禁止再次确认或完成试验 | | 7. 换档 | 倒计时结束后按提示手动调整至下一档 | 自动结束本档窗口;换档继续录像,但不沿用上一档恒定参考力 | @@ -36,19 +36,19 @@ 倒计时只结束档位窗口,完成或提前结束才停录像。到时不等于稳定;`stability_verified=0`,仍须复核。初始和最终零载保持时间相同,后续按恢复实测调整。 -## 模拟页与操作表 +## 操作表导出 -模拟页和标定页共用 UV 映射。选点、排序后“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行序列。三维位置与原像素不一致时拒绝导出,须重新选点。 +模拟页和采集页共用 UV 映射。在采集页队列点“导出测点操作表…”得到 UTF-8 CSV;操作员按 U/V 手动复现,不自动执行。缺 UV、缺已记录姿态,或三维位置与原像素不一致时拒绝导出,须重新选点。 | 操作表字段 | 含义 | |---|---| -| `order`、`site_id` | 表内顺序及测点编号;采集会话按首次实测顺序另行分配 site_id,以 U/V 和映射身份核对 | +| `order`、`site_id` | 表内顺序及测点编号;会话另行分配 site_id,以 U/V 与映射身份核对 | | `u_px`、`v_px` | 与标定页相同原点、方向和单位 | | `x_sensor_mm`/`y_sensor_mm`/`z_sensor_mm` | 传感器三维位置 | -| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 当前模拟安装设置下的平台目标及角度 | -| `origin_raster_x_px`/`origin_raster_y_px`、栅格宽高、轴方向、`mapping_sha256` | 像素坐标复现所需的映射身份 | +| `x_platform_mm`/`y_platform_mm`/`z_platform_mm`、`yaw_deg`、`pitch_deg` | 该点已记录姿态给出的平台目标及角度 | +| `origin_raster_x_px`/`origin_raster_y_px`、`raster_width_px`/`raster_height_px`、`u_positive`/`v_positive`、`mapping_sha256` | 像素坐标复现所需的映射身份 | -序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与模拟页操作表分别导出,不代表已接通载荷执行。 +列定义只有 `ui/site_export.py` 一处。序列页的 [`.oryseq`](sequence-format.md)仍为平台毫米坐标的几何/运动计划;它与操作表分别导出,不代表已接通载荷执行。 ## 目录与文件 @@ -73,7 +73,7 @@ finger_F001_<时间>_<标识>/ | 文件 | 内容及写入时机 | |---|---| -| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态;开始、保持结束、trial 结束和整场关闭时更新 | +| 根 `session.json` | `format_version=autocal-session-1.0`、session_id、型号/SN、配置快照及哈希、UV 定义和 uv_outline、sites、trials、状态、`recording` 块(`video`/`codec`/`lossless_frames`,建立时写一次,旧会话缺块按 `video=true` 读);开始、保持结束、trial 结束和关闭时更新 | | trial `session.json` | 共享录像元数据;另有 `calibration_session_id`、`trial_id`、`site_id`、`repeat_index`、site、plan、来源、配置、`trial_status`、`trial_reason`、`capture_status`、`capture_reason`、`capture_report`、steps;采集 session_id 是独立的整数编号 | | `raw_0001.mp4` | 保存每个已交付图像帧;容器帧率仅供播放,实际时序用 frames.csv;不等于来源视频 | | `frames.csv` | 帧身份、电脑采集时间、视频位置及丢帧状态;每帧写入 | @@ -82,7 +82,7 @@ finger_F001_<时间>_<标识>/ | `derived/review_<时间>_<标识>/review.json` | 另存初步分析,包含窗口均值、标准差、拟合和质量说明;不覆盖采集数据 | | 同目录 `overview.json` | 总览版本、session_id、计划 CSV 内容及来源哈希、测点覆盖和拟合摘要;载入时按原始数据及复核窗口重算 | | `derived/summary_*/`、`summary-latest.json` | 汇总四件文件与最后成功发布的指针;字段见[多点格式](multipoint-data-format.zh.md) | -| `derived/review_<时间>_<标识>/baseline_.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,不是跟踪器内部参考状态;无视频时在分析结果中标记 unavailable | +| `derived/review_<时间>_<标识>/baseline_.png` | 已确认初始零载窗口的第一张有效录像帧;作为检查基准,非内部参考状态;无视频时在分析结果中标记 unavailable | | `events.csv` | `trial_started`、`step_reached`、`hold_started`、`hold_ended`、`trial_finished`/`trial_aborted`;保存电脑事件时间及 step 信息 | | 参数/字段 | 定义 | @@ -138,7 +138,7 @@ finger_F001_<时间>_<标识>/ 采集页显示 FN/FX/FY raw 及最近 60 秒曲线,已标定轴另显示 N;无效帧断开,停止或超过 1 秒无新帧时提示。人工参考力不是实时 FT。 -实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。显示不改变录像及坐标。 +实时 UV 图可切换真实背景、位移箭头与深度;深度按当前帧有效范围自适应,单位不是毫米;跨帧同色不代表同值,缺失或过期字段不绘制。 检查保持段曲线、样本数、标准差和漂移;起止以保持开始为零,只能在原段内调整。勾选“已检查稳定”后重算并保存。至少两个不同载荷的已确认零载/加载窗口才有确认拟合;确认稳定不代表精度合格。 diff --git a/apps/autocal/docs/capture-workflow-plan.zh.md b/apps/autocal/docs/capture-workflow-plan.zh.md index d5bf8a2..c550968 100644 --- a/apps/autocal/docs/capture-workflow-plan.zh.md +++ b/apps/autocal/docs/capture-workflow-plan.zh.md @@ -1,10 +1,10 @@ # Autocal 标定采集页重构实施计划 -状态:2026-10-18 计划,未实施。目标是把“选点 → 对准 → 逐档加载 → 记录”收成一条流程:一个测点队列、一个不依赖 Qt 的状态控制器、一个随步骤变化的主按钮;同时加入录像选项、打开文件夹和平铺汇总。执行分工见[任务分工](capture-workflow-tasks.zh.md)。本计划不改变运动安全规则、不自动加载、不写回 SDK 系数。 +状态:2026-10-18 计划,P0–P6 已实施并通过独立验收复核。**仅软件验证**:V10 实机验收未执行,需用户指定设备、运动与载荷范围及停机条件;各项检查的实际口径见文末[实施结果](#实施结果2026-10-18)。目标是把“选点 → 对准 → 逐档加载 → 记录”收成一条流程:一个测点队列、一个不依赖 Qt 的状态控制器、一个随步骤变化的主按钮;同时加入录像选项、打开文件夹和平铺汇总。执行分工见[任务分工](capture-workflow-tasks.zh.md)。本计划不改变运动安全规则、不自动加载、不写回 SDK 系数。 ## 基线与问题 -调查基线:app `main`、HEAD `7607653`;工作树已有 `motion/service.py`、`record_diagnostics.py` 改动,实施时不得覆盖。结论来自源码、界面截图和 `D:/orisys_ws/data/dd02-gl`,未运行程序或硬件。 +调查基线:app `main`、HEAD `7607653`(实施时实际 HEAD 为 `7b5ffa7`,以 `.scratch/autocal-capture-workflow/baseline.md` 为准);工作树已有 `motion/service.py`、`record_diagnostics.py` 改动,实施时不得覆盖。结论来自源码、界面截图和 `D:/orisys_ws/data/dd02-gl`,未运行程序或硬件。 | 位置(相对 `apps/autocal/src/orisys_autocal/`) | 问题 | |---|---| @@ -116,3 +116,30 @@ | V10 | 实机验收 | 全部完成后 | 需指定设备、运动与载荷范围、停机条件;代理不执行 | V1–V9 是软件证据,不代表实机可用;V10 未执行前状态只能写“仅软件验证”。 + +## 实施结果(2026-10-18) + +证据都在 `D:\orisys_ws\orisys-app\.scratch\autocal-capture-workflow\`: + +| 阶段 | 结果 | 证据 | +|---|---|---| +| P0 | 基线 572 项测试通过;`panel_check` 2/260 既有失败 | `baseline.md` | +| P1、P2 | 打开文件夹、会话根 `summary/` 副本、录像选项与 `recording` 块 | `a/handoff-patches.zh.md`、`a/logs/` | +| P2b | 逐帧口径三轴均超 5% 门槛;保持窗口口径 FN、FX 基本在门槛内、FY 不在;本机 `cv2` 写不了 H.264 | `d/p2b-codec-equivalence.zh.md` | +| P3 | 控制器:队列、六项 Ready、单一主动作带原因、重测/跳过/停止 | `b/notes.md` | +| P4 | 新采集页只经控制器渲染,`ui/` 中 `campaign.phase` 零命中 | `c/verification.md`、`c/screens/` | +| P5 | 「几何与安装」页去掉测点表,操作表迁到队列;`panel_check` 由 2/260 失败变为 260 全过 | `c/verification-p5.md` | +| P6 | 三份格式/契约文档、`workflow.md`、README | 本目录各文档 | +| 验收 | 无阻断;2 项重要 + 1 项次要,修复后复核全部 PASS | `v/acceptance-report.zh.md` | + +当前 `pytest apps/autocal/tests` 668 项全过,`panel_check.py` 260 项全过,被改文档的 `doc_lint` 为 clean。 + +各检查的实际口径:V2、V4、V5、V7 通过;V3 只在本任务范围内 clean,全仓门禁另有并行外部会话引入的 findings; +V6 不适用(未改 `shared/`);V8 截图已备、待人工确认;V9 报告已交,是否切换默认编码待用户按 D2 决定。 + +## 验收后修正(2026-10-18,审查 F1、F3、F5) + +- 会话结束(完成或停止)后,主按钮“结束会话”把页面带回空闲:队列可编辑、可开始新会话,已记录的文件不变。 +- 保持时间到点由控制器 `poll()` 自动结束,参考窗口为计划时长(`t_end - t_start = 保持秒数`),与点击或计时器迟到多久无关。 +- 勾选“模拟”后不需要平台、总线、相机、标定包,也不需要先对准;没有标定包时,U、V 直接作为点的 X、Y,位姿标为 `simulated`。有平台时仍须先对准。 +- 仅软件验证:控制器与 Campaign 的新增测试已运行;界面测试(含新增的离线模拟用例)在本机因缺 libEGL 未能运行,须在 Windows 上补跑。 diff --git a/apps/autocal/docs/capture-workflow-tasks.zh.md b/apps/autocal/docs/capture-workflow-tasks.zh.md index 663f144..d131524 100644 --- a/apps/autocal/docs/capture-workflow-tasks.zh.md +++ b/apps/autocal/docs/capture-workflow-tasks.zh.md @@ -1,6 +1,6 @@ # 标定采集页重构:DeepSeek 代理执行清单 -状态:2026-10-18 计划,未实施。阶段定义、入口与出口条件、统一验证见[主计划](capture-workflow-plan.zh.md);本页只规定由谁执行、按什么顺序执行、交付什么。复选框记录实际完成情况。 +状态:2026-10-18 计划,W0–W4 已完成(P0–P6 全部实施)。**仅软件验证**:独立验收无阻断,2 项重要 + 1 项次要已修复并复核通过;V10 实机验收未执行。阶段定义、入口与出口条件、统一验证见[主计划](capture-workflow-plan.zh.md);本页只规定由谁执行、按什么顺序执行、交付什么。复选框记录实际完成情况。 ## 主代理指令 @@ -45,36 +45,36 @@ W4 V: 独立验收 → 主代理修复 → V 复核修复项 ### W0 主代理 -- [ ] P0 基线:记录 HEAD、工作树状态和基线测试结果到 `scratch/baseline.md`。 -- [ ] 冻结接口,写入 `scratch/interfaces.md`:`readiness()`、`primary_action()`、`perform()`、`secondary_actions()`、`poll()` 的签名、返回结构和动作键清单;`RecordingOptions` 的字段与 `session.json` 中 `recording` 块的结构。 +- [x] P0 基线:记录 HEAD、工作树状态和基线测试结果到 `scratch/baseline.md`。 +- [x] 冻结接口,写入 `scratch/interfaces.md`:`readiness()`、`primary_action()`、`perform()`、`secondary_actions()`、`poll()` 的签名、返回结构和动作键清单;`RecordingOptions` 的字段与 `session.json` 中 `recording` 块的结构。(实施中按缺口三次修订:1.4.1 对准阶梯、1.8/1.10 队列字段与编辑、1.11 补口。) ### A 数据与文件 交接提示:你负责让用户能找到数据、能控制视频体积。不得改变原始目录结构、不得删除或移动已有录像;summary 副本只是方便查看,指针仍是唯一权威。 -- [ ] P1:`reveal` 提取与三个按钮(按钮的布局由 C 在 P4 完成;A 在 P1 先接到现有会话行),`summary/` 平铺副本及失败处理。测试:`test_summary_export.py` 增加副本一致性、副本失败两项,新增 `test_reveal.py`。 -- [ ] P2:录像选项、工厂函数、`recording` 块、`video_expected` 判定、编码探测、体积估算。测试:`test_multipoint_recording.py` 增加关视频完成、编码不可用两项;`test_summary.py` 增加关视频行 eligible、旧会话不变两项;`test_preferences_isolation.py` 覆盖新的偏好键。 +- [x] P1:`reveal` 提取与三个按钮(按钮的布局由 C 在 P4 完成;A 在 P1 先接到现有会话行),`summary/` 平铺副本及失败处理。测试:`test_summary_export.py` 增加副本一致性、副本失败两项,新增 `test_reveal.py`。 +- [x] P2:录像选项、工厂函数、`recording` 块、`video_expected` 判定、编码探测、体积估算。测试:`test_multipoint_recording.py` 增加关视频完成、编码不可用两项;`test_summary.py` 增加关视频行 eligible、旧会话不变两项;`test_preferences_isolation.py` 覆盖新的偏好键。(体积估算与探测界面由 C 在 P4 实现。) ### B 控制器 交接提示:你负责把流程判断从界面中移出。控制器不得发出运动命令、不得导入 Qt;对准只发出请求。每个禁用的动作必须带原因,原因文字通过 locale 键给出。 -- [ ] P3:实现控制器及表驱动测试;用同一组输入比较“直接驱动 `Campaign`”与“经由控制器”写出的文件结构。 +- [x] P3:实现控制器及表驱动测试;用同一组输入比较“直接驱动 `Campaign`”与“经由控制器”写出的文件结构。 ### C 界面 交接提示:你负责重排界面,依据是设计稿与主计划 P4、P5。界面只渲染控制器的输出;不得在 `ui/` 中判断 `campaign.phase`。实时图与 UV 图必须能同时看到。 -- [ ] P4:新布局、更改输入对话框、队列表模型、当前点卡片、消息行;在模拟模式下用界面测试走完两点;截图存 scratch。 -- [ ] P5:Finger debug 改名并瘦身,测点表迁移,安装偏置变化后重新求解。 +- [x] P4:新布局、更改输入对话框、队列表模型、当前点卡片、消息行;在模拟模式下用界面测试走完两点;截图存 scratch。 +- [x] P5:Finger debug 改名并瘦身,测点表迁移,安装偏置变化后重新求解。 ### D 编码验证 -- [ ] P2b:按主计划完成对比并写报告,内容包括输入文件、编码参数、逐帧差值统计和窗口均值差占 1 N 阶跃的比例。不给结论性推荐,由用户决定。 +- [x] P2b:按主计划完成对比并写报告,内容包括输入文件、编码参数、逐帧差值统计和窗口均值差占 1 N 阶跃的比例。不给结论性推荐,由用户决定。 ### V 验收 -- [ ] 用新进程跑完主计划 V1–V8;按严重度列出发现,每条附复现步骤;不修改代码。 +- [x] 用新进程跑完主计划 V1–V8;按严重度列出发现,每条附复现步骤;不修改代码。 ## 子代理交付格式 @@ -89,6 +89,17 @@ W4 V: 独立验收 → 主代理修复 → V 复核修复项 ## 主代理最终交接 -- [ ] 所有改动保持未暂存;每个改动过的仓库各写 `.scratch/autocal-capture-workflow/commit-message.txt`,按 AGENTS 第 7 条给出单行的 `git add` 和 `git commit -F` 命令。 -- [ ] 列出需要排除的既有改动。 -- [ ] 状态写为“仅软件验证,待实机验收”;V10 实机验收需要用户指定设备、运动与载荷范围以及停机条件。 +- [x] 所有改动保持未暂存;每个改动过的仓库各写 `.scratch/autocal-capture-workflow/commit-message.txt`,按 AGENTS 第 7 条给出单行的 `git add` 和 `git commit -F` 命令。(本任务只改 `orisys-app`。) +- [x] 列出需要排除的既有改动。 +- [x] 状态写为“仅软件验证,待实机验收”;V10 实机验收需要用户指定设备、运动与载荷范围以及停机条件。 + +## 需要排除的既有改动(不属于本任务) + +`orisys-app` 工作树里这些改动在任务开始前就存在,提交时必须排除:`apps/autocal/src/orisys_autocal/motion/service.py`、 +`apps/autocal/src/orisys_autocal/record_diagnostics.py`;未跟踪的 `LZ.*.zip`、`LZ.LigMaster V1.7.0/`、 +`LZ.Devpack_L826(六维力非标设备包)(1)/`、`docs/test-execution-plan.zh.md`。 + +任务运行期间还有并行会话改了工作区其他部分,同样不属于本任务:`apps/studio/**` 与 +`shared/src/orisys_app_shared/theme/qss.py`(另一个 Studio 评审会话)、工作区根的 `docs/README.md` 与 +`docs/inventory.json`、`orisys-sdk` 的 `HEAD`(`f7f4682` → `ff7614a`,只改 `calibration.py`)。因此全仓 +`lint_gate` 的 `status: FINDINGS` 来自这些外部改动,本任务范围内的文件是 `status: clean`。 diff --git a/apps/autocal/docs/multipoint-data-format.zh.md b/apps/autocal/docs/multipoint-data-format.zh.md index b804528..1ff39b3 100644 --- a/apps/autocal/docs/multipoint-data-format.zh.md +++ b/apps/autocal/docs/multipoint-data-format.zh.md @@ -23,6 +23,7 @@ sites//repeat-00N/ | 文件 | 内容 | |---|---| | 根 `session.json` | `format_version=autocal-multipoint-1.0`、session_id、型号/SN、`coordinate_frame`、`force_to_tool_rotation`、`normal_projection`、力范围、`sync`、sites、trials、`completion`、`status`/`reason`;`platform_motion` 与 `automatic_force_control` 均为 false | +| 根 `session.json` 的 `recording` 块 | `video`、`codec`、`lossless_frames`;建立会话时写入并锁定,后续 repeat 不改写;缺该块的旧会话按 `video=true` 读取 | | site 记录 | `site_id`、`position`/`position_frame`、`normal_sensor`、`pose`、`uv_px`、`levels_n`、`hold_s`、`repeats`;`pose` 为空或 `in_range=false` 的点只保存、不下发 | | repeat `session.json` | `trial_id`、site、`pose`、`uv_px`、`steps[{step_index, branch, target_load_n}]`、`sync`、`images`、`status`;录像收尾后另有 `capture_status`、`capture_reason`、`capture_report`(共享记录的统计与问题清单) | | `reference.csv` | 每个实际开始的保持窗口一行:`step_index`、`branch`、`target_load_n`、`t_start_host_mono_ns`、`t_end_host_mono_ns`、`F_ref_normal_N`、site 坐标与坐标系、`repeat_index`/`repeat_count`、`hold_complete`、`reference_source`、`fx_N`/`fy_N`/`fz_N`、力到工具的旋转、`nx`/`ny`/`nz`、`pose_*`、`normal_aligned_to` | @@ -80,6 +81,8 @@ unknown,不把 `images.captured=true` 当成录像已验证。 `summary-latest.json`(相对路径、`summary_id`、输入与文件哈希)。采集终态自动发布候选汇总;人工 复核保存后再发布已复核汇总;失败保留旧指针并可重试,采集结果不变。 +指针替换成功后,同一份四件文件镜像到会话根 `summary/`:先写临时目录再替换目录;副本失败只在界面/日志报告,指针仍指向 `derived/` 中的已发布版本,采集结果不变。 + 两张表都带 `schema_version`、`summary_id` 与来源文件: | 表 | 行单位 | 关键字段 | diff --git a/apps/autocal/docs/recording-summary-contract.zh.md b/apps/autocal/docs/recording-summary-contract.zh.md index 8709fbf..93bda18 100644 --- a/apps/autocal/docs/recording-summary-contract.zh.md +++ b/apps/autocal/docs/recording-summary-contract.zh.md @@ -34,6 +34,11 @@ summary.html baseline_load_data.csv sensitivity_values.csv + summary/ # 最近一次发布的可读副本(四件文件),非权威 + summary.json + summary.html + baseline_load_data.csv + sensitivity_values.csv summary-latest.json # 最后成功发布的相对路径指针 ``` @@ -41,6 +46,8 @@ 导出先在唯一临时目录写齐并验证,再发布同盘目录,最后原子替换指针。失败保留旧指针,报告导出失败,不回滚采集状态。指针使用相对路径、summary_id 和内容哈希;并发发布由会话级单一任务串行化,取消后的旧任务不能更新指针(取消由调用方传入,界面暂未提供取消按钮)。原始文件、历史 review 和已发布 summary 均不覆盖。 +指针替换后镜像同一份四件文件到根 `summary/`(先写临时目录再替换);副本失败只报告,不回滚指针、不改采集结果。 + ## 录像身份、时间和生命周期 逻辑身份使用 `(calibration_session_id, trial_id, capture_session_id, frame_id)`;保持窗口再加 `step_index`。共享 recorder 的整数 session_id 不等于标定 session_id。同试验来源重启不得静默复用 frame_id;中止并新建 trial。重复身份、行数不符或缺关联必须报错/排除,不能退回按行号拼接。 diff --git a/apps/autocal/docs/recording-workflow.zh.md b/apps/autocal/docs/recording-workflow.zh.md index 544ed4d..3befc5f 100644 --- a/apps/autocal/docs/recording-workflow.zh.md +++ b/apps/autocal/docs/recording-workflow.zh.md @@ -4,8 +4,14 @@ 图中原有模块归属的源码引用固定在 `19426c3c4a648ca7f4d3785e2e11b1bf124a8e31`。本次五项修复尚未提交:写线程失败时中止、候选与确认结果分别汇总、按所选区间回放、允许已关闭失败记录诊断,以及取消全部解码图像的累积缓存。当前实现以[工作树快照清单](diagrams/recording-20261017-110144/snapshot.json)及其同目录补丁为依据,不将工作树字节冒充 HEAD 源码。 -每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。 +每 trial 连续保存零载、换档、加载和卸载画面,MP4 为有损输入录像。保持窗口依靠主机采集时间与帧身份关联,不能用容器 FPS 推算实验同步。操作员手动确认参考载荷;该流程不自动加载,也不证明 FT 物理同步。界面上的实时曲线与实时 UV 图只显示已有数据,不改变录像与坐标。 原始文件继续使用[单次](calibration-data-format.zh.md)与[多点](multipoint-data-format.zh.md)目录。保持段不拆成新视频,复核与汇总另存版本;字段及计算方法见[汇总契约](recording-summary-contract.zh.md)。 +## 录像选项与关闭视频 + +录像选项(`video`、`codec`、`lossless_frames`)在建立会话前选定,写入根 `session.json` 的 `recording` 块并锁定;会话建立后再改不生效。可用编码在启动时用 3 帧探测,写不出来的编码在界面上禁用并写明原因,绝不静默回退到别的编码。本机 `cv2` 目前只能写 `mp4v`,H.264 需要另定编码器来源(未验证,见 `.scratch/autocal-capture-workflow/d/p2b-codec-equivalence.zh.md`)。 + +关闭视频时 `capture_verdict` 跳过视频类判定,只保留数据类判定(帧与力的写入、来源会话未变等),因此关视频的 trial 仍可 complete、汇总行仍 eligible;复核界面按 `recording` 块显示「按设置未录视频」。缺该块的旧会话按 `video=true` 读取,结果与以前一致。体积按码率与计划时长估算,与磁盘剩余一并显示,不足两倍估算时警告。 + 验证:五个回归用例在修复前失败;修复后的相关 45 项测试和 Autocal 全量 572 项测试通过。Archify 的 showcase 校验、产物检查及真实浏览器检查通过,并检查了截图。未操作真实相机、平台或载荷。 diff --git a/apps/autocal/docs/workflow.md b/apps/autocal/docs/workflow.md index 5a28030..08b18cd 100644 --- a/apps/autocal/docs/workflow.md +++ b/apps/autocal/docs/workflow.md @@ -236,7 +236,8 @@ measured time per point. **Gate:** the operator has seen the path and a dry run has completed without a guard violation. -**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the +**The single-point dry run is built**, in the geometry & mounting screen (几何与安装, +which no longer carries a point table): 执行动作 sends the selected point's *solved pose* to the platform — the stages together with the table X/Y, and the press axis only once its zero is calibrated (设备设置 → 压轴); until then it is held, so the move cannot reach the sensor by accident. The order follows the @@ -282,6 +283,22 @@ data stays valid. **Existing code:** the motion layer has the guards and the poller; the step machine, the contact detection and the acquisition binding are new. +**As built today** (manual, through the capture page): the operator keeps one +queue of points — a single point is a queue with one row. `Ready` must be all +green before the first point. The page shows one primary button whose label is +the next step (align → start → at the target → hold finished → confirm liftoff → +finish) and states the reason whenever it is disabled. Alignment only requests a +guarded pose move, and a capture may not start until that move has completed, so +a repeat can never be recorded on a pose nobody confirmed. Remeasure, skip and +stop are the secondary actions. What the button says is derived from the campaign +state by `capture_controller.py`, never by the widgets. + +Changing the mounting offset, the tool XY or the travel limits re-solves every +queued pose: while the queue is still editable the new solution is stored, and a +point the stage can no longer reach is marked and refused for alignment. The +exported operation table comes from the same queue (queue → 导出测点操作表…), so +the table and the run cannot describe different points. + --- ## P8 — Store raw diff --git a/apps/autocal/src/orisys_autocal/calibration_session.py b/apps/autocal/src/orisys_autocal/calibration_session.py index fddcb1b..59aa3ed 100644 --- a/apps/autocal/src/orisys_autocal/calibration_session.py +++ b/apps/autocal/src/orisys_autocal/calibration_session.py @@ -8,6 +8,8 @@ from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 +from .recording_options import RecordingOptions + def write_json(path: Path, payload: dict) -> None: """Replace a manifest atomically, keeping the previous version on failure.""" @@ -31,11 +33,15 @@ class CalibrationSession: identity.append(value) self.directory = root / f"{'_'.join(identity)}_{stamp}_{uuid4().hex[:12]}" self.identity = self.identity_of(metadata) + #: The video choice belongs to the session, not to each trial: it is + #: written once here and never rewritten by a later ``add_trial``. + self.recording = RecordingOptions.from_block(metadata.get("recording")) self.document = { "format_version": "autocal-session-1.0", "session_id": self.directory.name, **self.identity, "config_snapshot": metadata["config_snapshot"], + "recording": self.recording.as_block(), "started_at": datetime.now(timezone.utc).isoformat(), "status": "in_progress", "sites": [], diff --git a/apps/autocal/src/orisys_autocal/capture_controller.py b/apps/autocal/src/orisys_autocal/capture_controller.py new file mode 100644 index 0000000..3ebb20a --- /dev/null +++ b/apps/autocal/src/orisys_autocal/capture_controller.py @@ -0,0 +1,773 @@ +"""The capture page's workflow controller. + +The controller owns the operator's next step. It wraps one +:class:`~orisys_autocal.multipoint.Campaign` (the point queue) and turns the +campaign's state into the single primary button the page shows, plus the three +secondary actions. It is deliberately headless — no Qt and no motion service — so +the whole decision can be exercised without a window, a camera or a controller. + +Aligning a point is only ever *requested*: the request is published on +:attr:`CaptureController.pending_alignment` and the window plans the move. The +controller cannot command motion even by accident. + +The queue is edited through the controller too: adding, updating, removing and +reordering a point, and storing the reference rotation, all go through its +methods, and :meth:`model` returns every field the page renders. That keeps the +page from reading ``campaign.sites`` or writing a ``SiteSpec``. + +The public contract is frozen in +``.scratch/autocal-capture-workflow/interfaces.md`` section 1. Two facts the +controller does not invent for itself: + +* the arguments :meth:`Campaign.start` needs (output root, sensor model and SN, + coordinate frame, simulate flag) are supplied by :attr:`start_arguments`, + because they are the window's current state, not the campaign's; +* whether a point must be aligned first is answered by ``alignment_required``. The + default is yes; the page says no only for a simulated session, which commands no + axis and so needs no platform, bus or surface solver; +* the maximum force is the controller's own ``max_force_n`` and is the only + source of the sixth readiness check. Before a session exists the campaign has + no maximum force, and after it starts the value has already been validated. + +The "this point is aligned" flag is what separates :meth:`align_point` from the +other primary actions. Only the window may set it, through +:meth:`mark_aligned`, after the requested move has actually finished: a request +can be refused, cancelled or fail, and starting a session on the old pose would +record the wrong geometry. The flag is cleared when a *different* point comes +into focus (the start of a session and each point change), never after +``campaign.reacquire()``: the platform is already on that point and recording it +again does not need another move. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, NamedTuple + +from .multipoint import Campaign + +if TYPE_CHECKING: # pragma: no cover - typing only, keeps the motion package out + from .motion.kinematics import PlatformStack + +#: Readiness rows in the frozen order. The label keys are the page's, not this +#: module's: a locale translation of a reason is never stored in the controller. +READINESS_ITEMS: tuple[tuple[str, str], ...] = ( + ("calibration_bundle", "cap.check.bundle"), + ("input_frames", "cap.check.frames"), + ("bus_ready", "cap.check.bus"), + ("output_writable", "cap.check.output"), + ("sensor_sn", "cap.check.sn"), + ("max_force", "cap.check.max_force"), +) + +READINESS_KEYS: tuple[str, ...] = tuple(key for key, _label in READINESS_ITEMS) + +_ACTION_LABELS = { + "align_point": "cap.action.align", + "start_session": "cap.action.start", + "confirm_target": "cap.action.confirm_target", + "confirm_hold": "cap.action.confirm_hold", + "confirm_liftoff": "cap.action.confirm_liftoff", + "finish_session": "cap.action.finish", + "closing": "cap.block.closing", + "remeasure": "cap.action.remeasure", + "skip": "cap.action.skip", + "stop": "cap.action.stop", +} + + +class ActionBlocked(RuntimeError): + """An action exists but cannot run yet. + + ``reason`` is a locale key (``cap.block.*``), never translated text; the page + owns the translation. ``label_key`` names the action that was refused. + """ + + def __init__(self, reason: str, label_key: str = "") -> None: + super().__init__(reason) + self.reason = reason + self.label_key = label_key + + +@dataclass(frozen=True) +class ReadinessCheck: + """One external fact the capture page needs before it may start.""" + + key: str + ok: bool + label_key: str + detail: str = "" + + +class PrimaryAction(NamedTuple): + key: str + label_key: str + enabled: bool + blocked_reason: str + + +@dataclass(frozen=True) +class AlignmentRequest: + """The five-axis targets for one point, for the window's move planner.""" + + site_id: str + targets: dict[str, float] + + +@dataclass(frozen=True) +class SiteRow: + """One row of the point queue, as the page renders it. + + Everything the queue table and the point editor may show is copied into the + row, so the page never reads ``campaign.sites`` or a ``SiteSpec`` field. + ``pose`` is a snapshot: a display or comparison value, not a handle the page + may write through. + """ + + site_id: str + position: dict[str, float] + repeats_planned: int + repeats_complete: int + complete: bool + reachable: bool + levels: tuple[float, ...] + hold_s: float + uv_px: tuple[float, float] | None + normal: tuple[float, float, float] | None + pose: dict | None + position_frame: str + skipped: bool + + +@dataclass(frozen=True) +class CaptureModel: + """Everything the page may read about the campaign, in one snapshot.""" + + sites: list[SiteRow] + current_index: int + progress: dict + + +class CaptureController: + """Walk one campaign through the operator's steps without touching Qt or motion.""" + + def __init__( + self, + campaign: Campaign, + *, + solver: PlatformStack | None = None, + readiness: Callable[[], list[ReadinessCheck]] | None = None, + max_force_n: float | None = None, + start_arguments: Callable[[], dict] | None = None, + alignment_required: Callable[[], bool] | None = None, + ) -> None: + self.campaign = campaign + self.solver = solver + self._readiness = readiness + self._max_force_n = None if max_force_n is None else float(max_force_n) + self._start_arguments = start_arguments + self._alignment_probe = alignment_required + self._pending_alignment: AlignmentRequest | None = None + self._aligned: set[str] = set() + self._skipped: set[str] = set() + self._focus_site = "" + + # ------------------------------------------------------------------ readiness + + @property + def ready(self) -> bool: + return all(check.ok for check in self.readiness()) + + def readiness(self) -> list[ReadinessCheck]: + """All six checks, in the frozen order, including the ones that failed.""" + probe = self._probe_checks() + result: list[ReadinessCheck] = [] + for key, label in READINESS_ITEMS: + if key == "max_force": + result.append(self._max_force_check(key, label)) + else: + result.append(probe[key]) + return result + + def _max_force_check(self, key: str, label: str) -> ReadinessCheck: + value = self._max_force_n + ok = value is not None and math.isfinite(value) and value > 0 + return ReadinessCheck(key, ok, label, "" if ok else "max_force_n is not set") + + def _probe_checks(self) -> dict[str, ReadinessCheck]: + """Map the injected probe's answer onto the six frozen keys. + + A probe that raises or omits a key must not take the page down: the + missing rows are reported as *not* ready with their cause, because an + unreported fact is not a satisfied one. + """ + if self._readiness is None: + return { + key: ReadinessCheck(key, True, label) for key, label in READINESS_ITEMS + } + try: + supplied = list(self._readiness()) + except Exception as exc: # noqa: BLE001 - a probe is external and must not crash the page + detail = str(exc) or type(exc).__name__ + return { + key: ReadinessCheck(key, False, label, detail) + for key, label in READINESS_ITEMS + } + reported = { + str(getattr(check, "key", "")): check + for check in supplied + if getattr(check, "key", "") + } + rows: dict[str, ReadinessCheck] = {} + for key, label in READINESS_ITEMS: + check = reported.get(key) + if check is None: + rows[key] = ReadinessCheck( + key, False, label, "the readiness probe did not report this check" + ) + else: + rows[key] = ReadinessCheck( + key, bool(check.ok), label, str(getattr(check, "detail", "") or "") + ) + return rows + + # ------------------------------------------------------------------ primary action + + def primary_action(self) -> PrimaryAction: + """The one button the page shows now, with a reason whenever it is disabled.""" + self._sync_alignment_focus() + campaign = self.campaign + phase = campaign.phase + if self._closing(): + return PrimaryAction( + "closing", "cap.block.closing", False, "cap.block.closing" + ) + if phase == "idle": + if not campaign.sites: + return self._start_action() + # A session that moves nothing has no pose to reach first. + if not self._alignment_required() or self._aligned_in_focus(): + return self._start_action() + return self._align_action() + if phase == "liftoff": + pending = campaign.pending_alignment_site + if ( + pending + and self._alignment_required() + and not self._is_aligned(pending) + ): + action = self._align_action() + if action.enabled: + return action + return PrimaryAction( + "confirm_liftoff", "cap.action.confirm_liftoff", True, "" + ) + if phase == "load": + return PrimaryAction( + "confirm_target", "cap.action.confirm_target", True, "" + ) + if phase == "hold": + if campaign.remaining_s() > 0: + return PrimaryAction( + "confirm_hold", + "cap.action.confirm_hold", + False, + "cap.block.hold_running", + ) + return PrimaryAction("confirm_hold", "cap.action.confirm_hold", True, "") + if phase in {"done", "aborted"}: + return PrimaryAction("finish_session", "cap.action.finish", True, "") + # An unknown phase is never actionable; say the repeat is being written + # rather than inventing a button that would do nothing. + return PrimaryAction("closing", "cap.block.closing", False, "cap.block.busy") + + def _alignment_required(self) -> bool: + """Whether a point must be aligned before it is recorded. + + A simulated session commands no axis, so the page says alignment is not + needed and the platform, the bus and the surface solver can all be absent. + Without that answer (no probe, or one that fails) alignment is required: the + stricter reading is the safe one. + """ + if self._alignment_probe is None: + return True + try: + return bool(self._alignment_probe()) + except Exception: # noqa: BLE001 - the page's own state must not break the decision + return True + + def _start_action(self) -> PrimaryAction: + campaign = self.campaign + if not campaign.sites: + return self._disabled("start_session", "cap.block.no_sites") + if self._start_arguments is None: + return self._disabled("start_session", "cap.block.no_session") + if not self.ready: + return self._disabled("start_session", "cap.block.not_ready") + return PrimaryAction("start_session", "cap.action.start", True, "") + + def _align_action(self) -> PrimaryAction: + if self._recording(): + return self._disabled("align_point", "cap.block.align_while_recording") + if self.solver is None: + return self._disabled("align_point", "cap.block.no_solver") + site_id = self._focus_site + if not site_id or not self._pose_usable(site_id): + return self._disabled("align_point", "cap.block.no_pose") + return PrimaryAction("align_point", "cap.action.align", True, "") + + @staticmethod + def _disabled(key: str, reason: str) -> PrimaryAction: + return PrimaryAction(key, _ACTION_LABELS[key], False, reason) + + # ------------------------------------------------------------------ secondary actions + + def secondary_actions(self) -> list[PrimaryAction]: + """Remeasure, skip and stop, in that frozen order.""" + self._sync_alignment_focus() + return [self._remeasure_action(), self._skip_action(), self._stop_action()] + + def _remeasure_action(self) -> PrimaryAction: + enabled = self.campaign.can_remeasure_step() + return PrimaryAction( + "remeasure", + "cap.action.remeasure", + enabled, + "" if enabled else "cap.block.remeasure_unavailable", + ) + + def _skip_action(self) -> PrimaryAction: + if not hasattr(self.campaign, "skip_current_point"): + return self._disabled("skip", "cap.block.skip_unavailable") + if self._recording(): + return self._disabled("skip", "cap.block.skip_live") + if self.campaign.phase not in {"load", "hold"}: + return self._disabled("skip", "cap.block.busy") + return PrimaryAction("skip", "cap.action.skip", True, "") + + def _stop_action(self) -> PrimaryAction: + running = self.campaign.is_active or self.campaign.busy + return PrimaryAction( + "stop", + "cap.action.stop", + running, + "" if running else "cap.block.stop_unavailable", + ) + + # ------------------------------------------------------------------ perform + + def perform(self, key: str, **inputs) -> None: + """Run one offered action, or explain in a locale key why it cannot run.""" + # A recording repeat must never be interrupted by a move, and a caller can + # ask for it directly instead of through the button. This guard outranks + # the "not the current action" check, so the refusal names the real cause. + if key == "align_point" and self._recording(): + raise ActionBlocked("cap.block.align_while_recording", "cap.action.align") + self._sync_alignment_focus() + primary = self.primary_action() + secondary = {action.key: action for action in self.secondary_actions()} + if key == primary.key: + action = primary + elif key in secondary: + action = secondary[key] + else: + raise ValueError(f"{key!r} is not an offered capture action") + if not action.enabled: + raise ActionBlocked(action.blocked_reason, action.label_key) + if inputs and key not in {"confirm_target", "stop"}: + raise ValueError(f"{key} takes no inputs") + if key == "start_session": + self._perform_start_session() + elif key == "align_point": + self._perform_align_point() + elif key == "confirm_target": + self._perform_confirm_target(inputs) + elif key == "confirm_hold": + self.campaign.complete_hold() + elif key == "confirm_liftoff": + self.campaign.confirm_liftoff() + elif key == "finish_session": + self._perform_finish_session() + elif key == "remeasure": + self.campaign.remeasure_step() + elif key == "skip": + self._perform_skip() + elif key == "stop": + self._perform_stop(inputs) + else: # pragma: no cover - "closing" and unknown keys never reach here + raise ValueError(f"{key!r} is not an executable capture action") + + def _perform_finish_session(self) -> None: + """Close the finished session out so the queue is editable again. + + ``flush_close`` is not what ends a session: it only waits for a writer that + is still closing, and ``primary_action`` never offers this key while one is. + What the operator needs is the way back to the idle phase, with the next + session possible and the finished one's files left exactly as they are. + """ + self.campaign.end_session() + # The platform stayed where the last point left it, and the queue may be + # edited next: nothing is aligned until a move says so. + self._aligned.clear() + self._pending_alignment = None + + def _perform_start_session(self) -> None: + arguments = self._start_arguments + if arguments is None: # pragma: no cover - the action is disabled without it + raise ActionBlocked("cap.block.no_session", "cap.action.start") + self._pending_alignment = None + self.campaign.start(**arguments()) + # A new session measures every point again: skip marks belong to the + # session that recorded them. + self._skipped.clear() + + def _perform_align_point(self) -> None: + """Publish the move request. It does not mean the platform has moved.""" + site_id = self._focus_site + try: + targets = dict(self.campaign.command_targets(site_id)) + except ValueError as exc: + raise ActionBlocked("cap.block.no_pose", "cap.action.align") from exc + self.pending_alignment = AlignmentRequest(site_id=site_id, targets=targets) + + def _perform_confirm_target(self, inputs: dict) -> None: + force_n = inputs.pop("force_n", None) + wrench = inputs.pop("wrench", None) + rotation = inputs.pop("rotation", None) + if inputs: + raise ValueError("confirm_target accepts force_n or wrench, nothing else") + if (force_n is None) == (wrench is None): + raise ValueError("confirm_target needs exactly one of force_n or wrench") + if force_n is not None: + if rotation is not None: + raise ValueError("rotation only applies to a six-axis reference") + self.campaign.confirm_reference(float(force_n)) + return + values = tuple(float(value) for value in wrench) + if len(values) != 3: + raise ValueError("wrench needs three components") + if rotation is not None: + # ``Campaign.start`` cleared the rotation; the only moment the operator + # enters it is this confirmation, so set it right before the reading. + self.campaign.set_force_rotation([float(value) for value in rotation]) + self.campaign.confirm_wrench(values[0], values[1], values[2]) + + def _perform_skip(self) -> None: + method = getattr(self.campaign, "skip_current_point", None) + if method is None: # pragma: no cover - the action is disabled without it + raise ActionBlocked("cap.block.skip_unavailable", "cap.action.skip") + site_id = self._focus_site + method() + if site_id: + # The queue still lists the point; the row says it was skipped so the + # operator can see it was not recorded rather than guess from a count. + self._skipped.add(site_id) + + def _perform_stop(self, inputs: dict) -> None: + """Stop the session, with an optional free-text reason for the record. + + ``session.json``'s ``reason`` is free text, not a locale key: the stop + button keeps the default phrase, while the window's shutdown path passes + its own so an application close stays distinguishable from an operator + stop in the data. + """ + reason = inputs.pop("reason", None) + if inputs: + raise ValueError("stop accepts a reason only") + if reason is None: + text = "operator stopped" + else: + text = str(reason).strip() + if not text: + raise ValueError("a stop reason must not be blank") + self.campaign.stop(text) + + # ------------------------------------------------------------------ alignment result + + def mark_aligned(self, site_id: str | None = None) -> None: + """The window confirmed the requested move finished. + + Only this call sets the "this point is aligned" flag. A request on its own + must not, because the move can be refused, cancelled, or fail half way — + and starting a session there would record the old pose. ``site_id=None`` + names the point currently in focus. + """ + target = self._focus_site_id() if site_id is None else str(site_id) + if not target: + raise ValueError("there is no point in focus to mark as aligned") + self._aligned.add(target) + + def invalidate_alignment(self) -> None: + """Forget every "this point is aligned" flag. + + The flag records that the platform stood on a point's pose at some moment. + Anything that moves it elsewhere (a jog, a home, a move from another page), + or changes what a pose means (a new surface, a changed mounting), makes + that statement false, and starting on it would record the wrong geometry. + Never raises: it runs on the window's motion paths. + """ + self._aligned.clear() + + def mark_alignment_failed(self, site_id: str | None = None) -> None: + """A move was refused, cancelled or failed, so the point stays unaligned. + + The page still shows ``align_point`` for a retry. This never raises: it + runs on an error path where the page has its own message to show. + """ + target = self._focus_site_id() if site_id is None else str(site_id) + if target: + self._aligned.discard(target) + + # ------------------------------------------------------------------ queue editing + + def add_point( + self, + site_id: str, + x: float, + y: float, + z: float, + normal: tuple[float, float, float], + pose: dict, + levels: list[float], + hold_s: float, + repeats: int, + *, + uv_px: tuple[float, float] | None = None, + ) -> None: + """Add a solved point to the queue. Idle only; never moves the platform.""" + self._require_queue_editable() + self.campaign.add_solved_site( + site_id, + x, + y, + z, + normal, + pose, + levels, + hold_s, + repeats, + max_force_n=self._require_max_force(), + uv_px=uv_px, + ) + self._skipped.discard(site_id) + self._aligned.discard(site_id) + + def update_point( + self, + site_id: str, + x: float, + y: float, + z: float, + levels: list[float], + hold_s: float, + repeats: int, + *, + new_id: str | None = None, + ) -> None: + """Replace a point's geometry or plan. Idle only. + + The stored pose and normal are kept by ``Campaign.update_site`` unless the + page re-solves and stores them; a changed point therefore loses its "this + point is aligned" flag, because that move was aimed at the old geometry. + """ + self._require_queue_editable() + self.campaign.update_site( + site_id, + x, + y, + z, + levels, + hold_s, + repeats, + max_force_n=self._require_max_force(), + new_id=new_id, + ) + for target in {site_id, new_id or site_id}: + self._skipped.discard(target) + self._aligned.discard(target) + + def remove_point(self, site_id: str) -> None: + """Remove one point from the queue. Idle only.""" + self._require_queue_editable() + self.campaign.remove_site(site_id) + self._skipped.discard(site_id) + self._aligned.discard(site_id) + + def move_point(self, site_id: str, delta: int) -> None: + """Move one point in the queue order. Idle only; positions are unchanged.""" + self._require_queue_editable() + self.campaign.move_site(site_id, delta) + + def set_force_rotation(self, values: Sequence[float]) -> None: + """Store the force-sensor-to-tool rotation used by six-axis references.""" + self._require_queue_editable() + self.campaign.set_force_rotation(list(values)) + + def store_solved_pose(self, site_id: str, pose: dict) -> None: + """Store a freshly solved pose for a point. Idle only. + + Re-solving is queue data, so it belongs behind the controller like the + other edits. The point loses its "this point is aligned" flag: a move made + for the old pose is not a move to this one. + """ + self._require_queue_editable() + self.campaign.store_solved_pose(site_id, pose) + self._aligned.discard(site_id) + + def set_max_force(self, value: float | None) -> None: + """Update the maximum force the readiness check and queue edits use.""" + self._max_force_n = None if value is None else float(value) + + def _require_queue_editable(self) -> None: + if self.campaign.phase != "idle": + raise ActionBlocked("cap.block.queue_busy") + + def _require_max_force(self) -> float: + if self._max_force_n is None: + raise ActionBlocked("cap.block.no_max_force") + return self._max_force_n + + # ------------------------------------------------------------------ poll and views + + def poll(self) -> dict: + """Advance closing evidence and report what the page should draw. + + ``campaign.poll()`` is what finalises a repeat's recording; omitting it + would leave the page waiting on a close that never finishes. + """ + self.campaign.poll() + # The planned hold time is the operator's plan, not a button to wait for: the + # window ends when the time does. A failure here (a writer that died during + # the hold, say) reaches the page like any other poll error. + self.campaign.auto_complete_hold() + self._sync_alignment_focus() + return { + "phase": self.campaign.phase, + "capture_state": self.campaign.capture_state, + "capture_reason": self.campaign.capture_reason, + "remaining_s": float(self.campaign.remaining_s()), + "pending_alignment_site": self.campaign.pending_alignment_site, + } + + def model(self) -> CaptureModel: + """The queue and the campaign's own progress, read-only. + + One row carries every field the queue table and the point editor show, so + the page never touches ``campaign.sites``. ``pose`` and ``position`` are + copies: the page may compare them, not write through them. + """ + self._sync_alignment_focus() + completion = {row["site_id"]: row for row in self.campaign.completion()} + rows = [] + for site in self.campaign.sites: + counts = completion.get(site.site_id) or {} + rows.append( + SiteRow( + site_id=site.site_id, + position=site.position(), + repeats_planned=int(site.repeats), + repeats_complete=int(counts.get("repeats_complete", 0)), + complete=bool(counts.get("complete", False)), + reachable=self._pose_usable(site.site_id), + levels=tuple(float(value) for value in site.levels_n), + hold_s=float(site.hold_s), + uv_px=( + None + if site.uv_px is None + else (float(site.uv_px[0]), float(site.uv_px[1])) + ), + normal=( + None + if site.normal is None + else tuple(float(value) for value in site.normal) + ), + pose=None if site.pose is None else dict(site.pose), + position_frame=str(site.position_frame or ""), + skipped=site.site_id in self._skipped, + ) + ) + return CaptureModel( + sites=rows, + current_index=self._current_index(), + progress=self.campaign.progress(), + ) + + @property + def pending_alignment(self) -> AlignmentRequest | None: + """The move the page still has to plan. The page clears it after taking it.""" + return self._pending_alignment + + @pending_alignment.setter + def pending_alignment(self, value: AlignmentRequest | None) -> None: + self._pending_alignment = value + + # ------------------------------------------------------------------ state helpers + + def _closing(self) -> bool: + return ( + self.campaign.phase == "closing" or self.campaign.capture_state == "closing" + ) + + def _recording(self) -> bool: + return self.campaign.capture_state == "recording" and self.campaign.phase in { + "load", + "hold", + } + + def _is_aligned(self, site_id: str) -> bool: + return bool(site_id) and site_id in self._aligned + + def _aligned_in_focus(self) -> bool: + return self._is_aligned(self._focus_site) + + def _pose_usable(self, site_id: str) -> bool: + """Whether the stored pose may be commanded. Read-only; never solves.""" + try: + self.campaign.command_targets(site_id) + except (KeyError, ValueError): + return False + return True + + def _sync_alignment_focus(self) -> str: + """Track the point in focus; a *different* point is not aligned yet. + + An empty focus (no session, or a finished one) never clears a flag, so the + point a ``campaign.reacquire()`` comes back to keeps its alignment. + """ + focus = self._focus_site_id() + if focus and self._focus_site and focus != self._focus_site: + self._aligned.discard(focus) + self._focus_site = focus + return focus + + def _focus_site_id(self) -> str: + campaign = self.campaign + if not campaign.sites: + return "" + phase = campaign.phase + if phase == "idle": + return campaign.sites[0].site_id + if phase == "liftoff": + return campaign.pending_alignment_site + if phase in {"done", "aborted"}: + return "" + return str(campaign.progress().get("site_id") or "") + + def _current_index(self) -> int: + """Zero-based row the page should highlight; ``-1`` when there is none. + + During ``liftoff`` the campaign has already advanced, so this is the point + the operator is about to align. + """ + sites = self.campaign.sites + if not sites: + return -1 + phase = self.campaign.phase + if phase == "idle": + return 0 + if phase in {"done", "aborted"}: + return -1 + index = int(self.campaign.progress().get("site_index") or 0) - 1 + return index if 0 <= index < len(sites) else -1 diff --git a/apps/autocal/src/orisys_autocal/capture_guard.py b/apps/autocal/src/orisys_autocal/capture_guard.py index 3ecdb90..dd6c5bc 100644 --- a/apps/autocal/src/orisys_autocal/capture_guard.py +++ b/apps/autocal/src/orisys_autocal/capture_guard.py @@ -156,6 +156,7 @@ def capture_verdict( timeout_s: float = CLOSING_TIMEOUT_S, probe_video: bool = True, source_stopped: bool = False, + video_expected: bool = True, ) -> CaptureVerdict: """Decide whether the closed recording is evidence, and say why not. @@ -163,6 +164,13 @@ def capture_verdict( keep polling instead of blocking its event loop. Both recording paths pass their own abort reason: the operator's reason is more useful than a generic token and is what the existing single-trial contract already records. + + ``video_expected=False`` is the operator's recorded choice not to keep a + video. The video-specific judgements (a missing frame, an unexpected segment + count, dropped video frames, an undecodable file) are then skipped, because + there is nothing to find; the data rows must still have been written and + dropped data still keeps the evidence incomplete. The default keeps every + existing caller's meaning: a video was expected. """ if recorder is None: # Nothing was recorded, so nothing is evidence. A simulation and a @@ -208,15 +216,16 @@ def capture_verdict( return verdict(INCOMPLETE, reason or ABORTED) if stats.frames <= 0: return verdict(INCOMPLETE, ZERO_FRAMES) - if stats.video_frames <= 0: - return verdict(INCOMPLETE, VIDEO_MISSING) - if stats.segments != 1: - return verdict(INCOMPLETE, SEGMENT_COUNT) + if video_expected: + if stats.video_frames <= 0: + return verdict(INCOMPLETE, VIDEO_MISSING) + if stats.segments != 1: + return verdict(INCOMPLETE, SEGMENT_COUNT) if stats.dropped_data: return verdict(INCOMPLETE, DROPPED_DATA) - if stats.dropped_video: + if video_expected and stats.dropped_video: return verdict(INCOMPLETE, DROPPED_VIDEO) - if probe_video: + if video_expected and probe_video: ok, detail = probe_segments(report.directory) if not ok: payload = dict(base["report"] or {}) diff --git a/apps/autocal/src/orisys_autocal/multipoint.py b/apps/autocal/src/orisys_autocal/multipoint.py index e7461f4..6f90266 100644 --- a/apps/autocal/src/orisys_autocal/multipoint.py +++ b/apps/autocal/src/orisys_autocal/multipoint.py @@ -18,10 +18,13 @@ from __future__ import annotations import csv import json +import logging import math import re +import shutil import statistics import time +from collections.abc import Mapping from dataclasses import dataclass from datetime import date, datetime, timezone from pathlib import Path @@ -38,6 +41,11 @@ from .capture_guard import ( capture_verdict, ) from .frames_index import read_frame_index, unique_frames +from .recording_options import ( + RecordingOptions, + make_record_config, + require_codec_available, +) from .sensor.frames import PLATFORM_AXES, TOOL_AXIS SENSOR_MODEL = "finger" @@ -408,6 +416,9 @@ class Campaign: self.sensor_model = "" self.sensor_sn = "" self.simulate = False + #: The recording choices this session was established with (plan P2). + #: Written to the root manifest once; every repeat reuses them. + self.recording = RecordingOptions() self.reason = "" self._document: dict | None = None self._site_index = 0 @@ -614,6 +625,8 @@ class Campaign: max_force_n: float, simulate: bool, min_force_n: float = 0.0, + recording: RecordingOptions | None = None, + codec_availability: Mapping[str, bool] | None = None, ) -> Path: if self.is_active or self.capture_state == "closing" or self.phase == "closing": raise RuntimeError("a multi-point acquisition is already running") @@ -633,12 +646,19 @@ class Campaign: raise ValueError("maximum force must be a positive number you enter") for site in self.sites: load_steps(list(site.levels_n), force, min_force_n=lower) + # Refuse an encoder this machine cannot open before anything is written: + # the session block must describe the recording that would actually run. + options = RecordingOptions() if recording is None else recording + if not isinstance(options, RecordingOptions): + raise TypeError("recording must be a RecordingOptions instance") + require_codec_available(options, codec_availability) self.coordinate_frame = coordinate_frame.strip() self.max_force_n = force self.min_force_n = lower self.sensor_model = sensor_model.strip() self.sensor_sn = sensor_sn.strip() self.simulate = bool(simulate) + self.recording = options self.reason = "" self._single_site = False self._finished_step_pending = False @@ -685,6 +705,7 @@ class Campaign: "automatic_force_control": False, "platform_motion": False, "simulate": self.simulate, + "recording": self.recording.as_block(), "sync": SYNC, "sites": [self._site_record(site) for site in self.sites], "trials": [], @@ -786,6 +807,34 @@ class Campaign: self._finished_step_pending = False self._begin_close(complete=True) + def auto_complete_hold(self) -> bool: + """Close the hold once its planned time has run out; ``True`` if it closed. + + The window ends at the planned time (``start + hold``), not at whenever the + page happened to notice: the reference window is the plan, so the recorded + length must not depend on how late a timer tick or a click was. + """ + if self.phase != "hold" or self._active is None or self._hold_started_ns is None: + return False + if self.remaining_s() > 0: + return False + self.complete_hold(now_ns=self._hold_started_ns + int(self._hold_s * 1e9)) + return True + + def end_session(self) -> None: + """Hand a finished or stopped session back to the idle phase. + + The recorded evidence is untouched: the directory, the session document and + the per-point counts stay readable, and only the phase changes so the queue + can be edited and the next session started. A repeat that is still being + written is refused, because it is not part of the evidence yet. + """ + if self.phase not in {"done", "aborted"}: + raise RuntimeError("only a finished or stopped session can be closed out") + if self.capture_state == "closing": + raise RuntimeError("the last repeat is still being written") + self.phase = "idle" + def confirm_liftoff(self) -> None: if self.phase != "liftoff": raise RuntimeError("liftoff confirmation is only required between points") @@ -911,6 +960,81 @@ class Campaign: self._open_repeat() self.phase = "load" + def skip_current_point(self) -> None: + """Leave the open point unrecorded and move on to the next one. + + A repeat directory and its trial record exist from the moment the repeat + opens, so skipping removes that unfinished record instead of inventing a + trial status the format does not define. The point stays in ``sites`` and + in the session document, so a later ``reacquire`` can still record it. + """ + if self.phase not in {"load", "hold"} or self.capture_state == "closing": + raise RuntimeError("no point is open to skip") + if self._recorder is not None: + # The capture writer owns its directory; stop the recording first. + raise RuntimeError("stop the recording before skipping the point") + site = self.sites[self._site_index] + directory, entry = self._trial_dir, self._trial_entry + # The files must be closed before the folder can be moved (Windows refuses + # otherwise), but nothing about the campaign changes until the folder is + # out of the way: a refusal leaves the repeat exactly as it was. + self._close_repeat_files() + completed = 0 + reference = None if directory is None else directory / "reference.csv" + if reference is not None and reference.is_file(): + with reference.open(encoding="utf-8-sig", newline="") as handle: + completed = sum( + 1 for row in csv.DictReader(handle) if row.get("hold_complete") == "1" + ) + aside = None + if directory is not None and directory.exists(): + # One rename is all-or-nothing; a recursive delete that fails half way + # would leave a reference file with its rows gone. + aside = self.directory / f".skipped-{directory.parent.name}-{directory.name}" + while aside.exists(): + aside = aside.with_name(f"{aside.name}-{uuid4().hex[:6]}") + try: + directory.replace(aside) + except OSError as exc: + self._trial_dir, self._trial_entry = directory, entry + self._reopen_repeat_files(directory) + raise RuntimeError( + f"the unfinished repeat folder could not be set aside ({exc}); " + "close anything that has it open and try again" + ) from exc + self._active = None + self._pending = None + self._hold_started_ns = None + self._finished_step_pending = False + self._completed_holds = max(0, self._completed_holds - completed) + if entry is not None: + self._document["trials"].remove(entry) + if aside is not None: + try: + shutil.rmtree(aside) + except OSError as exc: + # The skip itself has happened; a leftover hidden folder at the + # session root cannot collide with a repeat directory. + logging.getLogger(__name__).warning( + "Skipped repeat left at %s: %s", aside, exc + ) + self._event( + "site_skipped", + {"site_id": site.site_id, "repeat_index": self._repeat_index + 1}, + ) + self._site_index += 1 + self._repeat_index = 0 + if self._site_index < len(self.sites): + self.phase = "liftoff" + self._event( + "liftoff_required", + {"next_site_id": self.sites[self._site_index].site_id}, + ) + self._save() + return + self._finish_session(self._session_status()) + self.phase = "done" + def completion(self) -> list[dict]: counts: dict[str, int] = {} if self._document is not None: @@ -1212,6 +1336,7 @@ class Campaign: guard=self._closing_guard, elapsed_s=elapsed, source_stopped=self._source_stopped, + video_expected=self.recording.video, ) if verdict.status == PENDING: self.capture_state = "closing" @@ -1422,6 +1547,13 @@ class Campaign: return "partial" return "incomplete" + def _reopen_repeat_files(self, directory: Path) -> None: + """Reattach the open repeat's writers after a skip that did not happen.""" + self._reopen_reference() + handle = (directory / "events.csv").open("a", newline="", encoding="utf-8") + self._events_file = handle + self._events = csv.writer(handle) + def _reopen_events(self) -> None: if self._session_events is not None: return @@ -1470,11 +1602,9 @@ class Campaign: raise RuntimeError("start the camera and wait for a frame before live capture") if self._recorder is not None: raise RuntimeError("a capture is already running for this repeat") - from orisys_app_shared.record import ExperimentRecorder, RecordConfig + from orisys_app_shared.record import ExperimentRecorder - recorder = ExperimentRecorder( - RecordConfig(fps=self._fps, segment_seconds=24 * 3600.0, preserve_all_frames=True) - ) + recorder = ExperimentRecorder(make_record_config(self.recording, self._fps)) recorder.start( root=self._trial_dir, session_id=int(packet.session_id), diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py index 0ec444e..51d32cf 100644 --- a/apps/autocal/src/orisys_autocal/preferences.py +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -20,6 +20,11 @@ from .motion.service import ( clamp_bas_task, ) from .motion.units import DEFAULT_ACCEL, DEFAULT_SPEED, DEFAULT_UNITS +from .recording_options import ( + DEFAULT_CODEC, + DEFAULT_LOSSLESS_FRAMES, + DEFAULT_VIDEO, +) #: Connection modes, in the order the combo offers them. CONNECTION_MODES = ("eth", "com", "pci", "local", "demo") @@ -176,6 +181,20 @@ def _default_axis_params() -> dict[str, dict[str, float | int]]: } +def _default_recording() -> dict[str, Any]: + """Plan P2: what a capture records. + + The bench default stays ``mp4v`` until the P2b equivalence report is accepted + (decision D2); video is captured by default because it is the evidence the + review and the video page read. + """ + return { + "video": DEFAULT_VIDEO, + "codec": DEFAULT_CODEC, + "lossless_frames": DEFAULT_LOSSLESS_FRAMES, + } + + def default_preferences() -> dict[str, Any]: """AutoCal's default preference document.""" return { @@ -227,6 +246,8 @@ def default_preferences() -> dict[str, Any]: "sensor_setup": _default_sensor_setup(), #: P5: grid generation and run parameters. "sequence_profile": _default_sequence_profile(), + #: P2: video on/off, codec and lossless frames for a new capture session. + "recording": _default_recording(), #: The press axis's mechanical zero and sign, so a solved contact height #: can become a controller target. Uncalibrated means the press axis is #: held rather than driven. @@ -277,6 +298,7 @@ _MERGED_BLOCKS = ( "safety_limits", "sensor_setup", "sequence_profile", + "recording", ) diff --git a/apps/autocal/src/orisys_autocal/recording_options.py b/apps/autocal/src/orisys_autocal/recording_options.py new file mode 100644 index 0000000..cc7b661 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/recording_options.py @@ -0,0 +1,195 @@ +"""What a recording captures, and how those settings reach the shared recorder. + +The options are a small, explicit block rather than loose arguments for two +reasons. They are written into the session manifest once, so a later reader can +tell whether a missing video was a failure or the operator's choice. And they are +checked against this machine's encoders **before** recording starts: a codec that +cannot be opened is refused, never silently replaced by another one. + +Defaults are the confirmed bench decision (plan D2): video on, ``mp4v``, lossless +frames off. ``mp4v`` measured far more bitrate than H.264, but switching the +default is a separate decision that needs the equivalence report, so this module +keeps the current default until then. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +#: Default recording settings. These constants are the single definition; the +#: preference schema re-exports them instead of spelling the values again. +DEFAULT_VIDEO = True +DEFAULT_CODEC = "mp4v" +DEFAULT_LOSSLESS_FRAMES = False + +#: Codecs the startup probe writes a test clip for, in the order the UI offers them. +CODEC_CANDIDATES: tuple[str, ...] = ("mp4v", "avc1", "H264") + +#: Probe clip shape: small and short, but a real encode and a real decode. +PROBE_DIRECTORY = "codec_probe" +PROBE_FRAMES = 3 +PROBE_WIDTH = 64 +PROBE_HEIGHT = 48 +PROBE_FPS = 10.0 + +#: One continuous recording per trial, as the shared recorder already writes it. +TRIAL_SEGMENT_SECONDS = 24 * 3600.0 + + +@dataclass(frozen=True) +class RecordingOptions: + """The recording settings one session was established with.""" + + video: bool = DEFAULT_VIDEO + codec: str = DEFAULT_CODEC + lossless_frames: bool = DEFAULT_LOSSLESS_FRAMES + + def as_block(self) -> dict[str, object]: + """The ``recording`` block frozen for ``session.json``.""" + return { + "video": bool(self.video), + "codec": self.codec, + "lossless_frames": bool(self.lossless_frames), + } + + @classmethod + def from_block(cls, block: object | None) -> "RecordingOptions": + """Read a stored block; a missing or malformed value keeps its default. + + A session recorded before the block existed has no answer to "was the + video expected?". Its recorder always wrote video, so the absent block + means video was expected, which is the default here. + """ + if not isinstance(block, dict): + return cls() + video = block.get("video") + codec = block.get("codec") + lossless = block.get("lossless_frames") + return cls( + video=video if isinstance(video, bool) else DEFAULT_VIDEO, + codec=codec.strip() if isinstance(codec, str) and codec.strip() else DEFAULT_CODEC, + lossless_frames=( + lossless if isinstance(lossless, bool) else DEFAULT_LOSSLESS_FRAMES + ), + ) + + +def make_record_config(options: RecordingOptions, fps: float): + """The shared recorder's config for one trial under these options. + + The import stays inside the function so this module (and the preference + schema that reads its defaults) does not pull in the recorder's NumPy and + OpenCV dependencies at start-up. + """ + from orisys_app_shared.record import RecordConfig + + return RecordConfig( + fps=float(fps), + segment_seconds=TRIAL_SEGMENT_SECONDS, + preserve_all_frames=True, + video=bool(options.video), + codec=str(options.codec), + lossless_frames=bool(options.lossless_frames), + ) + + +def default_options(prefs: Mapping[str, Any] | None) -> RecordingOptions: + """The recording settings stored under ``prefs["recording"]``, defaults filled. + + Every key falls back independently, so a block saved by an older build (one + that did not have the key yet) is not read as an absent recording choice. + """ + block = prefs.get("recording") if isinstance(prefs, Mapping) else None + return RecordingOptions.from_block(block) + + +def probe_codecs(scratch: Path) -> dict[str, bool]: + """Encode a few frames with each candidate codec and report which ones work. + + The probe writes real clips into ``scratch`` and reads one frame back, so a + writer that opens but produces an empty file is not called available. Probe + files are removed afterwards; the caller keeps only the result. Nothing is + substituted when a codec fails: the caller disables it and says so. + """ + import cv2 + import numpy as np + + directory = Path(scratch) / PROBE_DIRECTORY + directory.mkdir(parents=True, exist_ok=True) + frame = np.zeros((PROBE_HEIGHT, PROBE_WIDTH, 3), dtype=np.uint8) + frame[:, :, 1] = 128 + availability: dict[str, bool] = {} + for codec in CODEC_CANDIDATES: + target = directory / f"{codec}.mp4" + available = False + try: + writer = cv2.VideoWriter( + str(target), + cv2.VideoWriter_fourcc(*codec), + PROBE_FPS, + (PROBE_WIDTH, PROBE_HEIGHT), + ) + try: + if writer.isOpened(): + for _ in range(PROBE_FRAMES): + writer.write(frame) + finally: + writer.release() + available = _probe_reads_back(target) + except Exception: # noqa: BLE001 - an encoder failure is a result, not a crash + available = False + finally: + try: + target.unlink() + except OSError: + pass + availability[codec] = available + try: + directory.rmdir() + except OSError: + pass + return availability + + +def _probe_reads_back(path: Path) -> bool: + """A written probe is only available when an encoded frame can be decoded.""" + import cv2 + + if not path.is_file() or path.stat().st_size <= 0: + return False + capture = cv2.VideoCapture(str(path)) + try: + return bool(capture.isOpened() and capture.read()[0]) + finally: + capture.release() + + +def codec_is_available(options: RecordingOptions, availability: Mapping[str, bool] | None) -> bool: + """Whether these options can be recorded given a probe result. + + ``None`` means nothing was probed (a headless caller that is not the UI), and + the answer is left to the recorder rather than guessing. + """ + if not options.video: + return True + if availability is None: + return True + return availability.get(options.codec) is True + + +def require_codec_available( + options: RecordingOptions, availability: Mapping[str, bool] | None +) -> None: + """Refuse to start with an encoder this machine cannot open. + + There is deliberately no fallback to another codec: a recording made under a + silently different codec is not the recording the session block will claim. + """ + if codec_is_available(options, availability): + return + raise ValueError( + f"the selected video codec is not available on this machine: {options.codec}" + ) diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index befa5c2..250c355 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -15,6 +15,7 @@ import numpy as np from .calibration_session import write_json from .frames_index import read_frame_index, unique_frames +from .recording_options import RecordingOptions def _rows(path: Path) -> list[dict]: @@ -133,15 +134,22 @@ def _sha256(path: Path) -> str: return digest.hexdigest() -def _capture_source(directory: Path, root: Path, inputs: dict) -> dict: +def _capture_source( + directory: Path, root: Path, inputs: dict, *, video_expected: bool = True +) -> dict: """Read the frame source recorded beside one repeat, or report why it is unusable. Image-derived force samples only exist where the shared recorder ran: simulation and a detached camera leave no frames, and the gap has to stay visible as a reason rather than become an empty window that reads like a measurement. + + ``video_expected=False`` is a session recorded without video on purpose: the frames + were still acquired (a row carries its host time), only none was written to a video. + Frames are then accepted regardless of video membership and no video file is read. + A session with no recording block keeps the default, which requires the video. """ source = { - "recorded": False, "reason": "no_capture_directory", + "recorded": False, "reason": "no_capture_directory", "video_expected": video_expected, "directory": str(directory.relative_to(root)) if directory.is_relative_to(root) else str(directory), "capture_session_id": None, "frames": 0, "in_video": 0, "time_bases": [], "segments": [], "samples": [], @@ -166,22 +174,26 @@ def _capture_source(directory: Path, root: Path, inputs: dict) -> dict: # left out instead of being paired with an order the files cannot prove. source["duplicate_frame_ids"] = len(duplicated) for frame in frame_rows: - if frame.dropped or frame.t_host_mono_ns is None or frame.in_video is False: + if frame.dropped or frame.t_host_mono_ns is None: continue if frame.frame_id in duplicated: continue - segment = frame.segment - video = directory / f"raw_{segment:04d}.mp4" - if segment < 1 or not video.is_file() or video.stat().st_size == 0: - source["reason"] = "missing_video_segment" - return source + if video_expected: + if frame.in_video is False: + continue + segment = frame.segment + video = directory / f"raw_{segment:04d}.mp4" + if segment < 1 or not video.is_file() or video.stat().st_size == 0: + source["reason"] = "missing_video_segment" + return source + segments.add(segment) + in_video += 1 frames[frame.frame_id] = frame.t_host_mono_ns bases.add(frame.time_basis) - in_video += 1 - if segment: - segments.add(segment) if not frames: - source["reason"] = "no_recorded_video_frames" + source["reason"] = ( + "no_recorded_video_frames" if video_expected else "no_recorded_frames" + ) return source for segment in segments: video = directory / f"raw_{segment:04d}.mp4" @@ -256,6 +268,10 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N if not math.isfinite(maximum) or maximum <= 0: raise ValueError("invalid session force range") simulated = manifest.get("simulate") is True + # The recording block decides whether a missing video is a failure. A session + # recorded before the block existed keeps the default, which expects video. + recording = RecordingOptions.from_block(manifest.get("recording")) + video_expected = recording.video for trial in manifest["trials"]: trial_id = trial["trial_id"] path = (root / trial["directory"]).resolve() @@ -270,7 +286,9 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N if not complete: issues.append({"trial_id": trial_id, "reason": "incomplete_trial", "detail": str(trial.get("reason") or record.get("status") or "")}) - source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs) + source = _capture_source( + path / CAPTURE_DIRECTORY, root, inputs, video_expected=video_expected + ) if source.get("duplicate_frame_ids"): issues.append({ "trial_id": trial_id, "reason": "duplicate_frame_identity", @@ -391,6 +409,7 @@ def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = N "formal_conclusion": False, "sites": sites, "issues": issues, "selections": selections, "input_sha256": inputs, "analysis_inputs": {}, "trials": manifest["trials"], "coverage": coverage, + "recording": recording.as_block(), "uv_definition": manifest.get("uv_definition"), "sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"), "started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"), @@ -525,7 +544,9 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: "source_directory": str(root), "session_status": manifest["status"], "status": "exploratory", "sites": sites, "issues": issues, "selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs, - "trials": manifest["trials"], "uv_definition": manifest["uv_definition"], + "trials": manifest["trials"], + "recording": RecordingOptions.from_block(manifest.get("recording")).as_block(), + "uv_definition": manifest["uv_definition"], "sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"), "started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"), } diff --git a/apps/autocal/src/orisys_autocal/summary.py b/apps/autocal/src/orisys_autocal/summary.py index d8c5105..8ef4f51 100644 --- a/apps/autocal/src/orisys_autocal/summary.py +++ b/apps/autocal/src/orisys_autocal/summary.py @@ -52,6 +52,7 @@ from .frames_index import ( reference_holds, resolve_capture_directory, ) +from .recording_options import RecordingOptions from .session_review import review_session SCHEMA_VERSION = "autocal-summary-1.0" @@ -242,6 +243,14 @@ def build_windows(root: Path, review: dict) -> list[dict]: windows_by_trial.setdefault(window["trial_id"], []).append(window) cache: dict = {} rows: list[dict] = [] + # A multi-point recording has to join a hold to a frame that reached the + # video. A session recorded without video (the root manifest's ``recording`` + # block) has no video membership to require; every recorded frame carries + # its own host time. A session with no block is treated as video-expected. + require_video = ( + bool(review.get("source_format")) + and RecordingOptions.from_block(review.get("recording")).video + ) for trial in review["trials"]: trial_id = trial["trial_id"] trial_status = str(trial.get("status") or "") @@ -261,7 +270,6 @@ def build_windows(root: Path, review: dict) -> list[dict]: if window is not None: matched.add((hold["step_index"], hold["t_start_host_mono_ns"])) directory = _capture_directory(root, trial, window or {}) - require_video = bool(review.get("source_format")) if window is not None and hold["complete"]: start, end, method = _window_interval(window, hold) if directory is None: diff --git a/apps/autocal/src/orisys_autocal/summary_export.py b/apps/autocal/src/orisys_autocal/summary_export.py index 55d866c..14e5fe4 100644 --- a/apps/autocal/src/orisys_autocal/summary_export.py +++ b/apps/autocal/src/orisys_autocal/summary_export.py @@ -6,6 +6,11 @@ it wrote, renames that directory into place, and only then replaces before the rename leaves the previous pointer untouched; the acquisition state is never rolled back because an export failed. +After the pointer moves, the same four files are mirrored into ``summary/`` at +the session root so the operator can open one obvious folder. The mirror is a +convenience copy: the pointer remains the only authority, and a failed mirror is +reported without moving the pointer back or changing the recording. + Nothing is published when the recorded inputs changed since the summary was built: the numbers would no longer be reproducible from the files on disk. """ @@ -16,17 +21,30 @@ import csv import hashlib import html import json +import logging +import shutil import threading from pathlib import Path +from typing import Callable from uuid import uuid4 from .calibration_session import write_json from .session_review import review_session from .summary import SCHEMA_VERSION, build_summary +logger = logging.getLogger(__name__) + POINTER_NAME = "summary-latest.json" POINTER_FORMAT = "autocal-summary-pointer-1.0" EXPORT_FORMAT = "autocal-summary-export-1.0" +#: Where the readable copy of the last published summary lives, under the session root. +SUMMARY_DIR_NAME = "summary" +SUMMARY_FILES = ( + "summary.json", + "summary.html", + "baseline_load_data.csv", + "sensitivity_values.csv", +) BASELINE_HEADER = ( "schema_version", "summary_id", "session_id", "trial_id", "hold_id", "site_id", @@ -260,7 +278,7 @@ def _write_export(directory: Path, summary: dict) -> dict[str, str]: _write_csv(directory / "baseline_load_data.csv", BASELINE_HEADER, summary.get("windows") or []) _write_csv(directory / "sensitivity_values.csv", SENSITIVITY_HEADER, summary.get("sensitivity") or []) hashes = {} - for name in ("summary.json", "summary.html", "baseline_load_data.csv", "sensitivity_values.csv"): + for name in SUMMARY_FILES: path = directory / name if not path.is_file(): raise SummaryExportError(f"the export is missing {name}") @@ -294,6 +312,105 @@ def _read_pointer(root: Path) -> dict: return pointer if isinstance(pointer, dict) else {} +def _recorded_summary_id(summary_json: Path) -> str: + """The ``summary_id`` inside an exported summary, or ``""`` when unreadable.""" + try: + payload = json.loads(summary_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + return "" + return str(payload.get("summary_id") or "") if isinstance(payload, dict) else "" + + +def published_summary_directory(root: Path) -> Path | None: + """The folder an operator should open for the last published summary. + + The readable ``summary/`` mirror, but only while it is the version the pointer + names: a failed mirror leaves the previous copy in place, and opening that + would show numbers the pointer no longer claims. Otherwise the directory the + pointer itself names, which is the authority. ``None`` means nothing has been + published yet; a caller should disable the action rather than invent a path. + """ + root = Path(root).resolve() + pointer = _read_pointer(root) + pointer_id = str(pointer.get("summary_id") or "") if pointer else "" + flat = root / SUMMARY_DIR_NAME + flat_summary = flat / "summary.json" + if flat.is_dir() and flat_summary.is_file(): + if not pointer_id or _recorded_summary_id(flat_summary) == pointer_id: + return flat + if not pointer: + return None + path = root / str(pointer.get("path") or "") + return path if path.is_dir() else None + + +def _write_flat_summary(temporary: Path, source: Path, hashes: dict[str, str]) -> None: + """Copy the published four files into a fresh directory and verify them. + + The copy is written into ``temporary`` first, so a failure here never touches + the existing ``summary/``; it becomes the new readable copy only after every + byte matched the pointer's hashes. + """ + temporary.mkdir(parents=True, exist_ok=False) + for name in SUMMARY_FILES: + destination = temporary / name + shutil.copy2(source / name, destination) + if hashes.get(name) and _sha256_file(destination) != hashes[name]: + raise SummaryExportError(f"the summary copy of {name} does not match the export") + + +def _replace_directory(target: Path, temporary: Path) -> None: + """Swap ``temporary`` into ``target``, restoring the old directory on failure.""" + backup = target.parent / f".tmp-summary-old-{uuid4().hex[:8]}" + moved_old = False + if target.exists(): + target.rename(backup) + moved_old = True + try: + temporary.rename(target) + except OSError: + if moved_old: + backup.rename(target) + raise + if moved_old: + try: + shutil.rmtree(backup) + except OSError: + # The new copy is in place; a leftover backup is untidy, not a failure. + logger.warning("Could not remove the previous summary copy: %s", backup) + + +def _publish_flat_copy( + root: Path, + source: Path, + hashes: dict[str, str], + on_warning: Callable[[str], None] | None, +) -> None: + """Mirror the published summary under ``summary/``; report, never fail the export. + + The pointer has already moved when this runs. The readable copy is a + convenience, so a failure here leaves the previous ``summary/`` and the + pointer exactly as they were and only tells the operator. + """ + temporary = root / f".tmp-summary-flat-{uuid4().hex[:8]}" + try: + _write_flat_summary(temporary, source, hashes) + _replace_directory(root / SUMMARY_DIR_NAME, temporary) + except Exception as exc: # noqa: BLE001 - a copy failure is reported, not fatal + if temporary.exists(): + shutil.rmtree(temporary, ignore_errors=True) + message = ( + f"the readable copy under {SUMMARY_DIR_NAME}/ was not written: {exc}. " + "The pointer still names the published summary; the recording is unchanged." + ) + logger.warning("%s", message) + if on_warning is not None: + try: + on_warning(message) + except Exception: # noqa: BLE001 - a reporter must not break the export + logger.exception("The summary copy warning handler failed") + + def verify_inputs(root: Path, summary: dict) -> None: """Refuse to publish numbers the files on disk can no longer reproduce.""" fresh = review_session(root) @@ -311,8 +428,14 @@ def publish_summary( *, verify: bool = True, cancelled=None, + on_warning: Callable[[str], None] | None = None, ) -> Path: - """Write, verify, rename, then move the pointer. Returns the published directory.""" + """Write, verify, rename, move the pointer, then mirror ``summary/``. + + Returns the published directory under ``derived/``. A failure of the readable + mirror is reported through ``on_warning`` (and the log) after the pointer has + moved; it never turns a published summary into a failed export. + """ root = Path(root).resolve() if not (root / "session.json").is_file(): raise SummaryExportError(f"not a session directory: {root}") @@ -358,6 +481,8 @@ def publish_summary( raise SummaryExportError(f"could not publish the summary: {exc}") from exc pointer = _pointer_for(root, summary, target, hashes) write_json(root / POINTER_NAME, pointer) + # The pointer is the authority from here on; the readable copy is optional. + _publish_flat_copy(root, target, hashes, on_warning) return target diff --git a/apps/autocal/src/orisys_autocal/trial.py b/apps/autocal/src/orisys_autocal/trial.py index 86506a0..091a021 100644 --- a/apps/autocal/src/orisys_autocal/trial.py +++ b/apps/autocal/src/orisys_autocal/trial.py @@ -11,12 +11,18 @@ import hashlib import json import math import time +from collections.abc import Mapping from pathlib import Path -from orisys_app_shared.record import ExperimentRecorder, RecordConfig +from orisys_app_shared.record import ExperimentRecorder from .calibration_session import CalibrationSession, write_json from .capture_guard import CaptureGuard, capture_verdict, PENDING +from .recording_options import ( + RecordingOptions, + make_record_config, + require_codec_available, +) from .sensor.uv_flat import Site @@ -56,6 +62,9 @@ class ManualTrial: self.worker = worker self.session: CalibrationSession | None = None self.entry: dict | None = None + #: The recording settings this session runs with (plan P2). The session + #: manifest locks them at establishment; a later trial reuses them. + self.recording = RecordingOptions() self.hold_s = 20.0 self.completed_steps: list[dict] = [] self.reason = "" @@ -91,6 +100,8 @@ class ManualTrial: config_ref: str, fps: float, uv_outline: dict | None = None, + recording: RecordingOptions | None = None, + codec_availability: Mapping[str, bool] | None = None, ) -> Path: if self.status in {"recording", "closing"}: raise RuntimeError("a trial is already active") @@ -106,6 +117,24 @@ class ManualTrial: if not 10.0 <= float(hold_s) <= 30.0: raise ValueError("hold time must be between 10 and 30 seconds") self.hold_s = float(hold_s) + # The session is the owner of the video choice: it is written into the + # root manifest once and every later trial reuses it. The codec is + # checked before anything is written, so an encoder this machine cannot + # open is refused instead of silently replaced. + options = RecordingOptions() if recording is None else recording + if not isinstance(options, RecordingOptions): + raise TypeError("recording must be a RecordingOptions instance") + if self.session is None: + require_codec_available(options, codec_availability) + else: + locked = RecordingOptions.from_block(self.session.document.get("recording")) + if recording is not None and options != locked: + raise ValueError( + "recording settings are locked once the session has started; " + "finish this session before changing them" + ) + options = locked + self.recording = options packet = self.worker.latest() if packet.capture_mono_ns is None: raise RuntimeError("the input has no frame-bound host timestamp") @@ -149,13 +178,14 @@ class ManualTrial: "reference_source": "manual_operator_confirmation", "reference_file": "reference.csv", "uv_outline": uv_outline, + # The root manifest keeps this block once; the trial's own metadata + # repeats it so a single trial folder states what it recorded. + "recording": self.recording.as_block(), } if uv_outline is not None and any(uv_outline[key] != metadata["uv_definition"][key] for key in ("mapping_sha256", "origin_raster_xy_px", "raster_size_hw")): raise ValueError("UV outline does not match the selected site mapping") - recorder = ExperimentRecorder( - RecordConfig(fps=float(fps), segment_seconds=24 * 3600.0, preserve_all_frames=True) - ) + recorder = ExperimentRecorder(make_record_config(self.recording, fps)) if self.session is None: self.session = CalibrationSession(Path(root).resolve(), metadata) elif Path(root).resolve() != self.session.directory.parent: @@ -390,6 +420,7 @@ class ManualTrial: reason=self.reason, guard=self.guard, elapsed_s=elapsed, + video_expected=self.recording.video, ) if verdict.status == PENDING: return self.status diff --git a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py index 15d9efb..90f8fe4 100644 --- a/apps/autocal/src/orisys_autocal/ui/calibration_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/calibration_panel.py @@ -1,384 +1,1539 @@ -"""Manual finger session: precise UV sites, live view and trial recording.""" +"""The capture page: one point queue, one controller, one current point. + +The page is a renderer. :class:`~orisys_autocal.capture_controller.CaptureController` +owns the workflow decision — which single primary button the operator sees, whether +it is enabled, and why it is not — and this widget draws that answer plus the queue +snapshot the controller hands it. The page never inspects the campaign's phase +field: the step label, the hint, the progress line and the button text all come +from ``primary_action()``, ``secondary_actions()``, ``poll()``, ``model()`` and +``readiness()``. + +Two things the page still does itself, because the frozen controller interface +deliberately has no action for them: + +* **Editing the queue.** Adding, updating, removing and reordering points are + commands on ``Campaign``; the controller only exposes their result through + ``model()``. The queue is editable only while no session is running. +* **Asking the solver again before a move.** A stored pose records what the solver + said when the point was taken. Mounting offsets, tool position and travel limits + can change afterwards, so the page re-solves the point against the geometry in + force now and refuses a point the current map no longer puts where the record + says. A changed pose is stored before the move request is published. + +Alignment itself is only ever a request: the page calls ``perform("align_point")``, +takes ``pending_alignment`` and emits it; the window plans the move. Only after the +window reports the move finished does the page call ``mark_aligned()`` — a refused, +cancelled or failed move leaves the point unaligned, so "start capture" never +becomes clickable on a pose nobody confirmed. +""" from __future__ import annotations import logging +import math +import os from pathlib import Path +import numpy as np from orisys_app_shared.i18n import t -from orisys_app_shared.paths import outputs_dir from PySide6.QtCore import QTimer, Signal from PySide6.QtWidgets import ( + QApplication, QComboBox, QDoubleSpinBox, QFileDialog, - QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QPushButton, - QSpinBox, QScrollArea, QSizePolicy, - QTabWidget, + QSpinBox, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) +from ..capture_controller import ( + READINESS_ITEMS, + ActionBlocked, + CaptureController, + ReadinessCheck, +) +from ..multipoint import Campaign, load_steps, next_point_id +from ..sensor.frames import machine_table_xy from ..sensor.uv_flat import site_at, uv_origin -from ..sensor.uv_outline import outline_snapshot from ..summary import build_summary -from ..summary_export import publish_summary -from ..trial import ManualTrial, load_path +from ..summary_export import published_summary_directory, publish_summary +from .capture_settings_dialog import STEP_OVERHEAD_S, CaptureSettingsDialog from .live_force_view import LiveForceView -from .multipoint_panel import MultipointPanel +from .live_uv_view import LiveUvView +from .reveal import reveal from .review_job import ReviewJob from .sensor_panel import FlatSurfaceView +from .site_export import write_operation_table -#: Product models a session can be recorded under. The stored value is this id. -PRODUCT_MODELS: tuple[str, ...] = ("finger",) +logger = logging.getLogger(__name__) + +#: A stored point and the mapping's answer for the same UV are the same point when +#: they agree this closely. Further apart means the map, the bundle or the mounting +#: moved under the record, and the point has to be taken again rather than retargeted. +_POINT_MATCH_MM = 0.05 +_NORMAL_MATCH = 1e-3 + +#: Session phases in which the queue, the input source and the recording options are +#: frozen. These are the controller's own ``poll()`` values, not the campaign's. +_LOCKED_PHASES = frozenset({"load", "hold", "liftoff"}) + +#: Source frame rate used when a live capture is attached but the input panel has not +#: published a mode yet. It keeps the recorder's stats finite; the actual stream rate +#: comes from the input mode whenever one exists. +_FALLBACK_FPS = 10.0 + + +def _labels() -> dict[str, str]: + """Step hint and label keys, mapping the controller's action keys to locale keys. + + Kept as one table: the page never invents a step name, it translates the action + the controller says is current. + """ + return { + "align_point": "cap.step.align_point", + "start_session": "cap.step.start_session", + "confirm_target": "cap.step.confirm_target", + "confirm_hold": "cap.step.confirm_hold", + "confirm_liftoff": "cap.step.confirm_liftoff", + "finish_session": "cap.step.finish_session", + "closing": "cap.step.closing", + } class CalibrationPanel(QWidget): - """One operator page. No motion commands are issued by this widget.""" + """The capture workflow. No motion commands are issued by this widget.""" loadBundleRequested = Signal() reviewRequested = Signal(str) + #: The five-axis targets of the point the operator asked to align. The window + #: plans the move; this page never commands an axis. + alignRequested = Signal(object) - def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None: + def __init__( + self, + source_panel: QWidget, + parent: QWidget | None = None, + *, + prefs: dict | None = None, + solver=None, + bus_problem=None, + ) -> None: super().__init__(parent) - self._summary_job: ReviewJob | None = None - self._summary_directory: Path | None = None - self._summary_ready_path: Path | None = None self.source_panel = source_panel - self.trial = ManualTrial(source_panel.worker) + self.prefs = prefs if isinstance(prefs, dict) else {} + self.solver = solver + self._bus_problem = bus_problem or (lambda: "") + # The queue itself is handed to the controller; the page only ever touches + # it through the controller (plus the two recorder-lifecycle methods the + # frozen contract allows: ``attach_capture`` and ``flush_close``). + self.campaign = Campaign() + self._controller: CaptureController | None = None + self._controller_max_force: float | None = None self._flat = None self._origin: tuple[int, int] | None = None self._bundle_path = "" self._site = None + self._rows: list = [] + self._listed_rows: tuple | None = None + self._last_plan_seconds = -1.0 + self._selected_site = "" + self._aligning_site = "" + #: Display-only reachability from a re-solve while the queue is frozen + #: (between trials). Empty in the idle phase, where the write-back lands in + #: the controller's own ``SiteRow.reachable``. + self._live_reachable: dict[str, bool] = {} + self._summary_job: ReviewJob | None = None + self._summary_directory: Path | None = None + self._summary_ready_path: Path | None = None + self._summary_published_for: str | None = None + #: A publish runs on a worker thread, so its warnings are collected here + #: and reported by ``_summary_finished`` on the GUI thread. + self._summary_warnings: list[str] = [] - outer = QVBoxLayout(self) - header = QHBoxLayout() - self.model_label = QLabel(self) - self.sensor_model = QComboBox(self) - self.sensor_model.setObjectName("calibrationSensorModel") - self.sensor_model.addItems(PRODUCT_MODELS) - self.sensor_model.setCurrentText(PRODUCT_MODELS[0]) - self.sn_label = QLabel(self) - self.sensor_sn = QLineEdit(self) - self.sensor_sn.setObjectName("calibrationSensorSn") - self.output_label = QLabel(self) - self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self) - self.output_path.setObjectName("calibrationOutputPath") - self.output_browse = QPushButton(self) - header.addWidget(self.model_label) - header.addWidget(self.sensor_model) - header.addWidget(self.sn_label) - header.addWidget(self.sensor_sn) - header.addWidget(self.output_label) - header.addWidget(self.output_path, 1) - header.addWidget(self.output_browse) - outer.addLayout(header) - self.source_state = QLabel(self) - outer.addWidget(self.source_state) - session_row = QHBoxLayout() - self.session_label = QLabel(self) - self.session_label.setWordWrap(True) - self.close_session_button = QPushButton(self) - self.close_session_button.setObjectName("calibrationCloseSession") - self.close_session_button.clicked.connect(self._close_session) - self.review_button = QPushButton(self) - self.review_button.setObjectName("calibrationReview") - self.review_button.clicked.connect(self._review) - session_row.addWidget(self.session_label, 1) - session_row.addWidget(self.review_button) - session_row.addWidget(self.close_session_button) - outer.addLayout(session_row) - - body = QHBoxLayout() - self.views = QTabWidget(self) - self.views.setObjectName("calibrationViews") - self.site_view = QWidget(self.views) - site_layout = QVBoxLayout(self.site_view) - self.flat_view = FlatSurfaceView(self.site_view) - self.flat_view.pixelPicked.connect(self._picked) - site_layout.addWidget(self.flat_view, 1) - self.bundle_button = QPushButton(self.site_view) - self.bundle_button.clicked.connect(self.loadBundleRequested) - site_layout.addWidget(self.bundle_button) - self.views.addTab(self.site_view, "") - self.views.addTab(self.source_panel, "") - self.multipoint = MultipointPanel(self, self.views) - self.views.addTab(self.multipoint, "") - body.addWidget(self.views, 3) - - controls = QGroupBox(self) - controls.setObjectName("groupBox") - form = QFormLayout(controls) - self.u_spin = QSpinBox(controls) - self.u_spin.setObjectName("calibrationU") - self.v_spin = QSpinBox(controls) - self.v_spin.setObjectName("calibrationV") - coords = QWidget(controls) - coord_row = QHBoxLayout(coords) - coord_row.setContentsMargins(0, 0, 0, 0) - coord_row.addWidget(self.u_spin) - coord_row.addWidget(self.v_spin) - self.coords_label = QLabel(controls) - form.addRow(self.coords_label, coords) - self.position_label = QLabel(controls) - self.position_value = QLabel("—", controls) - form.addRow(self.position_label, self.position_value) - self.levels_label = QLabel(controls) - self.levels = QLineEdit("5, 10, 20, 30", controls) - self.levels.setObjectName("calibrationLevels") - form.addRow(self.levels_label, self.levels) - self.hold_label = QLabel(controls) - self.hold = QDoubleSpinBox(controls) - self.hold.setRange(10, 30) - self.hold.setValue(20) - self.hold.setSuffix(" s") - form.addRow(self.hold_label, self.hold) - self.path_label = QLabel(controls) - self.path_label.setWordWrap(True) - form.addRow(self.path_label) - self.target_label = QLabel(controls) - form.addRow(self.target_label) - self.reference_label = QLabel(controls) - self.reference = QLineEdit(controls) - self.reference.setObjectName("calibrationReference") - form.addRow(self.reference_label, self.reference) - actions = QWidget(controls) - action_grid = QGridLayout(actions) - action_grid.setContentsMargins(0, 0, 0, 0) - self.start_button = QPushButton(actions) - self.confirm_button = QPushButton(actions) - self.finish_button = QPushButton(actions) - self.abort_button = QPushButton(actions) - action_grid.addWidget(self.start_button, 0, 0) - action_grid.addWidget(self.confirm_button, 0, 1) - action_grid.addWidget(self.finish_button, 1, 0) - action_grid.addWidget(self.abort_button, 1, 1) - form.addRow(actions) - self.progress_label = QLabel(controls) - self.progress_label.setWordWrap(True) - form.addRow(self.progress_label) - self.result_label = QLabel(controls) - self.result_label.setWordWrap(True) - form.addRow(self.result_label) - for label in (self.path_label, self.target_label, self.result_label): - label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - for widget in (self.target_label, self.reference_label, self.reference, - actions, self.progress_label, self.result_label): - form.removeWidget(widget) - right = QWidget(self) - right.setMinimumWidth(560) - right_layout = QVBoxLayout(right) - right_layout.setContentsMargins(0, 0, 0, 0) - control_scroll = QScrollArea(right) - control_scroll.setWidgetResizable(True) - control_scroll.setMinimumHeight(150) - control_scroll.setWidget(controls) - right_layout.addWidget(control_scroll, 3) - right_layout.addWidget(self.target_label) - reference_row = QHBoxLayout() - reference_row.addWidget(self.reference_label) - reference_row.addWidget(self.reference, 1) - right_layout.addLayout(reference_row) - right_layout.addWidget(actions) - right_layout.addWidget(self.progress_label) - right_layout.addWidget(self.result_label) - self.force_view = LiveForceView(source_panel.worker, right) - self.force_view.setMaximumHeight(280) - right_layout.addWidget(self.force_view, 2) - body.addWidget(right, 2) - outer.addLayout(body, 1) - - self.output_browse.clicked.connect(self._choose_output) - self.u_spin.valueChanged.connect(self._site_changed) - self.v_spin.valueChanged.connect(self._site_changed) - self.levels.textChanged.connect(self._sync) - self.start_button.clicked.connect(self._start) - self.confirm_button.clicked.connect(self._confirm) - self.finish_button.clicked.connect(self._finish) - self.abort_button.clicked.connect(self._abort) + self.settings = CaptureSettingsDialog(source_panel, self.prefs, self) + self.settings.bundleRequested.connect(self.loadBundleRequested) + self._build_ui() self._timer = QTimer(self) self._timer.setInterval(250) self._timer.timeout.connect(self._poll) self._timer.start() self.retranslate() - self._sync() - def set_flat(self, flat, bundle_path: str) -> None: - if self.trial.session is not None: - raise RuntimeError(t("calibration.close_before_mapping")) - self._flat = flat - self._uv_outline = outline_snapshot(flat) - self._bundle_path = bundle_path - self.flat_view.set_flat(flat) - origin_x, origin_y = uv_origin(flat) - self._origin = (origin_x, origin_y) - self.u_spin.setRange(-origin_x, flat.width - 1 - origin_x) - self.v_spin.setRange(origin_y - flat.height + 1, origin_y) - self._site_changed() + # --------------------------------------------------------------------- UI - def _picked(self, u: int, v: int) -> None: - origin_x, origin_y = self._origin - self.u_spin.setValue(u - origin_x) - self.v_spin.setValue(origin_y - v) - self._site_changed() + def _build_ui(self) -> None: + outer = QVBoxLayout(self) + outer.setContentsMargins(8, 6, 8, 6) + outer.setSpacing(6) - def _site_changed(self) -> None: + # ---- session row + self.session_row = QHBoxLayout() + self.session_label = QLabel(self) + self.session_label.setWordWrap(True) + self.session_label.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred + ) + self.open_session_button = QPushButton(self) + self.open_session_button.setObjectName("calibrationOpenSession") + self.open_session_button.clicked.connect(self._open_session_folder) + self.open_summary_button = QPushButton(self) + self.open_summary_button.setObjectName("calibrationOpenSummary") + self.open_summary_button.clicked.connect(self._open_summary_folder) + self.copy_path_button = QPushButton(self) + self.copy_path_button.setObjectName("calibrationCopyPath") + self.copy_path_button.clicked.connect(self._copy_session_path) + self.review_button = QPushButton(self) + self.review_button.setObjectName("calibrationReview") + self.review_button.clicked.connect(self._review) + self.session_row.addWidget(self.session_label, 1) + for button in ( + self.open_session_button, + self.open_summary_button, + self.copy_path_button, + self.review_button, + ): + self.session_row.addWidget(button) + outer.addLayout(self.session_row) + + # ---- ready row: every check, including the ones that failed + self.ready_row = QHBoxLayout() + self.ready_row.setSpacing(12) + self.ready_labels: dict[str, QLabel] = {} + for key, _label_key in READINESS_ITEMS: + label = QLabel(self) + label.setObjectName("readyCheck") + self.ready_labels[key] = label + self.ready_row.addWidget(label) + self.ready_row.addStretch(1) + outer.addLayout(self.ready_row) + + # ---- settings row: the one dialog, plus the recording estimate + settings_row = QHBoxLayout() + settings_row.setSpacing(8) + self.settings_button = QPushButton(self) + self.settings_button.setObjectName("captureSettings") + self.settings_button.clicked.connect(self._open_settings) + self.settings_summary = QLabel(self) + self.settings_summary.setObjectName("hintText") + self.settings_summary.setWordWrap(True) + settings_row.addWidget(self.settings_button) + settings_row.addWidget(self.settings_summary, 1) + outer.addLayout(settings_row) + + # ---- body: queue + views on the left, the current point on the right + body = QHBoxLayout() + body.setSpacing(8) + left = QVBoxLayout() + left.setSpacing(6) + views = QHBoxLayout() + views.setSpacing(6) + self.flat_view = FlatSurfaceView(self) + self.flat_view.pixelPicked.connect(self._picked) + self.flat_view.setMinimumHeight(160) + self.live_view = LiveUvView(self.source_panel, self) + views.addWidget(self.flat_view, 1) + views.addWidget(self.live_view, 1) + left.addLayout(views, 1) + queue_box = self._build_queue() + # The list keeps its own height; spare height belongs to the two images, + # which are what the operator reads while placing a point. + queue_box.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + left.addWidget(queue_box, 0) + body.addLayout(left, 3) + body.addWidget(self._build_point_card(), 1) + outer.addLayout(body, 1) + + # ---- one message line at the bottom + self.message = QLabel(self) + self.message.setObjectName("captureMessage") + self.message.setWordWrap(True) + self.message.setMinimumHeight(18) + outer.addWidget(self.message) + + def _build_queue(self) -> QWidget: + box = QGroupBox(self) + box.setObjectName("groupBox") + layout = QVBoxLayout(box) + layout.setSpacing(6) + + form = QGridLayout() + form.setHorizontalSpacing(8) + form.setVerticalSpacing(6) + self.uv_label = QLabel(box) + self.u_spin = QSpinBox(box) + self.u_spin.setObjectName("calibrationU") + self.u_spin.setRange(-1000, 1000) # until a surface sets its own limits + self.u_spin.setMinimumWidth(56) + self.u_spin.setMaximumWidth(90) + self.v_spin = QSpinBox(box) + self.v_spin.setObjectName("calibrationV") + self.v_spin.setRange(-1000, 1000) + self.v_spin.setMinimumWidth(56) + self.v_spin.setMaximumWidth(90) + self.position_label = QLabel(box) + self.position_value = QLabel("—", box) + self.position_value.setObjectName("hintText") + self.uv_row = QWidget(box) + uv_layout = QHBoxLayout(self.uv_row) + uv_layout.setContentsMargins(0, 0, 0, 0) + uv_layout.setSpacing(6) + uv_layout.addWidget(self.u_spin) + uv_layout.addWidget(self.v_spin) + form.addWidget(self.uv_label, 0, 0) + form.addWidget(self.uv_row, 0, 1) + form.addWidget(self.position_label, 0, 2) + form.addWidget(self.position_value, 0, 3, 1, 3) + + self.site_label = QLabel(box) + self.site_id = QLineEdit(box) + self.site_id.setObjectName("captureSiteId") + self.repeats_label = QLabel(box) + self.repeats = QSpinBox(box) + self.repeats.setRange(1, 99) + self.repeats.setValue(1) + self.repeats.setMinimumWidth(52) + self.repeats.setMaximumWidth(80) + self.add_point_button = QPushButton(box) + self.add_point_button.setObjectName("captureAddPoint") + self.add_point_button.setMinimumWidth(96) + self.add_point_button.setMaximumWidth(150) + self.add_point_button.clicked.connect(self._add_point) + form.addWidget(self.site_label, 1, 0) + form.addWidget(self.site_id, 1, 1) + form.addWidget(self.repeats_label, 1, 2) + form.addWidget(self.repeats, 1, 3) + form.addWidget(self.add_point_button, 1, 4, 1, 2) + + self.levels_label = QLabel(box) + self.levels = QLineEdit("5, 10, 20", box) + self.levels.setObjectName("calibrationLevels") + self.hold_label = QLabel(box) + self.hold = QDoubleSpinBox(box) + self.hold.setRange(10, 30) + self.hold.setValue(20) + self.hold.setSuffix(" s") + self.hold.setMinimumWidth(74) + self.hold.setMaximumWidth(110) + self.levels.textChanged.connect(self._refresh) + self.hold.valueChanged.connect(self._refresh) + queue_buttons = QHBoxLayout() + queue_buttons.setSpacing(4) + self.update_point_button = QPushButton(box) + self.remove_point_button = QPushButton(box) + self.up_button = QPushButton(box) + self.down_button = QPushButton(box) + for button in ( + self.update_point_button, + self.remove_point_button, + self.up_button, + self.down_button, + ): + button.setProperty("compact", True) + button.setMinimumWidth(44) + queue_buttons.addWidget(button) + self.update_point_button.clicked.connect(self._update_point) + self.remove_point_button.clicked.connect(self._remove_point) + self.up_button.clicked.connect(lambda: self._move_point(-1)) + self.down_button.clicked.connect(lambda: self._move_point(1)) + form.addWidget(self.levels_label, 2, 0) + form.addWidget(self.levels, 2, 1) + form.addWidget(self.hold_label, 2, 2) + form.addWidget(self.hold, 2, 3) + form.addLayout(queue_buttons, 2, 4, 1, 2) + form.setColumnStretch(5, 1) + layout.addLayout(form) + + self.queue_table = QTableWidget(0, 5, box) + self.queue_table.setObjectName("captureQueue") + self.queue_table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + self.queue_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) + self.queue_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + # Fixed height: the list scrolls, and the space the page gains on a bigger + # screen belongs to the two images, not to an emptier table. + self.queue_table.setFixedHeight(128) + self.queue_table.verticalHeader().setVisible(False) + self.queue_table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch + ) + self.queue_table.horizontalHeader().setStretchLastSection(True) + self.queue_table.itemSelectionChanged.connect(self._on_queue_selected) + layout.addWidget(self.queue_table, 1) + self.queue_hint = QLabel(box) + self.queue_hint.setWordWrap(True) + self.queue_hint.setObjectName("hintText") + footer = QHBoxLayout() + footer.setSpacing(6) + footer.addWidget(self.queue_hint, 1) + # The operation sheet belongs to the queue it exports: this is where the + # points are now, so this is where the sheet is written. + self.export_sheet_button = QPushButton(box) + self.export_sheet_button.setObjectName("captureExportSheet") + self.export_sheet_button.clicked.connect(self._export_sheet) + footer.addWidget(self.export_sheet_button) + layout.addLayout(footer) + return box + + def _build_point_card(self) -> QWidget: + right = QWidget(self) + right.setMinimumWidth(340) + right.setMaximumWidth(420) + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(6) + scroll = QScrollArea(right) + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + scroll.setMinimumHeight(150) + card = QWidget(scroll) + card_layout = QVBoxLayout(card) + card_layout.setSpacing(6) + scroll.setWidget(card) + + self.card_title = QLabel(card) + self.card_title.setObjectName("panelTitle") + self.step_label = QLabel(card) + self.step_label.setObjectName("panelTitle") + self.step_label.setWordWrap(True) + self.progress_label = QLabel(card) + self.progress_label.setObjectName("panelMeta") + self.progress_label.setWordWrap(True) + self.hint_label = QLabel(card) + self.hint_label.setObjectName("hintText") + self.hint_label.setWordWrap(True) + self.capture_label = QLabel(card) + self.capture_label.setObjectName("hintText") + self.capture_label.setWordWrap(True) + for widget in ( + self.card_title, self.step_label, self.progress_label, self.hint_label, + self.capture_label, + ): + card_layout.addWidget(widget) + + self.source_row = QWidget(card) + source_layout = QHBoxLayout(self.source_row) + source_layout.setContentsMargins(0, 0, 0, 0) + self.source_label = QLabel(self.source_row) + self.source_mode = QComboBox(self.source_row) + self.source_mode.setObjectName("captureReferenceSource") + self.source_mode.addItem("", "weight") + self.source_mode.addItem("", "wrench") + self.source_mode.currentIndexChanged.connect(self._on_source_mode_changed) + source_layout.addWidget(self.source_label) + source_layout.addWidget(self.source_mode, 1) + card_layout.addWidget(self.source_row) + self.force_inputs = QWidget(card) + force_layout = QVBoxLayout(self.force_inputs) + force_layout.setContentsMargins(0, 0, 0, 0) + self.weight_row = QWidget(self.force_inputs) + weight_layout = QHBoxLayout(self.weight_row) + weight_layout.setContentsMargins(0, 0, 0, 0) + self.reference_label = QLabel(self.weight_row) + self.reference = QLineEdit(self.weight_row) + self.reference.setObjectName("calibrationReference") + weight_layout.addWidget(self.reference_label) + weight_layout.addWidget(self.reference, 1) + self.wrench_box = QWidget(self.force_inputs) + wrench_layout = QGridLayout(self.wrench_box) + wrench_layout.setContentsMargins(0, 0, 0, 0) + wrench_layout.setHorizontalSpacing(6) + self.fx_label = QLabel(self.wrench_box) + self.fy_label = QLabel(self.wrench_box) + self.fz_label = QLabel(self.wrench_box) + self.fx = QLineEdit(self.wrench_box) + self.fy = QLineEdit(self.wrench_box) + self.fz = QLineEdit(self.wrench_box) + self.rotation_label = QLabel(self.wrench_box) + self.rotation = QLineEdit(self.wrench_box) + wrench_layout.addWidget(self.fx_label, 0, 0) + wrench_layout.addWidget(self.fx, 0, 1) + wrench_layout.addWidget(self.fy_label, 0, 2) + wrench_layout.addWidget(self.fy, 0, 3) + wrench_layout.addWidget(self.fz_label, 0, 4) + wrench_layout.addWidget(self.fz, 0, 5) + wrench_layout.addWidget(self.rotation_label, 1, 0) + wrench_layout.addWidget(self.rotation, 1, 1, 1, 5) + force_layout.addWidget(self.weight_row) + force_layout.addWidget(self.wrench_box) + self.wrench_box.setVisible(False) + card_layout.addWidget(self.force_inputs) + card_layout.addStretch(1) + right_layout.addWidget(scroll, 1) + + # The primary button and its three alternatives stay outside the scroll: + # the next step must always be visible without scrolling the card. + primary_block = QWidget(right) + primary_layout = QVBoxLayout(primary_block) + primary_layout.setContentsMargins(0, 0, 0, 0) + primary_layout.setSpacing(4) + self.primary_button = QPushButton(primary_block) + self.primary_button.setObjectName("primaryButton") + self.primary_button.setMinimumHeight(42) + self.primary_button.clicked.connect(self._primary) + primary_layout.addWidget(self.primary_button) + self.primary_reason = QLabel(primary_block) + self.primary_reason.setObjectName("hintText") + self.primary_reason.setWordWrap(True) + primary_layout.addWidget(self.primary_reason) + secondary_row = QHBoxLayout() + secondary_row.setSpacing(4) + self.remeasure_button = QPushButton(primary_block) + self.remeasure_button.setObjectName("captureRemeasure") + self.remeasure_button.clicked.connect(lambda: self._secondary("remeasure")) + self.skip_button = QPushButton(primary_block) + self.skip_button.setObjectName("captureSkip") + self.skip_button.clicked.connect(lambda: self._secondary("skip")) + self.stop_button = QPushButton(primary_block) + self.stop_button.setObjectName("dangerOutline") + self.stop_button.clicked.connect(lambda: self._secondary("stop")) + for button in (self.remeasure_button, self.skip_button, self.stop_button): + button.setProperty("compact", True) + secondary_row.addWidget(button) + primary_layout.addLayout(secondary_row) + right_layout.addWidget(primary_block, 0) + + self.force_view = LiveForceView(self.source_panel.worker, right) + self.force_view.setMinimumHeight(150) + self.force_view.setMaximumHeight(240) + right_layout.addWidget(self.force_view, 1) + return right + + # ---------------------------------------------------------------- controller + + @property + def controller(self) -> CaptureController: + if self._controller is None: + self._build_controller() + assert self._controller is not None + return self._controller + + def _max_force_value(self) -> float | None: + text = self.settings.max_force.text().strip() + if not text: + return None try: - self._site = site_at( - self._flat, - self.u_spin.value(), - self.v_spin.value(), - origin=self._origin, - ) + value = float(text) except ValueError: - self._site = None - origin = self._origin - self.flat_view.set_marker( - (origin[0] + self.u_spin.value(), origin[1] - self.v_spin.value()) - if origin is not None - else None - ) - self.flat_view.set_selected( - self._site.raster_xy_px if self._site is not None else None - ) - self.position_value.setText( - "—" - if self._site is None - else ", ".join(f"{v:.3f}" for v in self._site.position_sensor_mm) + " mm" - ) - self._sync() + return None + return value if math.isfinite(value) and value > 0 else None - def _choose_output(self) -> None: - path = QFileDialog.getExistingDirectory( - self, t("calibration.output"), self.output_path.text() + def _build_controller(self) -> CaptureController: + value = self._max_force_value() + self._controller_max_force = value + self._controller = CaptureController( + self.campaign, + solver=self.solver, + readiness=self._readiness_checks, + max_force_n=value, + start_arguments=self._start_arguments, + alignment_required=lambda: not self._simulating(), ) - if path: - self.output_path.setText(path) + return self._controller - def _start(self) -> None: - try: - if self.multipoint.is_active: - raise RuntimeError(t("multipoint.finger_busy")) - if self._site is None: - raise ValueError(t("calibration.invalid_site")) - if not self.output_path.text().strip(): - raise ValueError(t("calibration.output_required")) - levels = [float(value.strip()) for value in self.levels.text().split(",")] - source = { - "kind": self.source_panel.source_kind.currentData(), - "value": ( - self.source_panel.video_path.text().strip() - if self.source_panel.source_kind.currentData() == "video" - else self.source_panel.camera_index.value() + def _ensure_controller(self) -> CaptureController: + """Hand the controller a changed maximum force instead of rebuilding it. + + ``set_max_force`` is the controller's own fact for the sixth readiness check + and for the queue edits, so the page pushes the value it renders. Rebuilding + the controller would also drop the "this point is aligned" flag — an edit to + an unrelated setting must not silently do that. + """ + if self._controller is None: + return self._build_controller() + value = self._max_force_value() + if value != self._controller_max_force: + self._controller_max_force = value + self._controller.set_max_force(value) + return self._controller + + def _readiness_checks(self) -> list[ReadinessCheck]: + """The five externally probed checks; the controller owns the sixth.""" + fields = self.settings.session_fields() + simulate = bool(fields["simulate"]) + checks: list[ReadinessCheck] = [] + + # A simulation needs no calibration bundle either: the points are typed in, + # and nothing is solved against a surface (see ``_simulated_point``). + bundle_ok = simulate or self._flat is not None + checks.append( + ReadinessCheck( + "calibration_bundle", + bundle_ok, + "cap.check.bundle", + "" + if self._flat is not None + else ( + "simulation does not need a calibration bundle" + if simulate + else t("cap.block.no_bundle") ), - } - mode = self.source_panel.mode - if mode is None: - raise RuntimeError(t("calibration.start_input")) - directory = self.trial.start( - root=Path(self.output_path.text().strip()), - site=self._site, - levels=levels, - hold_s=self.hold.value(), - sensor_model=self.sensor_model.currentText(), - sensor_sn=self.sensor_sn.text(), - bundle_path=self._bundle_path, - source=source, - config_ref=self.source_panel.config_ref.text().strip(), - fps=mode.fps, - uv_outline=self._uv_outline, ) - self.views.setCurrentWidget(self.source_panel) - self.result_label.setText( - str(directory) - + ( - "\n" + t("calibration.replay_note") - if not self.trial.reference_alignment_possible - else "" - ) - ) - except (OSError, RuntimeError, ValueError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) - self._sync() + ) - def _confirm(self) -> None: - if self.multipoint.campaign.phase == "load": - if not self.multipoint.campaign.capture_ready: - self.result_label.setText(t("multipoint.capture_wait")) - self._sync() + worker = getattr(self.source_panel, "worker", None) + packet = None if worker is None else worker.latest() + frames_ok = simulate or bool( + worker is not None and getattr(worker, "is_running", False) and packet is not None + ) + checks.append( + ReadinessCheck( + "input_frames", + frames_ok, + "cap.check.frames", + "" if frames_ok else t("source.no_frame"), + ) + ) + + # Simulating records files without moving the platform or opening a camera, + # so neither the bus nor the input is a fact this run needs. + bus_problem = "" if simulate else str(self._bus_problem() or "") + checks.append( + ReadinessCheck( + "bus_ready", + not bus_problem, + "cap.check.bus", + bus_problem or ("simulation does not need the bus" if simulate else ""), + ) + ) + + output = str(fields["root"] or "").strip() + writable, detail = self._output_writable(output) + checks.append(ReadinessCheck("output_writable", writable, "cap.check.output", detail)) + + sn = str(fields["sensor_sn"] or "").strip() + checks.append( + ReadinessCheck( + "sensor_sn", bool(sn), "cap.check.sn", "" if sn else t("cap.block.no_sn") + ) + ) + return checks + + @staticmethod + def _output_writable(text: str) -> tuple[bool, str]: + if not text: + return False, t("cap.block.no_output") + path = Path(text) + probe = path + while not probe.exists() and probe != probe.parent: + probe = probe.parent + if not probe.exists(): + return False, "the output directory does not exist" + if not os.access(probe, os.W_OK): + return False, "the output directory is not writable" + return True, "" + + def _start_arguments(self) -> dict: + fields = self.settings.session_fields() + return { + "root": Path(fields["root"]), + "sensor_model": fields["sensor_model"], + "sensor_sn": fields["sensor_sn"], + "coordinate_frame": fields["coordinate_frame"], + "max_force_n": float(fields["max_force_n"]), + "min_force_n": float(fields["min_force_n"] or 0.0), + "simulate": bool(fields["simulate"]), + "recording": self.settings.recording_options(), + "codec_availability": self.settings.availability, + } + + # ----------------------------------------------------------------- actions + + def _primary(self) -> None: + try: + action = self.controller.primary_action() + if action.key == "align_point": + self._align() return - self.multipoint.confirm_loaded() + if not action.enabled: + raise ActionBlocked(action.blocked_reason, action.label_key) + if action.key == "start_session": + self._start_session() + elif action.key == "confirm_target": + self._confirm_target() + else: + if action.key == "finish_session": + # Closing out returns the page to idle, and the summary publish + # only runs for a finished session: start it (or find it already + # running) before the phase changes, so it is never skipped. + self._publish_when_finished(self.controller.poll()) + self.controller.perform(action.key) + if action.key == "finish_session": + self.set_message( + t("cap.message.done", path=self._directory() or "—") + ) + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + except (OSError, RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + finally: + self._refresh() + + def _start_session(self) -> None: + fields = self.settings.session_fields() + self._summary_published_for = None + self.controller.perform("start_session") + self._clear_live_reach() + directory = self._directory() + if not fields["simulate"]: + worker = getattr(self.source_panel, "worker", None) + mode = getattr(self.source_panel, "mode", None) + if worker is None or mode is None: + self._stop_session("the input is not running") + raise RuntimeError(t("cap.block.no_worker")) + try: + # The recorder's lifecycle is the one direct Campaign call the + # frozen contract keeps (interfaces 1.11): it is not a workflow + # decision and not data this page renders. + self.campaign.attach_capture( + worker, fps=float(getattr(mode, "fps", _FALLBACK_FPS)) + ) + except (OSError, RuntimeError, ValueError) as exc: + self._stop_session(f"the recording could not start: {exc}") + raise RuntimeError(t("cap.message.attach_failed", detail=exc)) from exc + self.set_message(t("cap.message.started", path=directory or "—")) + + def _stop_session(self, reason: str) -> None: + """Stop through the controller so ``session.json`` records the reason. + + ``reason`` is free text for the record, not a locale key (interfaces 1.11). + Nothing running is already the state this call wants, so a refusal is not + raised into the caller's error path. + """ + try: + self.controller.perform("stop", reason=reason) + except (ActionBlocked, OSError, RuntimeError, ValueError): + logger.debug("Stop not needed: %s", reason) + + def _confirm_target(self) -> None: + if self.source_mode.currentData() == "wrench": + parts = [part for part in self.rotation.text().replace(",", " ").split() if part] + rotation = [float(part) for part in parts] if parts else None + wrench = (float(self.fx.text()), float(self.fy.text()), float(self.fz.text())) + # The controller sets the rotation itself, right before the reading: + # Campaign.start() clears whatever was entered while idle. + self.controller.perform("confirm_target", wrench=wrench, rotation=rotation) + return + text = self.reference.text().strip() + if not text: + raise ValueError(t("calibration.reference_required")) + try: + force = float(text) + except ValueError as exc: + raise ValueError(t("cap.block.bad_reference")) from exc + self.controller.perform("confirm_target", force_n=force) + self.reference.clear() + + def _secondary(self, key: str) -> None: + try: + site_id = "" + if key == "skip": + site_id = str(self._model().progress.get("site_id") or "") + self.controller.perform(key) + if key == "skip": + # The controller keeps the skip mark and renders it through + # ``SiteRow.skipped``; the message only names the point. + self.set_message(t("cap.message.skipped", site=site_id or "—")) + elif key == "stop": + self.set_message(t("cap.message.stopped")) + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + except (OSError, RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + finally: + self._refresh() + + # --------------------------------------------------------------- alignment + + def _align(self) -> None: + """Ask for the current point, then publish the move request.""" + if self._aligning_site: + self.set_message(t("cap.block.align_running")) + return + if self.controller.primary_action().key != "align_point": + # A direct call while a run is in progress must not move the platform: + # the running repeat would be attributed to the old pose. + self.set_message(t("multipoint.align_busy")) + return + site_id = self._focus_site_id() + if not site_id: + self.set_message(t("cap.block.no_pose")) + return + if self._live_reachable.get(site_id) is False: + # A re-solve under the current mounting/travel said this point cannot be + # reached. The queue is frozen between trials, so this fact only lives in + # the display overlay; refusing here is what keeps the big red button + # from commanding a pose the machine no longer reaches. + self.set_message(t("cap.block.pose_unreachable")) return try: - if not self.reference.text().strip(): - raise ValueError(t("calibration.reference_required")) - self.trial.confirm(float(self.reference.text())) - self.reference.clear() + state = self.controller.poll() + if state.get("phase") == "idle": + # Idle is the only phase whose queue is editable, so it is the only + # one where a fresh solve can be stored through the controller. The + # display overlay from ``resolve_queue`` covers the frozen phases. + self._resolve_site_pose(site_id) + self.controller.perform("align_point") + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + self._refresh() + return except (OSError, RuntimeError, ValueError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) - self._sync() + self.set_message(t("calibration.error").format(detail=exc)) + self._refresh() + return + request = self.controller.pending_alignment + if request is None: # pragma: no cover - perform("align_point") always publishes + self.set_message(t("cap.block.no_pose")) + self._refresh() + return + self.controller.pending_alignment = None + self._aligning_site = site_id + self.set_message(t("cap.message.aligning", site=site_id)) + self.alignRequested.emit(dict(request.targets)) + self._refresh() - def _finish(self) -> None: - try: - self.trial.finish() - except (OSError, RuntimeError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) - self._sync() + def alignment_succeeded(self) -> None: + """The window confirmed the requested move finished.""" + site_id = self._aligning_site or None + self._aligning_site = "" + self._ensure_controller().mark_aligned(site_id) + self.set_message(t("cap.message.aligned", site=site_id or "—")) + self._refresh() - def _abort(self) -> None: + def alignment_invalidated(self) -> None: + """The platform moved for some other reason, or the geometry changed. + + An earlier "aligned" no longer describes where the platform stands, so the + page offers the align step again. + """ + self._ensure_controller().invalidate_alignment() + self._refresh() + + def alignment_failed(self, reason: str = "") -> None: + """The move was refused, cancelled or failed: the point stays unaligned.""" + site_id = self._aligning_site or None + self._aligning_site = "" + self._ensure_controller().mark_alignment_failed(site_id) + self.set_message( + t("cap.message.align_failed", detail=reason or t("move.failed")) + ) + self._refresh() + + def _focus_site_id(self) -> str: + state = self.controller.poll() + model = self.controller.model() + pending = str(state.get("pending_alignment_site") or "") + if pending: + return pending + if model.sites and state.get("phase") == "idle": + return model.sites[0].site_id + return str(model.progress.get("site_id") or "") + + def _row(self, site_id: str): + """The controller's snapshot of one queue row, or ``None``.""" + return next( + (row for row in self.controller.model().sites if row.site_id == site_id), + None, + ) + + def _resolve_site_pose(self, site_id: str) -> dict: + """Re-solve the point against the mounting in force now, then store it. + + The recorded point arrives as the controller's ``SiteRow`` (position, + normal, frame, UV), and the fresh pose is stored back through the + controller — the page never reads or writes the campaign's queue directly. + """ + row = self._row(site_id) + if row is None: + raise ValueError(t("multipoint.point_unmapped")) + if row.uv_px is not None: + point, normal, pose, _uv = self._solve_uv( + float(row.uv_px[0]), float(row.uv_px[1]) + ) + self._require_same_point(row, point, normal) + self._require_usable_pose(pose) + self.controller.store_solved_pose(site_id, pose) + return pose + if row.normal is None or row.position_frame != "sensor": + raise ValueError(t("multipoint.point_unmapped")) try: - self.trial.finish(aborted=True, reason="operator aborted") - except (OSError, RuntimeError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) - self._sync() + point = tuple(float(row.position[axis]) for axis in "xyz") + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(t("multipoint.point_unmapped")) from exc + pose = self._solve_point(point, row.normal) + self._require_usable_pose(pose) + self.controller.store_solved_pose(site_id, pose) + return pose + + @staticmethod + def _require_usable_pose(pose: dict) -> None: + """A pose the current machine cannot reach is stored, never commanded.""" + if not pose.get("ok") or not pose.get("in_range"): + raise ValueError( + t("multipoint.pose_refused").format(reason=pose.get("reason") or "") + ) + + def _solver_stack(self): + if self.solver is None: + raise ValueError(t("multipoint.no_solver")) + return self.solver + + @staticmethod + def _pose_of(solved, stack) -> dict: + """The record of one solve: the targets, and what the solver said of them.""" + move_x, move_y = machine_table_xy(solved.table_xy_mm) + return { + "x": float(move_x), + "y": float(move_y), + "yaw": float(solved.yaw_deg), + "pitch": float(solved.pitch_deg), + "z": float(solved.contact_z_mm), + "residual_deg": float(solved.residual_deg), + "upright": bool(solved.upright), + "in_range": bool(solved.ok and solved.in_range), + "ok": bool(solved.ok), + "normal_aligned_to": f"tool_axis:{stack.tool_axis}", + "reason": str(solved.reason or ""), + } + + def _solve_uv(self, u: float, v: float): + """Pose for the point currently chosen on the UV map. Does not move anything.""" + if self._flat is None: + if self._simulating(): + return self._simulated_point(u, v) + raise ValueError(t("multipoint.no_map")) + site = site_at(self._flat, int(round(u)), int(round(v)), origin=self._origin) + sample = self._flat.sample(*site.raster_xy_px) + if sample is None: + raise ValueError(t("multipoint.mesh_required")) + point, normal, _face = sample + stack = self._solver_stack() + solved = stack.solve( + np.asarray(point, dtype=np.float64), + np.asarray(normal, dtype=np.float64), + ) + length = float(np.linalg.norm(normal)) + unit = ( + tuple(float(value) / length for value in normal) + if length >= 1e-9 + else tuple(float(value) for value in normal) + ) + return ( + (float(point[0]), float(point[1]), float(point[2])), + unit, + self._pose_of(solved, stack), + (float(site.u_px), float(site.v_px)), + ) + + def _simulating(self) -> bool: + return bool(self.settings.session_fields()["simulate"]) + + @staticmethod + def _simulated_point(u: float, v: float): + """A point for a simulated session with no surface loaded. + + With nothing connected there is no bundle to pick on, so the typed U and V + are taken as the point's X and Y, with the surface normal straight along Z. + The pose is marked as simulated in its own record, and the session is + flagged ``simulate`` in ``session.json``; neither can be mistaken for a + solved pose on a real surface. + """ + pose = { + "x": float(u), + "y": float(v), + "yaw": 0.0, + "pitch": 0.0, + "z": 0.0, + "residual_deg": 0.0, + "upright": True, + "in_range": True, + "ok": True, + "normal_aligned_to": "simulated", + "reason": "simulated point: no calibration bundle loaded", + } + return ( + (float(u), float(v), 0.0), + (0.0, 0.0, 1.0), + pose, + (float(u), float(v)), + ) + + def _solve_point(self, point, normal) -> dict: + stack = self._solver_stack() + solved = stack.solve( + np.asarray(point, dtype=np.float64), + np.asarray(normal, dtype=np.float64), + ) + return self._pose_of(solved, stack) + + def _require_same_point(self, row, point, normal) -> None: + """Refuse a point the current map no longer puts where the record says. + + A re-solve is allowed to answer differently; it is not allowed to move the + point quietly. The recorded position is what a session's rows are attributed + to, so a changed map, bundle or mounting has to reach the operator as "take + this point again", never as a silently retargeted move. + + Compares against the controller's row snapshot, never the live campaign. + """ + if row.position_frame != "sensor" or row.normal is None: + return + try: + stored = np.asarray( + tuple(float(row.position[axis]) for axis in "xyz"), dtype=np.float64 + ) + except (KeyError, TypeError, ValueError): + return + moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored)) + if moved > _POINT_MATCH_MM: + raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}")) + dot = float( + np.dot( + np.asarray(normal, dtype=np.float64), + np.asarray(row.normal, dtype=np.float64), + ) + ) + turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0)))) + if turned > _NORMAL_MATCH: + raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}")) + + # ------------------------------------------------------------- queue edits + + def _levels(self) -> list[float]: + return [ + float(part.strip()) + for part in self.levels.text().split(",") + if part.strip() + ] + + def _hold_value(self) -> float: + return float(self.hold.value()) + + def _add_point(self) -> None: + try: + controller = self._ensure_controller() + levels = self._levels() + hold = self._hold_value() + point, normal, pose, uv = self._solve_current() + existing = [row.site_id for row in controller.model().sites] + site_id = self.site_id.text().strip() or next_point_id(existing) + controller.add_point( + site_id, + point[0], + point[1], + point[2], + normal, + pose, + levels, + hold, + self.repeats.value(), + uv_px=uv, + ) + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + self._refresh() + return + except (OSError, RuntimeError, ValueError, KeyError, TypeError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + self._refresh() + return + self._selected_site = site_id + self._clear_live_reach() + self.site_id.setText(next_point_id([*existing, site_id])) + self.set_message(t("cap.message.queue_added", site=site_id)) + self._refresh() + + def _update_point(self) -> None: + """Retake the selected point at the current UV position. + + The controller's ``update_point`` keeps the recorded normal and UV, so a + point taken again elsewhere is a new point: remove, add, and put it back in + the same queue position. Every step goes through the controller (the queue + editing API has no normal/UV setter on purpose) and none of it touches the + campaign directly. + """ + selected = self._selected_site + if not selected: + return + try: + controller = self._ensure_controller() + rows = controller.model().sites + index = next( + (position for position, row in enumerate(rows) if row.site_id == selected), + None, + ) + if index is None: + return + levels = self._levels() + hold = self._hold_value() + point, normal, pose, uv = self._solve_current() + target = self.site_id.text().strip() or selected + controller.remove_point(selected) + controller.add_point( + target, + point[0], + point[1], + point[2], + normal, + pose, + levels, + hold, + self.repeats.value(), + uv_px=uv, + ) + # The point was re-appended; restore its place in the queue order. + delta = index - (len(rows) - 1) + if delta: + controller.move_point(target, delta) + self._selected_site = target + self._clear_live_reach() + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + self._refresh() + return + except (OSError, RuntimeError, ValueError, KeyError, TypeError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + self._refresh() + + def _remove_point(self) -> None: + selected = self._selected_site + if not selected: + return + try: + self._ensure_controller().remove_point(selected) + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + self._refresh() + return + except (RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + self._refresh() + return + self._selected_site = "" + self._clear_live_reach() + self.set_message(t("cap.message.queue_removed", site=selected)) + self._refresh() + + def _move_point(self, delta: int) -> None: + selected = self._selected_site + if not selected: + return + try: + self._ensure_controller().move_point(selected, delta) + except ActionBlocked as exc: + self.set_message(t(exc.reason)) + except (RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + self._refresh() + + def _solve_current(self): + return self._solve_uv(float(self.u_spin.value()), float(self.v_spin.value())) + + # ------------------------------------------------------ operation sheet / re-solve + + def _export_sheet(self) -> None: + """Write the queue as the operator's point sheet (P5 exit condition).""" + try: + rows = self.controller.model().sites + except (OSError, RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + return + if not rows: + self.set_message(t("cap.queue.export_no_points")) + return + path, _selected = QFileDialog.getSaveFileName( + self, + t("cap.queue.export"), + "calibration-points.csv", + "CSV (*.csv)", + ) + if not path: + return + try: + write_operation_table(path, rows, self._flat) + except (OSError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + return + self.set_message(t("cap.queue.export_saved", path=path)) + + def resolve_queue(self) -> None: + """Re-solve every queued point against the geometry in force now. + + Called when the geometry screen reports a changed mounting, tool position or + travel limit. In the idle phase the queue is editable, so each fresh pose is + stored through the controller and the controller's own ``reachable`` follows. + Outside idle the queue is frozen: the page still re-solves and records what + that means for display and for refusing an align, but writes nothing back — + a mid-session mounting change is not allowed to retarget the queue silently. + """ + controller = self._ensure_controller() + # A changed mounting or travel limit changes what every stored pose means, + # whether or not the queue is editable right now. + controller.invalidate_alignment() + state = controller.poll() + editable = state.get("phase") == "idle" + reachable: dict[str, bool] = {} + for row in controller.model().sites: + if row.uv_px is None: + continue + try: + point, normal, pose, _uv = self._solve_uv( + float(row.uv_px[0]), float(row.uv_px[1]) + ) + except (OSError, RuntimeError, ValueError): + reachable[row.site_id] = False + continue + reachable[row.site_id] = bool(pose.get("ok") and pose.get("in_range")) + if not editable: + continue + try: + self._require_same_point(row, point, normal) + except ValueError: + # The point itself moved under the map: a mounting change must not + # quietly retarget it, so the row is reported unreachable instead. + reachable[row.site_id] = False + continue + try: + controller.store_solved_pose(row.site_id, pose) + except ActionBlocked: + reachable[row.site_id] = False + self._live_reachable = reachable + self._refresh() + + def _clear_live_reach(self) -> None: + self._live_reachable = {} + + # ---------------------------------------------------------------- polling def _poll(self) -> None: - previous = self.trial.status try: - current = self.trial.poll() - except (OSError, ValueError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) - self._sync() - return - if current != previous and current in {"complete", "incomplete"}: - result_key = ( - "calibration.incomplete_result" - if current == "incomplete" - else "calibration.result" - ) - self.result_label.setText( - t(result_key).format( - status=t(f"calibration.{current}"), path=self.trial.directory - ) - + ( - "\n" + t("calibration.replay_note") - if not self.trial.reference_alignment_possible - else "" - ) - ) - self._sync() + self._refresh() + except (OSError, RuntimeError, ValueError) as exc: + self.set_message(t("calibration.error").format(detail=exc)) + self._stop_session(str(exc)) - def _close_session(self) -> None: - try: - directory, status = self.trial.finish_session() - self.result_label.setText( - t("calibration.session_closed").format( - status=t(f"calibration.{status}"), path=directory + def _model(self): + return self.controller.model() + + def _session_locked(self, state: dict) -> bool: + if state.get("capture_state") == "closing": + return True + return str(state.get("phase") or "") in _LOCKED_PHASES + + def _refresh(self) -> None: + controller = self._ensure_controller() + state = controller.poll() + primary = controller.primary_action() + secondaries = {action.key: action for action in controller.secondary_actions()} + model = controller.model() + checks = controller.readiness() + locked = self._session_locked(state) + self._rows = list(model.sites) + self._render_session(model, state) + self._render_ready(checks) + self._render_queue(model, locked) + self._render_card(primary, secondaries, state, model) + self._render_settings(state, model, locked) + self._publish_when_finished(state) + + def _render_session(self, model, state: dict) -> None: + directory = str(model.progress.get("directory") or "") + if directory: + self.session_label.setText( + t( + "cap.session.active", + sites=len(model.sites), + done=int(model.progress.get("done") or 0), + total=int(model.progress.get("total") or 0), + path=directory, ) ) - except (OSError, RuntimeError) as exc: - self.result_label.setText(t("calibration.error").format(detail=exc)) else: - self.publish_summary_for(directory) - self._sync() + self.session_label.setText(t("calibration.session_none")) + session_directory = self._session_directory() + summary_directory = ( + None + if session_directory is None + else published_summary_directory(session_directory) + ) + if state.get("phase") in {"idle", "done", "aborted"}: + self.review_button.setEnabled(True) + else: + self.review_button.setEnabled(False) + # Reaching the folder is always allowed: an operator watching a hold may + # need the path, and opening it does not change the acquisition. + self.open_session_button.setEnabled(session_directory is not None) + self.open_summary_button.setEnabled(summary_directory is not None) + self.copy_path_button.setEnabled(session_directory is not None) + self.open_session_button.setToolTip( + t("cap.action.open_session") + if session_directory is not None + else t("cap.block.no_directory") + ) + self.open_summary_button.setToolTip( + t("cap.action.open_summary") + if summary_directory is not None + else t("cap.block.no_summary") + ) + self.copy_path_button.setToolTip( + t("cap.action.copy_path") + if session_directory is not None + else t("cap.block.no_directory") + ) + + def _render_ready(self, checks: list[ReadinessCheck]) -> None: + for check in checks: + label = self.ready_labels.get(check.key) + if label is None: + continue + mark = "✓" if check.ok else "✗" + label.setText(f"{mark} {t(check.label_key)}") + label.setToolTip(check.detail or t(check.label_key)) + + def _render_queue(self, model, locked: bool) -> None: + rows = [] + for index, row in enumerate(model.sites): + position = row.position or {} + if row.complete: + state = t("cap.queue.state_complete") + elif row.skipped and row.repeats_complete == 0: + state = t("cap.queue.state_skipped") + elif row.repeats_complete: + state = t("cap.queue.state_partial") + else: + state = t("cap.queue.state_pending") + marker = "▶ " if index == model.current_index else "" + reachable = self._live_reachable.get(row.site_id, row.reachable) + rows.append( + ( + f"{marker}{row.site_id}", + ", ".join(f"{float(position.get(axis) or 0.0):.2f}" for axis in "xyz"), + f"{row.repeats_complete}/{row.repeats_planned}", + t("cap.queue.reachable") if reachable else t("cap.queue.unreachable"), + state, + ) + ) + listed = tuple(rows) + if listed != self._listed_rows: + self._listed_rows = listed + self.queue_table.blockSignals(True) + self.queue_table.setRowCount(len(rows)) + for row_index, values in enumerate(rows): + for column, value in enumerate(values): + self.queue_table.setItem(row_index, column, QTableWidgetItem(value)) + self.queue_table.blockSignals(False) + self.queue_hint.setText( + t("cap.queue.empty") if not rows else "" + ) + # The sheet is an export of the queue: without points there is nothing to + # write, and the button says that rather than opening a dialog that fails. + self.export_sheet_button.setEnabled(bool(rows)) + self.export_sheet_button.setToolTip( + t("cap.queue.export_tip") if rows else t("cap.queue.export_no_points") + ) + editable = not locked + for widget in ( + self.add_point_button, + self.update_point_button, + self.remove_point_button, + self.up_button, + self.down_button, + self.levels, + self.hold, + self.repeats, + self.site_id, + self.u_spin, + self.v_spin, + ): + widget.setEnabled(editable) + for button in ( + self.add_point_button, + self.update_point_button, + self.remove_point_button, + self.up_button, + self.down_button, + ): + button.setToolTip("" if editable else t("cap.block.queue_busy")) + if self._selected_site and any(row.site_id == self._selected_site for row in model.sites): + self._select_row(self._selected_site) + elif self._selected_site: + self._selected_site = "" + + def _render_card(self, primary, secondaries: dict, state: dict, model) -> None: + empty = not model.sites + if empty: + # Nothing to align and nothing to start: say so in the card rather than + # showing a start step that can never be enabled. + self.step_label.setText(t("cap.card.no_point")) + else: + step_key = _labels().get(primary.key, "cap.step.closing") + self.step_label.setText(t(step_key)) + self.primary_button.setText(t(primary.label_key)) + self.primary_button.setEnabled(bool(primary.enabled)) + if primary.enabled: + self.primary_reason.setText("") + self.primary_button.setToolTip("") + else: + reason = t(primary.blocked_reason) if primary.blocked_reason else "" + self.primary_reason.setText(reason) + self.primary_button.setToolTip(reason) + self.progress_label.setText( + "" if empty else self._progress_text(state, model) + ) + self.capture_label.setText(self._capture_text(model)) + hint = t("cap.queue.empty") if empty else self._hint_text(primary, state) + self.hint_label.setText(hint) + + for key, button in ( + ("remeasure", self.remeasure_button), + ("skip", self.skip_button), + ("stop", self.stop_button), + ): + action = secondaries.get(key) + if action is None: # pragma: no cover - the controller offers all three + button.setEnabled(False) + continue + button.setText(t(action.label_key)) + button.setEnabled(bool(action.enabled)) + button.setToolTip("" if action.enabled else t(action.blocked_reason)) + + confirm = primary.key == "confirm_target" + self.source_row.setEnabled(confirm) + self.weight_row.setEnabled(confirm) + self.wrench_box.setEnabled(confirm) + if self.source_mode.currentData() == "wrench": + self.weight_row.setVisible(False) + self.wrench_box.setVisible(True) + else: + self.weight_row.setVisible(True) + self.wrench_box.setVisible(False) + + def _progress_text(self, state: dict, model) -> str: + phase = str(state.get("phase") or "") + progress = model.progress + if phase in {"load", "hold"}: + branch = { + "baseline": t("multipoint.branch_baseline"), + "load": t("multipoint.branch_load"), + "unload": t("multipoint.branch_unload"), + }.get(str(progress.get("branch") or ""), str(progress.get("branch") or "")) + return t("multipoint.progress").format( + site=progress.get("site_id") or "—", + site_index=progress.get("site_index") or 0, + site_count=progress.get("site_count") or 0, + branch=branch, + load=progress.get("target_n") or 0, + repeat=progress.get("repeat_index") or 0, + repeats=progress.get("repeats") or 0, + done=progress.get("done") or 0, + total=progress.get("total") or 0, + seconds=float(state.get("remaining_s") or 0.0), + ) + if phase == "liftoff": + next_site = str(state.get("pending_alignment_site") or "") + return t("cap.card.next_site", site=next_site or "—") + if phase in {"done", "aborted"}: + return t("cap.message.done", path=self._directory() or "—") + if phase == "closing": + return t("cap.step.closing") + return t("cap.card.no_progress") + + def _capture_text(self, model) -> str: + """Where the recording is, and why it is not complete, in one line.""" + capture = (model.progress.get("capture") or {}) if model.progress else {} + status = str(capture.get("state") or "idle") + key = { + "idle": "multipoint.capture_idle", + "recording": "multipoint.capture_recording", + "closing": "multipoint.capture_closing", + "complete": "multipoint.capture_complete", + "incomplete": "multipoint.capture_incomplete", + "not_recorded": "multipoint.capture_not_recorded", + }.get(status, "multipoint.capture_idle") + reason = str(capture.get("reason") or "") + reason_key = { + "source_restarted": "multipoint.capture_reason_restarted", + "zero_frames": "multipoint.capture_reason_zero_frames", + "write_error": "multipoint.capture_reason_write_error", + "video_missing": "multipoint.capture_reason_video_missing", + "video_undecodable": "multipoint.capture_reason_video_undecodable", + "dropped_data": "multipoint.capture_reason_dropped", + "dropped_video": "multipoint.capture_reason_dropped", + "segment_count": "multipoint.capture_reason_segments", + "closing_timeout": "multipoint.capture_reason_timeout", + "source_stopped": "multipoint.capture_reason_source_stopped", + "not_recorded": "multipoint.capture_reason_not_recorded", + }.get(reason) + return t("multipoint.capture").format( + status=t(key), + frames=int(capture.get("frames") or 0), + dropped=int(capture.get("dropped_data") or 0) + + int(capture.get("dropped_video") or 0), + reason=t(reason_key) if reason_key else (reason or "—"), + ) + + def _hint_text(self, primary, state: dict) -> str: + key = primary.key + if key == "confirm_hold": + return t("cap.hint.confirm_hold", seconds=float(state.get("remaining_s") or 0.0)) + hint_key = { + "align_point": "cap.hint.align_point", + "start_session": "cap.hint.start_session", + "confirm_target": "cap.hint.confirm_target", + "confirm_liftoff": "cap.hint.confirm_liftoff", + "finish_session": "cap.hint.finish_session", + "closing": "cap.hint.closing", + }.get(key) + if hint_key is None: # pragma: no cover - every primary key has a hint + return "" + text = t(hint_key) + if not primary.enabled and primary.blocked_reason == "cap.block.not_ready": + text += "\n" + t("cap.hint.not_ready") + return text + + def _render_settings(self, state: dict, model, locked: bool) -> None: + self.settings.set_locked(locked) + options = self.settings.recording_options() + summary = ( + t("cap.recording.summary", codec=options.codec) + if options.video + else t("cap.recording.off") + ) + plan = self._planned_seconds(model) + if plan != self._last_plan_seconds: + self._last_plan_seconds = plan + self.settings.set_plan(plan) + if plan > 0 and options.video: + summary += " · " + self.settings.estimate.text().replace("\n", " · ") + self.settings_summary.setText(summary) + self.settings_summary.setToolTip(t("cap.session.settings_hint")) + + def _planned_seconds(self, model) -> float: + try: + steps = len(load_steps(self._levels(), float(self._max_force_value() or 0.0))) + except (ValueError, TypeError): + return 0.0 + hold = self._hold_value() + return sum( + row.repeats_planned * steps * (hold + STEP_OVERHEAD_S) for row in model.sites + ) + + # -------------------------------------------------------------- publishing @property def summary_publishing(self) -> bool: @@ -397,10 +1552,13 @@ class CalibrationPanel(QWidget): return False self._summary_directory = path self._summary_ready_path = None + #: Warnings from the publish worker. It may not touch widgets, so it + #: collects them here and ``_summary_finished`` reports them. + self._summary_warnings: list[str] = [] def action(): summary = build_summary(path) - return publish_summary(path, summary) + return publish_summary(path, summary, on_warning=self._summary_warnings.append) self._summary_job = ReviewJob(action, self._summary_finished) self._summary_job.start() @@ -408,150 +1566,241 @@ class CalibrationPanel(QWidget): def _summary_finished(self, value, error) -> None: self._summary_job = None + warnings = list(getattr(self, "_summary_warnings", [])) + self._summary_warnings = [] if error is not None: # The pointer still names the previous summary; the recording stays # exactly as it was. - self.result_label.setText(t("summary.failed").format(detail=error)) + self.set_message(t("summary.failed").format(detail=error)) return self._summary_ready_path = Path(value) - self.result_label.setText(t("summary.published").format(path=value)) + if warnings: + # The pointer moved but the readable copy did not: say so instead of + # claiming a clean publish, because "打开汇总" may now show the older + # copy or fall back to the derived directory. The warning already ends + # with a full stop, and the template supplies its own punctuation. + detail = warnings[-1].strip().rstrip("。.") + self.set_message(t("summary.copy_failed").format(detail=detail)) + return + self.set_message(t("summary.published", path=value)) + + def _publish_when_finished(self, state: dict) -> None: + if state.get("phase") not in {"done", "aborted"}: + return + if state.get("capture_state") == "closing": + return + directory = self._session_directory() + if directory is None or str(directory) == self._summary_published_for: + return + if self.publish_summary_for(directory): + self._summary_published_for = str(directory) + + # ------------------------------------------------------------- navigation + + def _directory(self) -> str: + return str(self._model().progress.get("directory") or "") + + def _session_directory(self) -> Path | None: + directory = self._directory() + return Path(directory) if directory else None + + def _summary_folder(self) -> Path | None: + directory = self._session_directory() + return None if directory is None else published_summary_directory(directory) + + def _open_session_folder(self) -> None: + directory = self._session_directory() + if directory is not None: + reveal(directory) + + def _open_summary_folder(self) -> None: + directory = self._summary_folder() + if directory is not None: + reveal(directory) + + def _copy_session_path(self) -> None: + directory = self._session_directory() + if directory is not None: + QApplication.clipboard().setText(str(directory)) def _review(self) -> None: - session = self.trial.session directory = QFileDialog.getExistingDirectory( - self, t("review.open"), - str(session.directory) if session is not None else self.output_path.text(), + self, t("review.open"), self._directory() or self.settings.output_path.text() ) if directory: self.reviewRequested.emit(directory) + def set_message(self, text: str) -> None: + """Show one line of operator feedback at the bottom of the page.""" + self.message.setText(str(text or "")) + + def _open_settings(self) -> None: + self.settings.show() + self.settings.raise_() + self.settings.activateWindow() + + # ------------------------------------------------------------------ input + + def set_flat(self, flat, bundle_path: str) -> None: + """Take the rasterised surface whose UV map the queue is picked on.""" + if self.session_open: + raise RuntimeError(t("calibration.close_before_mapping")) + self._flat = flat + self._bundle_path = str(bundle_path) + self._clear_live_reach() + # A new surface means every earlier "aligned" was for another one. + self._ensure_controller().invalidate_alignment() + self.flat_view.set_flat(flat) + origin_x, origin_y = uv_origin(flat) + self._origin = (origin_x, origin_y) + self.u_spin.setRange(-origin_x, flat.width - 1 - origin_x) + self.v_spin.setRange(origin_y - flat.height + 1, origin_y) + self.settings.set_bundle(self._bundle_path) + self._site_changed() + + @property + def session_open(self) -> bool: + """Whether a session is recording or still closing.""" + if self._controller is None: + return False + state = self._controller.poll() + return self._session_locked(state) + + def _picked(self, u: int, v: int) -> None: + if self._origin is None: + return + origin_x, origin_y = self._origin + self.u_spin.setValue(u - origin_x) + self.v_spin.setValue(origin_y - v) + self._site_changed() + + def _site_changed(self) -> None: + try: + self._site = site_at( + self._flat, + self.u_spin.value(), + self.v_spin.value(), + origin=self._origin, + ) + except (ValueError, TypeError, AttributeError): + self._site = None + origin = self._origin + self.flat_view.set_marker( + (origin[0] + self.u_spin.value(), origin[1] - self.v_spin.value()) + if origin is not None + else None + ) + self.flat_view.set_selected( + self._site.raster_xy_px if self._site is not None else None + ) + self.position_value.setText( + "—" + if self._site is None + else ", ".join(f"{value:.3f}" for value in self._site.position_sensor_mm) + + " mm" + ) + self._refresh() + + def _on_queue_selected(self) -> None: + rows = self.queue_table.selectionModel().selectedRows() + if not rows: + return + index = rows[0].row() + if not 0 <= index < len(self._rows): + return + row = self._rows[index] + self._selected_site = row.site_id + self.site_id.setText(row.site_id) + if self._flat is not None: + pixel = self.flat_view.pixel_for_point( + (row.position.get("x"), row.position.get("y"), row.position.get("z")) + ) + if pixel is not None and self._origin is not None: + origin_x, origin_y = self._origin + self.u_spin.blockSignals(True) + self.v_spin.blockSignals(True) + self.u_spin.setValue(pixel[0] - origin_x) + self.v_spin.setValue(origin_y - pixel[1]) + self.u_spin.blockSignals(False) + self.v_spin.blockSignals(False) + self.flat_view.set_selected(pixel) + self.queue_hint.setText(t("cap.queue.selected", site=row.site_id)) + + def _select_row(self, site_id: str) -> None: + for index, row in enumerate(self._rows): + if row.site_id != site_id: + continue + if self.queue_table.currentRow() != index: + self.queue_table.blockSignals(True) + self.queue_table.selectRow(index) + self.queue_table.blockSignals(False) + return + + def _on_source_mode_changed(self) -> None: + wrench = self.source_mode.currentData() == "wrench" + self.weight_row.setVisible(not wrench) + self.wrench_box.setVisible(wrench) + self.force_inputs.adjustSize() + + # ------------------------------------------------------------- lifecycle + def shutdown(self) -> None: self._timer.stop() + self.live_view.shutdown() self.force_view.shutdown() try: - if self.trial.status == "recording": - self.trial.finish(aborted=True, reason="application closed") - self.multipoint.shutdown() - if self.trial.status == "closing" and self.trial.recorder is not None: - self.trial.recorder.stop(wait=True) - self.trial.poll() - if self.trial.session is not None: - self.trial.finish_session() + # Stop through the controller first: an active campaign's repeat is left + # explicitly incomplete rather than looking complete because the window + # closed mid-hold, and session.json records why it ended. + self._stop_session("application closed") + # Recorder teardown is the one direct Campaign call the contract keeps. + self.campaign.flush_close() except (OSError, RuntimeError, ValueError) as exc: - # A failed manifest must not prevent the window stopping the equipment. - logging.getLogger(__name__).exception("Calibration shutdown failed: %s", exc) + logging.getLogger(__name__).exception("Capture shutdown failed: %s", exc) - def _sync(self) -> None: - recording = self.trial.status == "recording" - locked = recording or self.trial.status == "closing" - for widget in ( - self.u_spin, - self.v_spin, - self.levels, - self.hold, - self.bundle_button, - ): - widget.setEnabled(not locked) - campaign_busy = self.multipoint.is_active - for widget in (self.sensor_sn, self.sensor_model, self.output_path, - self.output_browse, self.bundle_button): - widget.setEnabled(not locked and self.trial.session is None and not campaign_busy) - self.start_button.setEnabled( - not locked and self._site is not None and not campaign_busy - ) - can_confirm = recording and self.trial.next_step is not None and not self.trial.holding - multipoint_load = self.multipoint.campaign.phase == "load" - multipoint_ready = self.multipoint.campaign.capture_ready - multipoint_confirm = multipoint_load and multipoint_ready - self.confirm_button.setEnabled(can_confirm or multipoint_confirm) - self.reference.setEnabled( - can_confirm or (multipoint_confirm and self.multipoint.source.currentData() != "wrench") - ) - self.finish_button.setEnabled(recording and self.trial.next_step is None) - self.abort_button.setEnabled(recording) - step = self.trial.next_step - if recording and step is not None: - key = "calibration.holding" if self.trial.holding else "calibration.next_step" - target_text = t(key).format( - branch=t(f"calibration.{step[0]}"), load=step[1], - seconds=self.trial.remaining_s, - ) - elif recording: - target_text = t("calibration.ready_to_finish") - elif self.trial.status == "idle": - target_text = t("calibration.before_start") - else: - target_text = t("calibration.no_step") - if self.multipoint.campaign.phase == "load": - target_text = ( - t("multipoint.right_confirm") - if self.multipoint.campaign.capture_ready - else t("multipoint.capture_wait") - ) - self.target_label.setText(target_text) - self.progress_label.setText( - t("calibration.progress").format( - done=self.trial.step_index, - total=len(self.trial.steps), - status=t(f"calibration.{self.trial.status}"), - ) - ) - try: - steps = load_path([float(v.strip()) for v in self.levels.text().split(",")]) - self.path_label.setText(t("calibration.path").format( - path=" → ".join(f"{t('calibration.' + branch)} {force:g}" for branch, force in steps) - )) - except ValueError: - self.path_label.setText(t("calibration.invalid_levels")) - session = self.trial.session - self.close_session_button.setEnabled(session is not None and not locked) - self.review_button.setEnabled(not locked) - self.session_label.setText( - t("calibration.session_active").format( - path=session.directory, sites=len(session.document["sites"]), - trials=len(session.document["trials"]), - ) if session is not None else t("calibration.session_none") - ) - worker = self.source_panel.worker - packet = worker.latest() - self.source_state.setText( - t("calibration.source_state").format( - status=t("source.running") - if worker.is_running - else t("source.stopped"), - frame=packet.frame_id if packet is not None else "—", - dropped=getattr(packet, "capture_dropped", "—") - if packet is not None - else "—", - ) - ) + # ------------------------------------------------------------ translations def retranslate(self) -> None: + self.settings.retranslate() + self.live_view.retranslate() self.force_view.retranslate() - self.model_label.setText(t("calibration.sensor_model")) - self.sn_label.setText(t("calibration.sensor_sn")) - self.output_label.setText(t("calibration.output")) - self.output_browse.setText(t("source.browse")) - self.close_session_button.setText(t("calibration.close_session")) - self.close_session_button.setToolTip(t("calibration.close_session_tip")) + self.open_session_button.setText(t("cap.action.open_session")) + self.open_summary_button.setText(t("cap.action.open_summary")) + self.copy_path_button.setText(t("cap.action.copy_path")) self.review_button.setText(t("review.open")) - self.views.setTabText(0, t("calibration.site_view")) - self.views.setTabText(1, t("calibration.live_view")) - self.views.setTabText(2, t("multipoint.tab")) - self.multipoint.retranslate() - self.bundle_button.setText(t("finger.load")) - self.coords_label.setText(t("calibration.uv")) + self.settings_button.setText(t("cap.action.settings")) + self.uv_label.setText(t("cap.queue.current_uv")) self.position_label.setText(t("calibration.position")) + self.site_label.setText(t("cap.queue.site_id")) + self.repeats_label.setText(t("cap.queue.repeats")) self.levels_label.setText(t("calibration.levels")) self.hold_label.setText(t("calibration.hold")) self.hold.setToolTip(t("calibration.hold_tip")) + self.add_point_button.setText(t("cap.queue.add")) + self.update_point_button.setText(t("cap.queue.update_short")) + self.remove_point_button.setText(t("cap.queue.remove_short")) + self.up_button.setText(t("cap.queue.up")) + self.down_button.setText(t("cap.queue.down")) + self.export_sheet_button.setText(t("cap.queue.export")) + self.export_sheet_button.setToolTip(t("cap.queue.export_tip")) + self.queue_table.setHorizontalHeaderLabels( + [ + t("cap.queue.col_id"), + t("cap.queue.col_position"), + t("cap.queue.col_repeats"), + t("cap.queue.col_reachable"), + t("cap.queue.col_state"), + ] + ) + self.card_title.setText(t("cap.card.title")) + self.source_label.setText(t("multipoint.source")) + self.source_mode.setItemText(0, t("multipoint.source_weight")) + self.source_mode.setItemText(1, t("multipoint.source_wrench")) self.reference_label.setText(t("calibration.reference")) - self.start_button.setText(t("calibration.start")) - self.start_button.setToolTip(t("calibration.start_tip")) - self.confirm_button.setText(t("calibration.confirm")) - self.confirm_button.setToolTip(t("calibration.confirm_tip")) - self.finish_button.setText(t("calibration.finish")) - self.finish_button.setToolTip(t("calibration.finish_tip")) - self.abort_button.setText(t("calibration.abort")) - self.abort_button.setToolTip(t("calibration.abort_tip")) - self._sync() + self.fx_label.setText(t("multipoint.fx")) + self.fy_label.setText(t("multipoint.fy")) + self.fz_label.setText(t("multipoint.fz")) + self.rotation_label.setText(t("multipoint.rotation")) + self.rotation.setPlaceholderText(t("multipoint.rotation_placeholder")) + self.primary_button.setText(t("cap.action.start")) + self._refresh() diff --git a/apps/autocal/src/orisys_autocal/ui/capture_settings_dialog.py b/apps/autocal/src/orisys_autocal/ui/capture_settings_dialog.py new file mode 100644 index 0000000..3be7f66 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/capture_settings_dialog.py @@ -0,0 +1,484 @@ +"""The capture page's settings dialog: input source, bundle, session and recording. + +The capture page itself shows the workflow; everything that is decided once per +session lives here, so that starting a capture cannot switch pages or hide the +live image behind a configuration form. + +Three things this dialog owns beyond plain fields: + +* **The codec probe.** Recording candidates are written and read back once per + process before they are offered. A codec this machine cannot open is disabled + with its reason shown, never silently replaced — a recording made under a + different codec than the session manifest claims is not the evidence it says it + is. +* **The size estimate.** Bitrate times the planned duration is an estimate (the + module says so wherever it is displayed) and the free space beside it is real; + when less than twice the estimate is free the dialog warns before anything is + recorded. +* **The bundle.** Loading one rasterises the finger geometry; the page keeps that + work, the button here only asks for it. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import outputs_dir +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..recording_options import CODEC_CANDIDATES, RecordingOptions, probe_codecs +from ..sequence_io import format_duration + +logger = logging.getLogger(__name__) + +#: Product models a session can be recorded under. The stored value is this id. +PRODUCT_MODELS: tuple[str, ...] = ("finger",) + +#: Measured video bitrates used only for the volume estimate. The plan's baseline +#: measured about 27 Mbit/s for ``mp4v`` on this view and about 5 Mbit/s for the +#: H.264 sample; the slider is not an encoder parameter, so the number is labelled +#: an estimate wherever it is shown. +CODEC_MBIT_PER_S: dict[str, float] = {"mp4v": 27.0, "avc1": 5.0, "H264": 5.0} +DEFAULT_MBIT_PER_S = 10.0 + +#: Planning allowance per hold: load, settle, confirm and unload around the hold +#: itself. It only shapes the size estimate, never a recorded window. +STEP_OVERHEAD_S = 5.0 + +#: One probe per process. The probe writes three real clips, and every window in +#: this app would otherwise repeat it; the answer cannot change while the process +#: runs because it is a property of this machine's OpenCV build. +_PROBE_CACHE: dict[str, dict[str, bool]] = {} + + +def probe_once(scratch: Path | None = None) -> dict[str, bool]: + """The codec availability for this machine, probed at most once per process.""" + directory = Path(scratch) if scratch is not None else Path(outputs_dir()) / "codec_probe" + key = str(directory.resolve()) + if key not in _PROBE_CACHE: + try: + _PROBE_CACHE[key] = dict(probe_codecs(directory)) + except Exception as exc: # noqa: BLE001 - a probe is external and must not crash start-up + logger.exception("Codec probe failed: %s", exc) + _PROBE_CACHE[key] = {} + return dict(_PROBE_CACHE[key]) + + +def format_size(value: float) -> str: + """A byte count in the unit an operator thinks in.""" + number = float(value) + for unit, scale in (("GB", 1e9), ("MB", 1e6), ("kB", 1e3)): + if number >= scale: + return f"{number / scale:.2f} {unit}" + return f"{number:.0f} B" + + +class CaptureSettingsDialog(QDialog): + """Everything decided once per session, in one place.""" + + #: The operator asked for a bundle; the page (and the window) own the dialog. + bundleRequested = Signal() + #: A recorded option changed and should be remembered in the preferences. + recordingChanged = Signal() + + def __init__( + self, + source_panel: QWidget, + prefs: dict, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle(t("cap.settings.title")) + self.resize(720, 700) + self.setModal(True) + self.prefs = prefs + self.source_panel = source_panel + self._availability: dict[str, bool] | None = None + self._probe_error = "" + self._planned_seconds = 0.0 + self._locked = False + + outer = QVBoxLayout(self) + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + page = QWidget(scroll) + layout = QVBoxLayout(page) + layout.setSpacing(10) + scroll.setWidget(page) + outer.addWidget(scroll, 1) + + self.input_group = QGroupBox(page) + self.input_group.setObjectName("groupBox") + input_layout = QVBoxLayout(self.input_group) + # The input panel is the live image plus its controls. It stays bounded + # here: the dialog has four sections, and a preview that expands would push + # the bundle, session and recording settings below the fold on every screen. + source_panel.setMaximumHeight(320) + source_panel.preview.setMinimumSize(140, 90) + input_layout.addWidget(source_panel) + self.input_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum + ) + layout.addWidget(self.input_group) + + self.bundle_group = QGroupBox(page) + self.bundle_group.setObjectName("groupBox") + bundle_row = QHBoxLayout(self.bundle_group) + self.bundle_label = QLabel(t("cap.settings.bundle_none"), self.bundle_group) + self.bundle_label.setWordWrap(True) + self.bundle_label.setObjectName("hintText") + self.bundle_button = QPushButton(self.bundle_group) + self.bundle_button.setObjectName("captureLoadBundle") + self.bundle_button.clicked.connect(self.bundleRequested) + bundle_row.addWidget(self.bundle_label, 1) + bundle_row.addWidget(self.bundle_button) + layout.addWidget(self.bundle_group) + + self.session_group = QGroupBox(page) + self.session_group.setObjectName("groupBox") + session_grid = QGridLayout(self.session_group) + session_grid.setHorizontalSpacing(12) + session_grid.setVerticalSpacing(8) + self.model_label = QLabel(self.session_group) + self.sensor_model = QComboBox(self.session_group) + self.sensor_model.setObjectName("captureSensorModel") + self.sensor_model.addItems(list(PRODUCT_MODELS)) + self.sn_label = QLabel(self.session_group) + self.sensor_sn = QLineEdit(self.session_group) + self.sensor_sn.setObjectName("captureSensorSn") + self.output_label = QLabel(self.session_group) + self.output_path = QLineEdit(str(Path(outputs_dir()) / "calibrations"), self.session_group) + self.output_path.setObjectName("captureOutputPath") + self.output_browse = QPushButton(self.session_group) + self.frame_label = QLabel(self.session_group) + self.frame = QLineEdit("sensor", self.session_group) + self.frame.setObjectName("captureCoordinateFrame") + self.max_force_label = QLabel(self.session_group) + self.max_force = QLineEdit(self.session_group) + self.max_force.setObjectName("captureMaxForce") + self.min_force_label = QLabel(self.session_group) + self.min_force = QLineEdit(self.session_group) + self.min_force.setObjectName("captureMinForce") + for row, (label, field) in enumerate( + ( + (self.model_label, self.sensor_model), + (self.sn_label, self.sensor_sn), + (self.output_label, self.output_path), + (self.frame_label, self.frame), + (self.max_force_label, self.max_force), + (self.min_force_label, self.min_force), + ) + ): + session_grid.addWidget(label, row, 0) + session_grid.addWidget(field, row, 1) + session_grid.addWidget(self.output_browse, 2, 2) + self.simulate = QCheckBox(self.session_group) + self.simulate.setObjectName("captureSimulate") + session_grid.addWidget(self.simulate, 6, 0, 1, 3) + layout.addWidget(self.session_group) + + self.recording_group = QGroupBox(page) + self.recording_group.setObjectName("groupBox") + recording_layout = QGridLayout(self.recording_group) + recording_layout.setHorizontalSpacing(12) + recording_layout.setVerticalSpacing(6) + self.video_check = QCheckBox(self.recording_group) + self.video_check.setObjectName("captureRecordVideo") + self.codec_label = QLabel(self.recording_group) + self.codec = QComboBox(self.recording_group) + self.codec.setObjectName("captureCodec") + self.lossless_check = QCheckBox(self.recording_group) + self.lossless_check.setObjectName("captureLossless") + self.codec_reason = QLabel(self.recording_group) + self.codec_reason.setWordWrap(True) + self.codec_reason.setObjectName("hintText") + self.estimate = QLabel(self.recording_group) + self.estimate.setWordWrap(True) + self.estimate.setObjectName("hintText") + self.locked_label = QLabel(self.recording_group) + self.locked_label.setWordWrap(True) + self.locked_label.setObjectName("hintText") + recording_layout.addWidget(self.video_check, 0, 0, 1, 2) + recording_layout.addWidget(self.codec_label, 1, 0) + recording_layout.addWidget(self.codec, 1, 1) + recording_layout.addWidget(self.lossless_check, 2, 0, 1, 2) + recording_layout.addWidget(self.codec_reason, 3, 0, 1, 2) + recording_layout.addWidget(self.estimate, 4, 0, 1, 2) + recording_layout.addWidget(self.locked_label, 5, 0, 1, 2) + layout.addWidget(self.recording_group) + layout.addStretch(1) + + self.buttons = QDialogButtonBox(self) + self.close_button = self.buttons.addButton( + t("cap.settings.close"), QDialogButtonBox.ButtonRole.AcceptRole + ) + self.close_button.setObjectName("captureSettingsClose") + outer.addWidget(self.buttons) + + self.close_button.clicked.connect(self.close) + self.output_browse.clicked.connect(self._choose_output) + self.bundle_button.clicked.connect(self._choose_bundle) + self.video_check.toggled.connect(self._on_recording_changed) + self.codec.currentIndexChanged.connect(self._on_recording_changed) + self.lossless_check.toggled.connect(self._on_recording_changed) + self.output_path.textChanged.connect(self._refresh_estimate) + self.sensor_sn.textChanged.connect(self._refresh_estimate) + self.simulate.toggled.connect(self._refresh_estimate) + self.set_availability(probe_once(), error="") + self._load_preferences() + self.retranslate() + + # ------------------------------------------------------------- preferences + + def _load_preferences(self) -> None: + block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None + self._apply_options(RecordingOptions.from_block(block)) + + def _remember(self) -> None: + if not isinstance(self.prefs, dict): + return + # Only the recording block is a preference: the sensor SN, the frame name + # and the maximum force belong to the session being set up, not to the + # operator's stored UI state, so they are not carried across restarts. + self.prefs["recording"] = self.recording_options().as_block() + + # ------------------------------------------------------------------ values + + @property + def availability(self) -> dict[str, bool] | None: + """The probed codec result the start path must honour (``None``: unprobed).""" + return None if self._availability is None else dict(self._availability) + + def recording_options(self) -> RecordingOptions: + codec = self.codec.currentData() + return RecordingOptions( + video=self.video_check.isChecked(), + codec=str(codec or CODEC_CANDIDATES[0]), + lossless_frames=self.lossless_check.isChecked(), + ) + + def _apply_options(self, options: RecordingOptions) -> None: + self.video_check.setChecked(bool(options.video)) + index = self.codec.findData(options.codec) + # A stored codec this machine cannot write must not become the selection: + # ``set_availability`` already fell back to one that works, and re-selecting + # the disabled entry would leave the operator pointing at a codec the start + # path is about to refuse. + if index >= 0 and self._codec_usable(index): + self.codec.setCurrentIndex(index) + self.lossless_check.setChecked(bool(options.lossless_frames)) + + def _codec_usable(self, index: int) -> bool: + item = self.codec.model().item(index) + return item is None or item.isEnabled() + + def session_fields(self) -> dict: + """The session parameters as the page reads them when it starts a session.""" + return { + "sensor_model": self.sensor_model.currentText(), + "sensor_sn": self.sensor_sn.text().strip(), + "root": self.output_path.text().strip(), + "coordinate_frame": self.frame.text().strip(), + "max_force_n": self.max_force.text().strip(), + "min_force_n": self.min_force.text().strip(), + "simulate": self.simulate.isChecked(), + } + + # ------------------------------------------------------------------- probe + + def set_availability(self, availability: dict[str, bool] | None, *, error: str = "") -> None: + """Offer only the codecs this machine can actually encode with.""" + self._availability = None if availability is None else dict(availability) + self._probe_error = str(error or "") + self.codec.blockSignals(True) + try: + self.codec.clear() + for codec in CODEC_CANDIDATES: + label = codec + available = True + if self._availability is not None: + available = self._availability.get(codec) is True + if not available: + label = t("cap.recording.unavailable", codec=codec) + self.codec.addItem(label, codec) + item = self.codec.model().item(self.codec.count() - 1) + if item is not None: + item.setEnabled(available) + wanted = str(self._stored_codec() or CODEC_CANDIDATES[0]) + index = self.codec.findData(wanted) + if index < 0: + index = 0 + self.codec.setCurrentIndex(index) + # The stored codec can be one this machine no longer opens. Offer the + # first available one instead, and keep the reason above visible: the + # operator must not be left with a disabled selection and no way out. + for candidate in range(self.codec.count()): + item = self.codec.model().item(candidate) + if item is not None and item.isEnabled(): + self.codec.setCurrentIndex(candidate) + break + finally: + self.codec.blockSignals(False) + self.codec.setToolTip( + "\n".join( + t("cap.recording.unavailable", codec=codec) + for codec, ok in (self._availability or {}).items() + if not ok + ) + ) + self._refresh_estimate() + + def _stored_codec(self) -> str: + block = self.prefs.get("recording") if isinstance(self.prefs, dict) else None + if isinstance(block, dict) and isinstance(block.get("codec"), str): + return block["codec"] + return CODEC_CANDIDATES[0] + + def _on_recording_changed(self) -> None: + self._remember() + self._refresh_estimate() + self.recordingChanged.emit() + + # ------------------------------------------------------------------ estimate + + def set_plan(self, seconds: float) -> None: + """How long the queue is planned to record for, in seconds.""" + self._planned_seconds = max(0.0, float(seconds)) + self._refresh_estimate() + + def _disk_root(self) -> Path: + text = self.output_path.text().strip() + candidate = Path(text) if text else Path.cwd() + while not candidate.exists() and candidate != candidate.parent: + candidate = candidate.parent + return candidate if candidate.exists() else Path.cwd() + + def _refresh_estimate(self) -> None: + options = self.recording_options() + if not options.video: + text = t("cap.recording.off") + if options.lossless_frames: + text += "\n" + t("cap.recording.lossless") + self.estimate.setText(text) + return + if self._planned_seconds <= 0: + self.estimate.setText(t("cap.recording.no_plan")) + return + codec = str(options.codec) + mbit = CODEC_MBIT_PER_S.get(codec, DEFAULT_MBIT_PER_S) + size = mbit * 1e6 / 8.0 * self._planned_seconds + lines = [] + try: + free = shutil.disk_usage(self._disk_root()).free + except OSError: + free = None + if free is None: + lines.append( + t("cap.recording.estimate", size=format_size(size), + seconds=format_duration(self._planned_seconds), free="—") + ) + else: + lines.append( + t("cap.recording.estimate", size=format_size(size), + seconds=format_duration(self._planned_seconds), free=format_size(free)) + ) + if free < 2.0 * size: + lines.append(t("cap.recording.disk_short")) + self.estimate.setText("\n".join(lines)) + + # -------------------------------------------------------------------- state + + def set_locked(self, locked: bool) -> None: + """A started session locks its recording and session parameters.""" + if bool(locked) == self._locked and self.locked_label.isVisible() == self._locked: + return + self._locked = bool(locked) + for widget in ( + self.input_group, self.bundle_button, self.session_group, + self.sensor_model, self.sensor_sn, self.output_path, self.output_browse, + self.frame, self.max_force, self.min_force, self.simulate, + self.video_check, self.codec, self.lossless_check, + ): + widget.setEnabled(not self._locked) + self.locked_label.setText(t("cap.recording.locked") if self._locked else "") + self.locked_label.setVisible(self._locked) + + def set_bundle(self, path: str) -> None: + text = str(path or "").strip() + self.bundle_label.setText(text or t("cap.settings.bundle_none")) + self.bundle_label.setToolTip(text) + + def refresh_sizes(self) -> None: + """Re-read free space; opening the dialog again refreshes the estimate.""" + self._refresh_estimate() + + def _choose_output(self) -> None: + path = QFileDialog.getExistingDirectory(self, t("calibration.output"), self.output_path.text()) + if path: + self.output_path.setText(path) + + def _choose_bundle(self) -> None: + self.bundleRequested.emit() + + # -------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.setWindowTitle(t("cap.settings.title")) + self.input_group.setTitle(t("cap.settings.input")) + self.bundle_group.setTitle(t("cap.settings.bundle")) + self.bundle_button.setText(t("cap.settings.bundle_load")) + self.session_group.setTitle(t("cap.settings.session")) + self.model_label.setText(t("calibration.sensor_model")) + self.sn_label.setText(t("calibration.sensor_sn")) + self.output_label.setText(t("calibration.output")) + self.output_browse.setText(t("source.browse")) + self.frame_label.setText(t("multipoint.frame")) + self.max_force_label.setText(t("multipoint.max_force")) + self.max_force.setPlaceholderText(t("cap.settings.max_required")) + self.min_force_label.setText(t("multipoint.min_force")) + self.min_force.setPlaceholderText(t("multipoint.min_placeholder")) + self.simulate.setText(t("cap.settings.simulate")) + self.recording_group.setTitle(t("cap.recording.title")) + self.video_check.setText(t("cap.recording.video")) + self.codec_label.setText(t("cap.recording.codec")) + self.lossless_check.setText(t("cap.recording.lossless")) + if self._probe_error: + self.codec_reason.setText(t("cap.recording.probe_failed", detail=self._probe_error)) + else: + self.codec_reason.setText( + "\n".join( + t("cap.recording.unavailable", codec=codec) + for codec, ok in (self._availability or {}).items() + if not ok + ) + ) + self.close_button.setText(t("cap.settings.close")) + self._refresh_estimate() + + def showEvent(self, event) -> None: # noqa: N802 - Qt override + super().showEvent(event) + self._refresh_estimate() + + def closeEvent(self, event) -> None: # noqa: N802 - Qt override + self._remember() + super().closeEvent(event) diff --git a/apps/autocal/src/orisys_autocal/ui/live_uv_view.py b/apps/autocal/src/orisys_autocal/ui/live_uv_view.py new file mode 100644 index 0000000..6acf2fa --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/live_uv_view.py @@ -0,0 +1,112 @@ +"""The processed camera image, shown beside the UV map on the capture page. + +The capture page needs the live 2-D UV image and the pickable UV map *at the same +time*: the operator confirms a point's position on the map and watches the same +frame the recorder sees. This widget therefore reads the already-running input +worker; it never starts, stops or reconfigures it — the input source panel in the +settings dialog owns that. + +Multiple readers of ``worker.latest()`` are allowed: the input panel keeps its own +copy for the settings dialog, and this view keeps one here. +""" + +from __future__ import annotations + +import numpy as np +from orisys_app_shared.i18n import t +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QImage, QPixmap +from PySide6.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget + + +class LiveUvView(QWidget): + """Show the latest processed frame and the identity of the packet behind it.""" + + def __init__(self, source_panel: QWidget, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.source_panel = source_panel + self._identity: tuple[int, int] | None = None + self._image: QImage | None = None + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + self.preview = QLabel(self) + self.preview.setObjectName("liveUvPreview") + self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter) + # No minimum of its own: the image scales to whatever room the row gives + # it, and a minimum would overflow the row the moment the page is squeezed. + self.preview.setMinimumSize(1, 1) + self.preview.setSizePolicy( + # The image scales to whatever room the row gives it; a minimum that + # honoured the frame's own size would push the page past the window. + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Ignored, + ) + self.status = QLabel(self) + self.status.setWordWrap(True) + self.status.setObjectName("hintText") + layout.addWidget(self.preview, 1) + layout.addWidget(self.status) + self._timer = QTimer(self) + self._timer.setInterval(100) + self._timer.timeout.connect(self.refresh) + self._timer.start() + self.retranslate() + self.refresh() + + def retranslate(self) -> None: + if self._image is None: + self.preview.setText(t("source.no_frame")) + self.refresh() + + def refresh(self) -> None: + worker = getattr(self.source_panel, "worker", None) + packet = None if worker is None else worker.latest() + if packet is not None: + identity = (int(packet.session_id), int(packet.frame_id)) + if identity != self._identity: + self._identity = identity + self._show(packet.processed_frame) + running = bool(getattr(worker, "is_running", False)) + if packet is None: + # The preview already says there is no frame; the line below it says + # whether the input is running, which is the fact an operator needs. + self.status.setText(t("source.running") if running else t("source.stopped")) + return + self.status.setText( + t("source.frame").format( + frame=packet.frame_id, + source_frame=getattr(packet, "source_frame_id", None) or "–", + basis=t("source.basis_unknown"), + time="—", + dropped=getattr(packet, "capture_dropped", 0), + ) + ) + + def _show(self, frame) -> None: + data = np.asarray(frame) + if data.ndim != 3 or data.shape[2] != 3: + return + height, width = data.shape[:2] + self._image = QImage( + data.data, width, height, int(data.strides[0]), QImage.Format.Format_BGR888 + ).copy() + self._scale() + + def _scale(self) -> None: + if self._image is None: + return + self.preview.setPixmap( + QPixmap.fromImage(self._image).scaled( + self.preview.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + def resizeEvent(self, event) -> None: # noqa: N802 - Qt override + super().resizeEvent(event) + self._scale() + + def shutdown(self) -> None: + self._timer.stop() diff --git a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py b/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py deleted file mode 100644 index 60bc68e..0000000 --- a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py +++ /dev/null @@ -1,746 +0,0 @@ -"""02 multi-point acquisition. Manual loads only; this page never moves the platform.""" - -from __future__ import annotations - -import numpy as np -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QComboBox, - QGridLayout, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, - QSizePolicy, - QSpinBox, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) -from orisys_app_shared.i18n import t - -from ..multipoint import Campaign, next_point_id -from ..sensor.frames import machine_table_xy -from ..sensor.uv_flat import site_at - -#: A stored point and the mapping's answer for the same UV are the same point when -#: they agree this closely. Further apart means the map, the bundle or the mounting -#: moved under the record, and the point has to be taken again rather than retargeted. -_POINT_MATCH_MM = 0.05 -_NORMAL_MATCH = 1e-3 - - -class _WrappingLabel(QLabel): - """Keep wrapped text inside its own row instead of painting over the next control.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setWordWrap(True) - self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) - - def resizeEvent(self, event) -> None: - super().resizeEvent(event) - height = self.heightForWidth(self.width()) - if height > 0 and self.minimumHeight() != height: - self.setMinimumHeight(height) - self.updateGeometry() - - -class MultipointPanel(QWidget): - """Add points, then record them in order after each load is confirmed by hand.""" - - alignRequested = Signal(object) - - def __init__(self, host: QWidget, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.host = host - self.campaign = Campaign() - self._selected = "" - self._offered_liftoff = "" - self._finger = None - - outer = QVBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.Shape.NoFrame) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - outer.addWidget(scroll) - page = QWidget(scroll) - page.setMinimumWidth(560) - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(12) - scroll.setWidget(page) - - self.hint = _WrappingLabel(page) - layout.addWidget(self.hint) - - self.site_id = QLineEdit(page) - self.repeats = QSpinBox(page) - self.repeats.setRange(1, 99) - self.repeats.setValue(1) - self.repeats.setMinimumWidth(160) - self.frame = QLineEdit(page) - self.max_force = QLineEdit(page) - self.min_force = QLineEdit(page) - self.id_label = QLabel(page) - self.repeats_label = QLabel(page) - self.frame_label = QLabel(page) - self.force_label = QLabel(page) - self.min_label = QLabel(page) - form = QGridLayout() - form.setHorizontalSpacing(16) - form.setVerticalSpacing(10) - form.setColumnMinimumWidth(0, 156) - form.setColumnStretch(1, 1) - fields = ( - (self.id_label, self.site_id), - (self.repeats_label, self.repeats), - (self.frame_label, self.frame), - (self.min_label, self.min_force), - (self.force_label, self.max_force), - ) - for row, (label, field) in enumerate(fields): - label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) - form.addWidget(label, row, 0) - form.addWidget(field, row, 1) - layout.addLayout(form) - - buttons = QHBoxLayout() - buttons.setSpacing(8) - self.add_button = QPushButton(page) - self.update_button = QPushButton(page) - self.remove_button = QPushButton(page) - self.up_button = QPushButton(page) - self.down_button = QPushButton(page) - for button in ( - self.add_button, self.update_button, self.remove_button, - self.up_button, self.down_button, - ): - buttons.addWidget(button, 1) - layout.addLayout(buttons) - extra = QHBoxLayout() - extra.setSpacing(8) - self.align_button = QPushButton(page) - self.reacquire_button = QPushButton(page) - for button in (self.align_button, self.reacquire_button): - extra.addWidget(button, 1) - layout.addLayout(extra) - - self.table = QTableWidget(0, 8, page) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) - self.table.setMinimumHeight(160) - self.table.verticalHeader().setVisible(False) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.itemSelectionChanged.connect(self._on_select) - layout.addWidget(self.table, 1) - - self.progress = _WrappingLabel(page) - self.save_status = _WrappingLabel(page) - self.capture_status = _WrappingLabel(page) - self.capture_status.setObjectName("multipointCapture") - layout.addWidget(self.progress) - layout.addWidget(self.save_status) - layout.addWidget(self.capture_status) - self.pose_status = _WrappingLabel(page) - layout.addWidget(self.pose_status) - - self.source_label = QLabel(page) - self.source_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self.source_label.setMinimumWidth(156) - self.source = QComboBox(page) - self.source.addItem("", "weight") - self.source.addItem("", "wrench") - source_row = QHBoxLayout() - source_row.setSpacing(16) - source_row.addWidget(self.source_label) - source_row.addWidget(self.source, 1) - layout.addLayout(source_row) - - self.wrench_box = QWidget(page) - wrench = QGridLayout(self.wrench_box) - wrench.setContentsMargins(0, 0, 0, 0) - wrench.setHorizontalSpacing(8) - wrench.setVerticalSpacing(8) - self.fx_label = QLabel(self.wrench_box) - self.fy_label = QLabel(self.wrench_box) - self.fz_label = QLabel(self.wrench_box) - self.fx_edit = QLineEdit(self.wrench_box) - self.fy_edit = QLineEdit(self.wrench_box) - self.fz_edit = QLineEdit(self.wrench_box) - self.rotation_label = QLabel(self.wrench_box) - self.rotation = QLineEdit(self.wrench_box) - wrench.addWidget(self.fx_label, 0, 0) - wrench.addWidget(self.fx_edit, 0, 1) - wrench.addWidget(self.fy_label, 0, 2) - wrench.addWidget(self.fy_edit, 0, 3) - wrench.addWidget(self.fz_label, 0, 4) - wrench.addWidget(self.fz_edit, 0, 5) - wrench.addWidget(self.rotation_label, 1, 0) - wrench.addWidget(self.rotation, 1, 1, 1, 5) - layout.addWidget(self.wrench_box) - - actions = QGridLayout() - actions.setHorizontalSpacing(8) - actions.setVerticalSpacing(8) - self.simulate_button = QPushButton(page) - self.start_button = QPushButton(page) - self.liftoff_button = QPushButton(page) - self.remeasure_button = QPushButton(page) - self.stop_button = QPushButton(page) - actions.addWidget(self.simulate_button, 0, 0) - actions.addWidget(self.start_button, 0, 1) - actions.addWidget(self.liftoff_button, 1, 0, 1, 2) - actions.addWidget(self.remeasure_button, 2, 0, 1, 2) - actions.addWidget(self.stop_button, 3, 0, 1, 2) - actions.setColumnStretch(0, 1) - actions.setColumnStretch(1, 1) - layout.addLayout(actions) - self.message = _WrappingLabel(page) - layout.addWidget(self.message) - - self.add_button.clicked.connect(self._add) - self.update_button.clicked.connect(self._update) - self.remove_button.clicked.connect(self._remove) - self.up_button.clicked.connect(lambda: self._move(-1)) - self.down_button.clicked.connect(lambda: self._move(1)) - self.align_button.clicked.connect(self._align) - self.reacquire_button.clicked.connect(self._reacquire) - self.source.currentIndexChanged.connect(lambda _index: self._refresh()) - self.simulate_button.clicked.connect(lambda: self._begin(simulate=True)) - self.start_button.clicked.connect(lambda: self._begin(simulate=False)) - self.liftoff_button.clicked.connect(self._liftoff) - self.remeasure_button.clicked.connect(self._remeasure) - self.stop_button.clicked.connect(self._stop) - self._timer = QTimer(self) - self._timer.setInterval(200) - self._timer.timeout.connect(self._poll) - self._timer.start() - self._listed: tuple = () - self._summary_published_for = None - self.retranslate() - self.site_id.setText(self._next_site_id()) - self.frame.setText("sensor") - self._refresh() - - @property - def is_active(self) -> bool: - return self.campaign.is_active - - def bind_finger(self, panel) -> None: - """The finger page whose picked points and solved poses this tab records.""" - self._finger = panel - - def shutdown(self) -> None: - self._timer.stop() - if self.campaign.is_active: - self.campaign.stop("application closed") - # Bounded only at close time: the window cannot wait forever, and a - # writer that misses the deadline leaves an explicitly incomplete repeat. - self.campaign.flush_close() - - def retranslate(self) -> None: - self.hint.setText(t("multipoint.hint")) - self.id_label.setText(t("multipoint.site_id")) - self.repeats_label.setText(t("multipoint.repeats")) - self.frame_label.setText(t("multipoint.frame")) - self.force_label.setText(t("multipoint.max_force")) - self.min_label.setText(t("multipoint.min_force")) - self.frame.setPlaceholderText(t("multipoint.frame_placeholder")) - self.max_force.setPlaceholderText(t("multipoint.force_placeholder")) - self.min_force.setPlaceholderText(t("multipoint.min_placeholder")) - self.add_button.setText(t("multipoint.add")) - self.update_button.setText(t("multipoint.update")) - self.remove_button.setText(t("multipoint.remove")) - self.up_button.setText(t("multipoint.up")) - self.down_button.setText(t("multipoint.down")) - self.align_button.setText(t("multipoint.align")) - self.reacquire_button.setText(t("multipoint.reacquire")) - self.source_label.setText(t("multipoint.source")) - self.source.setItemText(0, t("multipoint.source_weight")) - self.source.setItemText(1, t("multipoint.source_wrench")) - self.fx_label.setText(t("multipoint.fx")) - self.fy_label.setText(t("multipoint.fy")) - self.fz_label.setText(t("multipoint.fz")) - self.rotation_label.setText(t("multipoint.rotation")) - self.rotation.setPlaceholderText(t("multipoint.rotation_placeholder")) - self.simulate_button.setText(t("multipoint.simulate")) - self.start_button.setText(t("multipoint.start")) - self.liftoff_button.setText(t("multipoint.liftoff")) - self.remeasure_button.setText(t("multipoint.remeasure")) - self.remeasure_button.setToolTip(t("multipoint.remeasure_ready")) - self.stop_button.setText(t("multipoint.stop")) - self.table.setHorizontalHeaderLabels([ - t("multipoint.col_id"), t("multipoint.col_x"), t("multipoint.col_y"), - t("multipoint.col_z"), t("multipoint.col_levels"), t("multipoint.col_hold"), - t("multipoint.col_repeats"), t("multipoint.col_done"), - ]) - self._refresh() - - def _next_site_id(self) -> str: - return next_point_id([site.site_id for site in self.campaign.sites]) - - def _levels(self) -> list[float]: - return [float(part.strip()) for part in self.host.levels.text().split(",") if part.strip()] - - def _hold(self) -> float: - return float(self.host.hold.value()) - - def _solver(self): - finger = self._finger - stack = None if finger is None else getattr(finger, "stack", None) - if stack is None: - raise ValueError(t("multipoint.no_solver")) - return stack - - @staticmethod - def _pose_of(solved, stack) -> dict: - """The record of one solve: the targets, and what the solver said of them.""" - move_x, move_y = machine_table_xy(solved.table_xy_mm) - return { - "x": float(move_x), - "y": float(move_y), - "yaw": float(solved.yaw_deg), - "pitch": float(solved.pitch_deg), - "z": float(solved.contact_z_mm), - "residual_deg": float(solved.residual_deg), - "upright": bool(solved.upright), - "in_range": bool(solved.ok and solved.in_range), - "ok": bool(solved.ok), - "normal_aligned_to": f"tool_axis:{stack.tool_axis}", - "reason": str(solved.reason or ""), - } - - def _solve_uv(self, u: float, v: float): - """Pose for the point currently chosen on 选择测点. Does not move the platform.""" - flat = getattr(self.host, "_flat", None) - if flat is None: - raise ValueError(t("multipoint.no_map")) - site = site_at(flat, int(round(u)), int(round(v)), origin=getattr(self.host, "_origin", None)) - sample = flat.sample(*site.raster_xy_px) - if sample is None: - raise ValueError(t("multipoint.mesh_required")) - point, normal, _face = sample - stack = self._solver() - solved = stack.solve( - np.asarray(point, dtype=np.float64), - np.asarray(normal, dtype=np.float64), - ) - length = float(np.linalg.norm(normal)) - unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple( - float(value) for value in normal - ) - return ( - (float(point[0]), float(point[1]), float(point[2])), - unit, - self._pose_of(solved, stack), - (float(site.u_px), float(site.v_px)), - ) - - def _solve_point(self, point, normal) -> dict: - """Pose for a recorded sensor-frame point, against the geometry in force now.""" - stack = self._solver() - solved = stack.solve( - np.asarray(point, dtype=np.float64), - np.asarray(normal, dtype=np.float64), - ) - return self._pose_of(solved, stack) - - def _solve_current(self): - return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value())) - - def _store_pose(self, site_id: str, point, normal, pose, uv) -> None: - site = next(item for item in self.campaign.sites if item.site_id == site_id) - site.x, site.y, site.z = point - site.normal = normal - site.pose = pose - site.uv_px = uv - site.position_frame = "sensor" - - def _store_solved_pose(self, site_id: str, pose: dict) -> None: - """Record a fresh solve of a point without retaking the point itself.""" - self.campaign.store_solved_pose(site_id, pose) - - def _require_same_point(self, site, point, normal) -> None: - """Refuse a point the current map no longer puts where the record says. - - A re-solve is allowed to answer differently; it is not allowed to move the - point quietly. The recorded position is what a session's rows are - attributed to, so a changed map, bundle or mounting has to reach the - operator as "take this point again", never as a silently retargeted move. - """ - if site.position_frame != "sensor" or site.normal is None: - return - stored = np.asarray((site.x, site.y, site.z), dtype=np.float64) - moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored)) - if moved > _POINT_MATCH_MM: - raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}")) - dot = float(np.dot( - np.asarray(normal, dtype=np.float64), - np.asarray(site.normal, dtype=np.float64), - )) - turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0)))) - if turned > _NORMAL_MATCH: - raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}")) - - def _resolve_site(self, site) -> dict: - """The pose to align now, solved against the geometry in force now. - - ``site.pose`` records what the solver said when the point was taken. The - mounting offsets, the mounting angle, the tool position and the travel - limits are all editable afterwards, so a stored pose can name a target - this machine no longer reaches — or reach the same point a different way. - The closed form is cheap and stateless, so this asks it again instead of - keeping a cache and a rule for when to distrust it. - """ - if site.uv_px is not None: - point, normal, pose, _uv = self._solve_uv(*site.uv_px) - self._require_same_point(site, point, normal) - self._store_solved_pose(site.site_id, pose) - return pose - if site.normal is None or site.position_frame != "sensor": - raise ValueError(t("multipoint.point_unmapped")) - pose = self._solve_point((site.x, site.y, site.z), site.normal) - self._store_solved_pose(site.site_id, pose) - return pose - - def _pose_for_display(self, site): - """What the status line shows: solved now, or the record when that fails. - - The line is read before a move, so it has to answer for the geometry in - force now; a stored pose the current mounting no longer reaches would - otherwise read as reachable right up to the refusal. - """ - if site is None: - return None - if self.campaign.is_active: - return site.pose - try: - return self._resolve_site(site) - except (OSError, RuntimeError, ValueError): - return site.pose - - def _max_force(self) -> float: - text = self.max_force.text().strip() - if not text: - raise ValueError(t("multipoint.force_required")) - return float(text) - - def _min_force(self) -> float: - text = self.min_force.text().strip() - return 0.0 if not text else float(text) - - def _add(self) -> None: - def go(): - if not self.site_id.text().strip(): - self.site_id.setText(self._next_site_id()) - point, normal, pose, uv = self._solve_current() - self.campaign.add_solved_site( - self.site_id.text(), point[0], point[1], point[2], normal, pose, - self._levels(), self._hold(), self.repeats.value(), - max_force_n=self._max_force(), min_force_n=self._min_force(), uv_px=uv, - ) - self.table.clearSelection() - self._selected = "" - self.site_id.setText(self._next_site_id()) - self._run(go) - - def _update(self) -> None: - if not self._selected: - return - def go(): - point, normal, pose, uv = self._solve_current() - new_id = self.site_id.text().strip() - self.campaign.update_site( - self._selected, point[0], point[1], point[2], self._levels(), self._hold(), - self.repeats.value(), max_force_n=self._max_force(), new_id=new_id, - ) - self._store_pose(new_id or self._selected, point, normal, pose, uv) - self._run(go) - - def _remove(self) -> None: - if self._selected: - self._run(lambda: self.campaign.remove_site(self._selected)) - - def _move(self, delta: int) -> None: - if self._selected: - self._run(lambda: self.campaign.move_site(self._selected, delta)) - - def _begin(self, *, simulate: bool) -> None: - def go(): - if self.host.trial.status in {"recording", "closing"}: - raise RuntimeError(t("multipoint.finger_busy")) - self._summary_published_for = None - if not simulate: - worker = self.host.source_panel.worker - packet = worker.latest() - mode = getattr(self.host.source_panel, "mode", None) - if not worker.is_running or packet is None or mode is None: - raise RuntimeError(t("multipoint.camera_required")) - directory = self.campaign.start( - self.host.output_path.text().strip(), - sensor_model=self.host.sensor_model.currentText(), - sensor_sn=self.host.sensor_sn.text(), - coordinate_frame=self.frame.text().strip() or "sensor", - max_force_n=self._max_force(), - min_force_n=self._min_force(), - simulate=simulate, - ) - if not simulate: - try: - self.campaign.attach_capture( - self.host.source_panel.worker, - fps=float(self.host.source_panel.mode.fps), - ) - except (OSError, RuntimeError, ValueError) as exc: - # A repeat that cannot record its frames must not be offered - # for confirmation: stop it rather than collect unattached - # holds that would look complete. - self.campaign.stop(str(exc)) - raise - self.message.setText(str(directory)) - self._run(go) - - def _confirm(self) -> None: - if self.source.currentData() == "wrench": - parts = [part for part in self.rotation.text().replace(",", " ").split() if part] - self._run(lambda: ( - self.campaign.set_force_rotation([float(part) for part in parts]), - self.campaign.confirm_wrench( - float(self.fx_edit.text()), float(self.fy_edit.text()), float(self.fz_edit.text()), - ), - )) - return - self._run(lambda: self.campaign.confirm_reference(float(self.host.reference.text()))) - - def confirm_loaded(self) -> None: - """The right-hand confirm button records the current multi-point step.""" - self._confirm() - - def _can_align(self) -> bool: - return bool(self._selected) and ( - not self.campaign.is_active - or self._selected == self.campaign.pending_alignment_site - ) - - def _align(self) -> None: - if not self._selected: - self.message.setText(t("multipoint.align_select")) - return - if not self._can_align(): - # Moving the platform in the middle of a run would invalidate the trial - # being recorded, so a second point waits until this one is finished. - self.message.setText(t("multipoint.align_busy")) - return - def go(): - site = next(item for item in self.campaign.sites if item.site_id == self._selected) - # Solved, not looked up: the stored pose may predate any of the - # mounting, tool or travel settings the operator has changed since. - pose = self._resolve_site(site) - if not pose.get("ok") or not pose.get("in_range"): - raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or "")) - self.alignRequested.emit(self.campaign.command_targets(self._selected)) - self._run(go) - - def _reacquire(self) -> None: - if self._selected: - self._summary_published_for = None - self._run(lambda: self.campaign.reacquire(self._selected)) - - def _liftoff(self) -> None: - self._run(self.campaign.confirm_liftoff) - - def _remeasure(self) -> None: - def go(): - self.campaign.remeasure_step() - self.message.setText(t("multipoint.remeasure_ready")) - self._run(go) - - def _stop(self) -> None: - self.campaign.stop(t("multipoint.stopped_reason")) - self._refresh() - - def _poll(self) -> None: - try: - self.campaign.poll() - except (OSError, RuntimeError, ValueError) as exc: - self.message.setText(t("calibration.error").format(detail=exc)) - if self.campaign.is_active: - self.campaign.stop(str(exc)) - if self.campaign.phase == "hold" and self.campaign.remaining_s() <= 0: - try: - self.campaign.complete_hold() - except (OSError, RuntimeError, ValueError) as exc: - self.message.setText(t("calibration.error").format(detail=exc)) - if self.campaign.is_active: - self.campaign.stop(str(exc)) - self._publish_summary_when_finished() - self._refresh() - - def _publish_summary_when_finished(self) -> None: - """The candidate export starts when the campaign has closed its evidence.""" - directory = self.campaign.directory - if directory is None or self.campaign.phase not in {"done", "aborted"}: - return - if self.campaign.capture_state == "closing": - return - if directory == self._summary_published_for: - return - if self.host.publish_summary_for(directory): - self._summary_published_for = directory - - def _run(self, action) -> None: - try: - action() - except (OSError, RuntimeError, ValueError) as exc: - self.message.setText(t("calibration.error").format(detail=exc)) - self._refresh() - - def _on_select(self) -> None: - rows = self.table.selectionModel().selectedRows() - if not rows: - return - site = self.campaign.sites[rows[0].row()] - self._selected = site.site_id - self.site_id.setText(site.site_id) - self.repeats.setValue(site.repeats) - if site.uv_px is not None: - self.host.u_spin.setValue(int(round(site.uv_px[0]))) - self.host.v_spin.setValue(int(round(site.uv_px[1]))) - - def _refresh(self) -> None: - finished = {item["site_id"]: item for item in self.campaign.completion()} - listed = tuple( - ( - site.site_id, site.x, site.y, site.z, site.levels_n, site.hold_s, site.repeats, - finished[site.site_id]["repeats_complete"], - ) - for site in self.campaign.sites - ) - if listed != self._listed: - self._listed = listed - self.table.setRowCount(len(self.campaign.sites)) - for row, site in enumerate(self.campaign.sites): - item = finished[site.site_id] - values = [ - site.site_id, f"{site.x:g}", f"{site.y:g}", f"{site.z:g}", - ", ".join(f"{value:g}" for value in site.levels_n), - f"{site.hold_s:g}", str(site.repeats), - f"{item['repeats_complete']}/{item['repeats_planned']}", - ] - for column, value in enumerate(values): - self.table.setItem(row, column, QTableWidgetItem(value)) - active = self.campaign.is_active - busy = self.campaign.busy - wrench = self.source.currentData() == "wrench" - self.wrench_box.setVisible(wrench) - for widget in ( - self.add_button, self.update_button, self.remove_button, self.up_button, - self.down_button, self.simulate_button, self.start_button, - self.site_id, self.repeats, self.frame, self.max_force, self.min_force, self.source, - ): - widget.setEnabled(not busy) - for widget in (self.fx_edit, self.fy_edit, self.fz_edit, self.rotation): - widget.setEnabled(self.campaign.phase == "load" and wrench) - self.liftoff_button.setEnabled(self.campaign.phase == "liftoff") - self.remeasure_button.setEnabled(self.campaign.can_remeasure_step()) - self.stop_button.setEnabled(active) - self.reacquire_button.setEnabled( - not busy and bool(self._selected) and self.campaign.directory is not None - ) - state = self.campaign.progress() - next_id = state["site_id"] if self.campaign.phase == "liftoff" else "" - if next_id and next_id != self._offered_liftoff: - self._offered_liftoff = next_id - for row, site in enumerate(self.campaign.sites): - if site.site_id == next_id: - self.table.selectRow(row) - break - elif self.campaign.phase != "liftoff": - self._offered_liftoff = "" - self.align_button.setEnabled(self._can_align()) - branch = { - "baseline": t("multipoint.branch_baseline"), - "load": t("multipoint.branch_load"), - "unload": t("multipoint.branch_unload"), - }.get(state["branch"], state["branch"]) - if self.campaign.phase == "liftoff": - self.progress.setText(t("multipoint.liftoff_wait").format( - site=state["site_id"], done=state["done"], total=state["total"], - )) - elif state["phase"] in {"load", "hold"}: - self.progress.setText(t("multipoint.progress").format( - site=state["site_id"], site_index=state["site_index"], - site_count=state["site_count"], branch=branch, - load=state["target_n"], repeat=state["repeat_index"], - repeats=state["repeats"], done=state["done"], total=state["total"], - seconds=self.campaign.remaining_s(), - )) - else: - self.progress.setText(t("multipoint.idle")) - shown = next( - (site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)), - None, - ) - pose = self._pose_for_display(shown) - if pose: - text = t("multipoint.pose").format( - x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"], - z=pose["z"], residual=float(pose.get("residual_deg") or 0.0), - ) - if not pose.get("in_range"): - text += "\n" + t("multipoint.pose_refused").format(reason=pose.get("reason") or "") - self.pose_status.setText(text) - else: - self.pose_status.setText(t("multipoint.no_pose")) - if self.campaign.phase == "done": - save = t("multipoint.save_done") - elif self.campaign.phase == "aborted": - save = t("multipoint.save_kept") - elif active or self.campaign.closing: - save = t("multipoint.save_writing") - else: - save = t("multipoint.save_idle") - self.save_status.setText(t("multipoint.save").format(status=save, path=state["directory"] or "—")) - self.capture_status.setText(self._capture_text(state)) - - def _capture_text(self, state: dict) -> str: - """Where the recording is, and why it is not complete, in one line.""" - capture = state.get("capture") or {} - status = capture.get("state") or "idle" - key = { - "idle": "multipoint.capture_idle", - "recording": "multipoint.capture_recording", - "closing": "multipoint.capture_closing", - "complete": "multipoint.capture_complete", - "incomplete": "multipoint.capture_incomplete", - "not_recorded": "multipoint.capture_not_recorded", - }.get(status, "multipoint.capture_idle") - reason = capture.get("reason") or "" - reason_key = { - "source_restarted": "multipoint.capture_reason_restarted", - "zero_frames": "multipoint.capture_reason_zero_frames", - "write_error": "multipoint.capture_reason_write_error", - "video_missing": "multipoint.capture_reason_video_missing", - "video_undecodable": "multipoint.capture_reason_video_undecodable", - "dropped_data": "multipoint.capture_reason_dropped", - "dropped_video": "multipoint.capture_reason_dropped", - "segment_count": "multipoint.capture_reason_segments", - "closing_timeout": "multipoint.capture_reason_timeout", - "source_stopped": "multipoint.capture_reason_source_stopped", - "not_recorded": "multipoint.capture_reason_not_recorded", - }.get(reason) - return t("multipoint.capture").format( - status=t(key), - frames=int(capture.get("frames") or 0), - dropped=int(capture.get("dropped_data") or 0) + int(capture.get("dropped_video") or 0), - reason=t(reason_key) if reason_key else (reason or "—"), - ) diff --git a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py index e6f7c12..f60d5fb 100644 --- a/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py +++ b/apps/autocal/src/orisys_autocal/ui/record_overview_dialog.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( ) from ..record_overview import load_overview, overview_rows, read_plan, save_overview +from ..recording_options import RecordingOptions from ..session_review import review_session from ..record_diagnostics import trial_diagnostics from ..sensor.uv_outline import recorded_outline @@ -255,7 +256,9 @@ class RecordOverviewDialog(QDialog): model=self.review.get("sensor_model") or "—", sn=self.review.get("sensor_sn") or "—", date=date or "—", source=" / ".join(t("overview.source_" + s) for s in sources) or "—", status=t("overview.capture_in_progress") if self.review["session_status"] == "in_progress" - else t("calibration." + self.review["session_status"]))) + else t("calibration." + self.review["session_status"])) + + ("" if RecordingOptions.from_block(self.review.get("recording")).video + else "\n" + t("overview.video_not_recorded"))) self.identity.setToolTip(self.review["session_id"] + "\n" + str(self.directory)) selected = self.table.currentRow() self.rows = overview_rows(self.review, self.plan) diff --git a/apps/autocal/src/orisys_autocal/ui/reveal.py b/apps/autocal/src/orisys_autocal/ui/reveal.py new file mode 100644 index 0000000..ca08bc5 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/reveal.py @@ -0,0 +1,59 @@ +"""Open a file or folder in the platform's file manager. + +One implementation for the menu's "show config" actions and for the capture page's +session row: the operator has the recording path in front of them and wants the +folder, not a path string to paste. Deliberately Qt-free — it hands the path to +the OS, and a headless test can assert the exact argument the file manager gets. +""" + +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def reveal(path: str | Path | None, select: str | Path | None = None) -> bool: + """Show ``path`` in the platform file manager; return whether it launched. + + On Windows a ``select`` names a file to highlight, which is the + ``explorer /select,`` form. Explorer expects that switch and the quoted + path as one command-line string; passing the two as separate argv entries + breaks on a path containing a space. When the selected file has gone since + the operator clicked, the containing folder is opened instead of failing + silently. + + A missing path, an empty path or a failed launch returns ``False`` and is + logged: this is a convenience action, never a reason to raise into the event + loop that called it. + """ + if path is None or not str(path).strip(): + return False + target = Path(path) + if not target.exists(): + logger.warning("Cannot reveal a path that does not exist: %s", target) + return False + folder = target if target.is_dir() else target.parent + selected = None if select is None else Path(select) + if selected is not None and not selected.exists(): + logger.warning("The file to select is gone; opening its folder instead: %s", selected) + selected = None + command: str | list[str] + if sys.platform == "win32": + if selected is not None: + command = f'explorer /select,"{selected}"' + else: + command = ["explorer", str(folder)] + elif sys.platform == "darwin": # pragma: no cover - Windows is the target + command = ["open", str(folder)] + else: # pragma: no cover - Windows is the target + command = ["xdg-open", str(folder)] + try: + subprocess.Popen(command) + except OSError: + logger.exception("Could not open %s", folder) + return False + return True diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index 095c389..2551a29 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -1,4 +1,9 @@ -"""Finger debug screen: pick a calibration point on the flat image, get a pose. +"""Geometry and mounting screen: pick one calibration point on the flat image. + +The page used to keep a table of picked points and export it. The queue on the +capture page owns points now, so this screen is the single-point trial it always +was underneath: pick a point, read the geometry, watch the simulated pose and the +3D reach preview, and optionally run that one pose. Workflow, with no platform connected: @@ -18,8 +23,6 @@ not. from __future__ import annotations -import csv -import math from pathlib import Path import numpy as np @@ -29,17 +32,13 @@ from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap from PySide6.QtWidgets import ( QCheckBox, QDoubleSpinBox, - QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, - QHeaderView, QLabel, QLineEdit, QPushButton, QSizePolicy, - QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -49,10 +48,7 @@ from ..motion.pose_move import PressAxis, press_targets from ..sensor.export_bundle import SensorBundle from ..sensor.frames import describe as describe_frames from ..sensor.frames import machine_table_xy -from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin - -#: How many picked points the list keeps before the oldest is dropped. -MAX_POINTS = 200 +from ..sensor.uv_flat import FlatSurface, uv_origin #: How close a stored point must be to a raster entry to be shown on the flat #: image. One pixel of the 1024-wide raster is about 0.0147 mm on this export, so @@ -223,31 +219,23 @@ class FlatSurfaceView(QLabel): class FingerPanel(QGroupBox): - """The whole debug screen: bundle, picker, readouts, point list, preview.""" + """Geometry and mounting: bundle, single-point picker, readouts and preview. + + The panel keeps exactly one picked point. A queue of points belongs to the + capture page; this screen is where one point is tried, and where the mounting + numbers that turn a sensor-frame point into a platform pose are entered. + """ loadRequested = Signal() pickRequested = Signal(int, int) stackChanged = Signal() - #: Run the selected point's solved pose on the platform, and stop moving. + #: Run the picked point's solved pose on the platform, and stop moving. executeRequested = Signal() stopRequested = Signal() #: Whether to ask before moving. Persisted; the window honours it for every #: pose move, including the rail's return-to-zero. confirmChanged = Signal(bool) - #: Columns of the point table, minus the leading select checkbox. The first six - #: are the ones an operator may edit — the sensor-frame point and its normal — - #: and everything after them is *solved*, so it is redrawn from the edit rather - #: than typed into. - COLUMNS: tuple[str, ...] = ( - "x", "y", "z", "nx", "ny", "nz", - "X", "Y", "Z", "yaw", "pitch", "residual", "u_px", "v_px", - ) - #: How many of ``COLUMNS`` are inputs rather than solved outputs. - INPUT_COLUMNS = 6 - #: Offset of the first data column; column 0 holds the select checkbox. - SELECT_COLUMN = 0 - def __init__( self, stack: PlatformStack | None = None, @@ -262,13 +250,8 @@ class FingerPanel(QGroupBox): self.setObjectName("groupBox") self._stack = stack or PlatformStack() self._bundle: SensorBundle | None = None - #: Full-precision picked rows, index-aligned with the table. - self._picked: list[dict[str, float]] = [] - #: True while this panel is writing the table itself, so the ``itemChanged`` - #: handler can tell its own redraw from an operator's edit. Without it every - #: solved-column update would be re-read as an edit and re-solve. - self._writing = False - self._headers = self.COLUMNS + #: The one picked point, at full precision. ``None`` until a pick lands. + self._point: dict[str, float] | None = None outer = QVBoxLayout(self) outer.setContentsMargins(10, 8, 10, 10) @@ -289,20 +272,9 @@ class FingerPanel(QGroupBox): column.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) body.addWidget(picker_box, 2) body.addWidget(readout_box, 3) - body.addWidget(preview_box, 5) - # The body outranks the table, because the 3D view is the pane an operator - # actually reads the pose from, and at an even split it came out 240 px tall - # — a box too short to judge a normal in, under three lines of legend. + body.addWidget(preview_box, 6) outer.addLayout(body, 1) - # ``Maximum`` so the table stops at its natural height and the spare vertical - # space goes to the 3D view above it. Left expandable it ate the window: the - # preview stayed at its 240 px floor while the table had hundreds of empty - # pixels under three rows. - table_box = self._build_point_table() - table_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) - outer.addWidget(table_box) - self.set_sensor_setup(sensor_offset_mm, sensor_yaw_deg) self.set_tool_xy(*tool_xy_mm) self.retranslate() @@ -582,62 +554,6 @@ class FingerPanel(QGroupBox): self._preview_box = box return box - def _build_point_table(self) -> QWidget: - box = QGroupBox(self) - box.setObjectName("groupBox") - column = QVBoxLayout(box) - column.setContentsMargins(8, 6, 8, 8) - column.setSpacing(6) - - self.table = QTableWidget(0, len(self._headers) + 1, box) - self.table.verticalHeader().setVisible(False) - self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) - # Editable, but only on a deliberate double-click: a single click selects - # the row (and drives the markers), which is what an operator does most. - self.table.setEditTriggers( - QTableWidget.EditTrigger.DoubleClicked - | QTableWidget.EditTrigger.EditKeyPressed - ) - self.table.setAlternatingRowColors(True) - self.table.setColumnWidth(0, 34) - for index in range(1, len(self._headers) + 1): - self.table.setColumnWidth(index, 82) - self.table.horizontalHeader().setSectionResizeMode( - len(self._headers), QHeaderView.ResizeMode.Stretch - ) - self.table.setMinimumHeight(90) - self.table.itemChanged.connect(self._on_item_changed) - self.table.itemSelectionChanged.connect(self._on_selection_changed) - column.addWidget(self.table, 1) - - row = QHBoxLayout() - self.clear_button = QPushButton(box) - self.clear_button.clicked.connect(self.clear_points) - self.remove_button = QPushButton(box) - self.remove_button.clicked.connect(self.remove_selected) - self.up_button = QPushButton(box) - self.up_button.clicked.connect(lambda: self.move_selected(-1)) - self.down_button = QPushButton(box) - self.down_button.clicked.connect(lambda: self.move_selected(1)) - self.export_sites_button = QPushButton(box) - self.export_sites_button.setObjectName("exportCalibrationSites") - self.export_sites_button.clicked.connect(self._export_sites_dialog) - for button in (self.clear_button, self.remove_button, self.up_button, self.down_button): - row.addWidget(button) - row.addWidget(self.export_sites_button) - row.addStretch(1) - self._selected_label = QLabel(box) - self._selected_label.setObjectName("hintLabel") - row.addWidget(self._selected_label) - self.count_label = QLabel(box) - self.count_label.setObjectName("hintLabel") - row.addWidget(self.count_label) - column.addLayout(row) - - self._table_box = box - return box - # -------------------------------------------------------------------- state @property @@ -699,16 +615,25 @@ class FingerPanel(QGroupBox): self.preview.set_tool_xy(*self.tool_xy_mm) def _resolve_everything(self) -> None: - """Re-solve every stored row, then redraw what the current one shows. + """The setup or the tool moved: redraw what the picked point now solves to. - Every row, not only the current one: the setup and the tool move the whole - machine, so a table still holding the old X/Y/Z would be a table of numbers - from a machine that no longer exists. + The capture page's queue is re-solved by that page when this signal fires; + this screen only owns its single picked point, so it re-solves that one and + repaints both views with the machine that exists now. """ - for row in range(len(self._picked)): - self.resolve_row(row) - self._after_points_changed() - self._refresh_path() + record = self._point + if record is not None: + point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64) + normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64) + pose = self._stack.solve(point, normal) + self._last_pose = pose + self.preview.set_stack(self._stack) + self.preview.set_point(point, normal) + self.preview.set_pose(pose) + self.preview.set_highlight(point) + self._sync_sim_button() + self.refresh_readouts(point, normal, pose) + self._refresh_path() self._refresh_reach() self.stackChanged.emit() @@ -716,6 +641,9 @@ class FingerPanel(QGroupBox): self.path_edit.setText(path) def set_bundle(self, bundle: SensorBundle | None, flat: FlatSurface | None = None) -> None: + # A different mesh means a previously picked point names a surface that no + # longer exists, so the single-point trial starts over. + self.clear_point() self._bundle = bundle self.view.set_flat(flat) self.preview.set_bundle(bundle) @@ -836,110 +764,30 @@ class FingerPanel(QGroupBox): else: self.view_label.setText(t("finger.untextured")) - def clear_points(self) -> None: - self._picked.clear() - self._writing = True - try: - self.table.setRowCount(0) - finally: - self._writing = False + def clear_point(self) -> None: + """Forget the picked point, and the pictures drawn for it.""" + self._point = None + self._last_pose = None + self._last_face = -1 self.preview.set_pose(None) self.preview.set_point(None, None) self.preview.set_highlight(None) self.view.set_marker(None) self.view.set_selected(None) self._sync_sim_button() - self._refresh_count() self.path_label.setText("") def point_count(self) -> int: - return self.table.rowCount() + return 0 if self._point is None else 1 def points(self) -> list[dict[str, float]]: - """Every picked point, at full precision, for a later export.""" - return [dict(row) for row in self._picked] + """The single picked point, at full precision (empty before a pick). - def acquisition_points(self) -> list[dict]: - """Picked points with a freshly solved pose for 02 acquisition. - - The pose is the same ``PlatformStack.solve`` result the execute button uses. - Calling this does not move the platform. + A list, not a bare dict, because the old table-based callers asked for a + sequence; this screen tries one point and the capture page's queue is where + many are kept. """ - points = [] - for index, record in enumerate(self._picked, 1): - point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64) - normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64) - pose = self._stack.solve(point, normal) - move_x, move_y = machine_table_xy(pose.table_xy_mm) - points.append({ - "site_id": f"site-{index:03d}", - "x": float(point[0]), - "y": float(point[1]), - "z": float(point[2]), - "nx": float(normal[0]), - "ny": float(normal[1]), - "nz": float(normal[2]), - "u_px": record.get("u_px"), - "v_px": record.get("v_px"), - "pose": { - "x": float(move_x), - "y": float(move_y), - "yaw": float(pose.yaw_deg), - "pitch": float(pose.pitch_deg), - "z": float(pose.contact_z_mm), - "residual_deg": float(pose.residual_deg), - "upright": bool(pose.upright), - "in_range": bool(pose.ok and pose.in_range), - "ok": bool(pose.ok), - "normal_aligned_to": f"tool_axis:{self._stack.tool_axis}", - "reason": pose.reason, - }, - }) - return points - - def export_sites(self, path: str | Path) -> Path: - """Export the displayed order, with the same UV convention as calibration.""" - flat = self.view.flat - if flat is None or not self._picked: - raise ValueError(t("finger.export_no_points")) - origin_x, origin_y = uv_origin(flat) - identity = mapping_sha256(flat) - rows = [] - for order, record in enumerate(self._picked, 1): - site = site_at( - flat, record["u_px"], record["v_px"], origin=(origin_x, origin_y), - mapping_digest=identity, - ) - point = [record[key] for key in ("x", "y", "z")] - if not np.allclose(point, site.position_sensor_mm, rtol=0, atol=1e-9): - raise ValueError(t("finger.export_edited_point").format(order=order)) - rows.append({ - "order": order, "site_id": f"site-{order:03d}", - "u_px": site.u_px, "v_px": site.v_px, - **{f"{axis}_sensor_mm": record[axis] for axis in ("x", "y", "z")}, - **{f"{axis.lower()}_platform_mm": record[axis] for axis in ("X", "Y", "Z")}, - "yaw_deg": record["yaw"], "pitch_deg": record["pitch"], - "origin_raster_x_px": origin_x, "origin_raster_y_px": origin_y, - "raster_width_px": flat.width, "raster_height_px": flat.height, - "u_positive": "right", "v_positive": "up", "mapping_sha256": identity, - }) - destination = Path(path) - with destination.open("w", newline="", encoding="utf-8-sig") as handle: - writer = csv.DictWriter(handle, fieldnames=list(rows[0])) - writer.writeheader() - writer.writerows(rows) - return destination - - def _export_sites_dialog(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, t("finger.export_sites"), "calibration-sites.csv", "CSV (*.csv)" - ) - if path: - try: - self.export_sites(path) - self.status_label.setText(t("finger.sites_exported").format(path=path)) - except (OSError, ValueError) as exc: - self.status_label.setText(t("calibration.error").format(detail=exc)) + return [] if self._point is None else [dict(self._point)] # ------------------------------------------------------------------ actions @@ -947,7 +795,8 @@ class FingerPanel(QGroupBox): """Turn a click into a pose. Returns the row, or ``None`` if no surface. ``None`` is the honest answer for a UV-island gap: reporting (0, 0, 0) - would put a calibration point at the sensor's origin. + would put a calibration point at the sensor's origin. A second click + replaces the first: this screen tries one point at a time. """ if self.view.flat is None: self.status_label.setText(t("finger.no_bundle")) @@ -960,9 +809,10 @@ class FingerPanel(QGroupBox): point, normal, face = sample origin_x, origin_y = uv_origin(self.view.flat) pose = self._stack.solve(point, normal) - # The click leaves a cross-hair where it landed *and* promotes the new row - # to the selection, so the red dot marks the same point in both views. + # The click leaves a cross-hair where it landed and a red dot on the point + # about to be tried, so both views name the same pixel. self.view.set_marker((x, y)) + self.view.set_selected((x, y)) self.preview.set_stack(self._stack) self.preview.set_point(point, normal) @@ -975,7 +825,7 @@ class FingerPanel(QGroupBox): "residual": float(pose.residual_deg), "u_px": x - origin_x, "v_px": origin_y - y, } - self._append_row(row) + self._point = dict(row) self._last_pose = pose self._last_face = face # Hand the solved pose to the preview so the play button demonstrates @@ -985,266 +835,8 @@ class FingerPanel(QGroupBox): self._sync_sim_button() self.refresh_readouts(point, normal, pose, face) self._refresh_path() - # Select the row just added, so the table agrees with the dot. - self._writing = True - try: - self.table.setCurrentCell(self.table.rowCount() - 1, 1) - finally: - self._writing = False return row - def _append_row(self, row: dict[str, float]) -> None: - # The full-precision row is kept alongside the table, because the table - # only holds three decimals for reading. Recovering the numbers by parsing - # the displayed text would quietly hand a later export coordinates the - # solver never produced. - self._picked.append(dict(row)) - self.table.insertRow(self.table.rowCount()) - self._write_row(self.table.rowCount() - 1) - while self.table.rowCount() > MAX_POINTS: - self.table.removeRow(0) - while len(self._picked) > MAX_POINTS: - self._picked.pop(0) - self._refresh_count() - - # ------------------------------------------------------- table row plumbing - - def _write_row(self, index: int) -> None: - """Render one ``_picked`` row into the table, without re-triggering edits.""" - if not (0 <= index < len(self._picked)): - return - row = self._picked[index] - self._writing = True - try: - for offset, key in enumerate(self._headers): - column = offset + 1 - item = self.table.item(index, column) - if item is None: - item = QTableWidgetItem() - item.setTextAlignment( - Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter - ) - if offset >= self.INPUT_COLUMNS: - # Solved values are shown, not typed: editing the normal and - # reading the pose is the workflow, and the other way round - # would need an inverse the table cannot express. - item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) - item.setForeground(QColor("#5c6678")) - self.table.setItem(index, column, item) - item.setText( - f"{row[key]:.0f}" if key in {"u_px", "v_px"} else f"{row[key]:.3f}" - ) - self._ensure_selector(index) - finally: - self._writing = False - - def _ensure_selector(self, index: int, checked: bool = False) -> None: - """Give a row its select checkbox, created once.""" - if self.table.cellWidget(index, self.SELECT_COLUMN) is not None: - return - holder = QWidget(self.table) - layout = QHBoxLayout(holder) - layout.setContentsMargins(0, 0, 0, 0) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - check = QCheckBox(holder) - check.setChecked(checked) - check.toggled.connect(self._refresh_count) - layout.addWidget(check) - self.table.setCellWidget(index, self.SELECT_COLUMN, holder) - - def _selector(self, index: int) -> QCheckBox | None: - holder = self.table.cellWidget(index, self.SELECT_COLUMN) - if holder is None: - return None - return holder.findChild(QCheckBox) - - def selected_rows(self) -> list[int]: - """Rows whose checkbox is ticked, ascending.""" - return [ - index - for index in range(self.table.rowCount()) - if (check := self._selector(index)) is not None and check.isChecked() - ] - - def remove_selected(self) -> int: - """Drop every ticked row, and the last-picked point if none is ticked.""" - rows = self.selected_rows() - if not rows: - row = self.table.currentRow() - rows = [row] if row >= 0 else [] - for index in sorted(rows, reverse=True): - self.table.removeRow(index) - if 0 <= index < len(self._picked): - self._picked.pop(index) - self._refresh_count() - self._after_points_changed() - return len(rows) - - def move_selected(self, offset: int) -> bool: - """Shift the ticked rows (or the current one) up or down one place. - - Moves the topmost row first when going down and the bottom-most first when - going up, so a contiguous block keeps its internal order and no row is - swapped twice. The current row follows the move, so repeating the action - keeps pushing the same point rather than stranding the selection. - """ - rows = self.selected_rows() or ( - [self.table.currentRow()] if self.table.currentRow() >= 0 else [] - ) - if not rows: - return False - ordered = sorted(rows, reverse=offset > 0) - moved = False - landed = self.table.currentRow() - for index in ordered: - target = index + offset - if not (0 <= target < self.table.rowCount()): - continue - if target in rows: # a selected neighbour will move too; do not swap twice - continue - self._swap_rows(index, target) - if index == self.table.currentRow(): - landed = target - moved = True - if moved: - self._writing = True - try: - self.table.setCurrentCell(landed, max(1, self.table.currentColumn())) - finally: - self._writing = False - self._refresh_count() - self._after_points_changed() - return moved - - def _swap_rows(self, first: int, second: int) -> None: - """Exchange two table rows and their stored points, selection included.""" - self._picked[first], self._picked[second] = self._picked[second], self._picked[first] - checked = [ - bool((check := self._selector(row)) is not None and check.isChecked()) - for row in (first, second) - ] - self._writing = True - try: - # Rebuild both rows from the swapped data: simpler than moving items, and - # it cannot leave a stale value behind. ``takeCellWidget`` does not exist, - # so the old checkboxes are detached by replacing them with None. - for row in (first, second): - for column in range(1, len(self._headers) + 1): - self.table.takeItem(row, column) - self.table.removeCellWidget(row, self.SELECT_COLUMN) - self._write_row(first) - self._write_row(second) - finally: - self._writing = False - for row, was_checked in zip((first, second), checked): - check = self._selector(row) - if check is not None: - check.setChecked(was_checked) - - def _refresh_count(self) -> None: - total = self.table.rowCount() - chosen = len(self.selected_rows()) - self.count_label.setText(t("finger.point_count", count=total)) - self.remove_button.setEnabled(total > 0) - self.up_button.setEnabled(total > 0) - self.down_button.setEnabled(total > 0) - self._selected_label.setText( - t("finger.selected_count", count=chosen) if chosen else "" - ) - - # ----------------------------------------------------------------- editing - - def _on_item_changed(self, item: QTableWidgetItem) -> None: - """An operator edited a cell: re-solve the row and refresh everything. - - Only the six input columns are editable, so an edit always means "this - sensor-frame point or normal changed". The pose columns are redrawn from the - new solve rather than trusted, which is what keeps the table and the readouts - from disagreeing. - """ - if self._writing: - return - column = item.column() - 1 - row = item.row() - if column < 0 or column >= self.INPUT_COLUMNS: - return - if not (0 <= row < len(self._picked)): - return - try: - value = float(item.text()) - except ValueError: - # Put the old number back rather than storing a non-number. - self._write_row(row) - self.status_label.setText(t("finger.edit_not_a_number")) - return - if not math.isfinite(value): - self._write_row(row) - self.status_label.setText(t("finger.edit_not_a_number")) - return - - record = self._picked[row] - record[self._headers[column]] = value - self.resolve_row(row) - self._after_points_changed() - - def resolve_row(self, row: int) -> None: - """Re-solve a stored row from its sensor-frame point and normal.""" - if not (0 <= row < len(self._picked)): - return - record = self._picked[row] - point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64) - normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64) - length = float(np.linalg.norm(normal)) - if length < 1e-12: - return - normal = normal / length - pose = self._stack.solve(point, normal) - record.update( - { - "nx": float(normal[0]), "ny": float(normal[1]), "nz": float(normal[2]), - "X": float(pose.target_xy_mm[0]), "Y": float(pose.target_xy_mm[1]), - "Z": float(pose.contact_z_mm), - "yaw": float(pose.yaw_deg), "pitch": float(pose.pitch_deg), - "residual": float(pose.residual_deg), - } - ) - self._write_row(row) - - def _on_selection_changed(self) -> None: - """Follow the current row in both pictures, so the marker means something.""" - row = self.table.currentRow() - if not (0 <= row < len(self._picked)): - return - self.show_point(row) - - def show_point(self, row: int) -> None: - """Point the 2D and 3D markers at one row: this is the point to be tested.""" - if not (0 <= row < len(self._picked)): - return - record = self._picked[row] - point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64) - normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64) - self.preview.set_point(point, normal) - self.preview.set_highlight(point) - if self.view.flat is not None: - pixel = self.view.pixel_for_point(point) - self.view.set_selected(pixel) - pose = self._stack.solve(point, normal) - self._last_pose = pose - self.preview.set_pose(pose) - self._sync_sim_button() - self.refresh_readouts(point, normal, pose) - self._refresh_path() - - def _after_points_changed(self) -> None: - """Keep the picture honest after a delete, reorder or edit.""" - row = self.table.currentRow() - if 0 <= row < len(self._picked): - self.show_point(row) - else: - self.preview.set_highlight(None) - self.view.set_selected(None) - # ----------------------------------------------------------------- readouts _last_pose = None @@ -1417,7 +1009,6 @@ class FingerPanel(QGroupBox): self._picker_box.setTitle(t("finger.flat_title")) self._readout_box.setTitle(t("finger.readout_title")) self._preview_box.setTitle(t("finger.preview_title")) - self._table_box.setTitle(t("finger.points_title")) labels = { "sensor_point": "finger.row_sensor_point", "sensor_normal": "finger.row_sensor_normal", @@ -1456,16 +1047,5 @@ class FingerPanel(QGroupBox): self.stop_button.setToolTip(t("move.stop_hint")) self.confirm_check.setText(t("move.confirm_check")) self.confirm_check.setToolTip(t("move.confirm_hint")) - self.table.setHorizontalHeaderLabels( - [""] + [t(f"finger.col_{key}") for key in self._headers] - ) - self.clear_button.setText(t("finger.clear_points")) - self.export_sites_button.setText(t("finger.export_sites")) - self.export_sites_button.setToolTip(t("finger.export_sites_tip")) - self.remove_button.setText(t("finger.remove_selected")) - self.up_button.setText(t("finger.move_up")) - self.down_button.setText(t("finger.move_down")) - self.table.setToolTip(t("finger.table_hint")) - self._refresh_count() self.status_label.setText(t("finger.select_hint")) diff --git a/apps/autocal/src/orisys_autocal/ui/site_export.py b/apps/autocal/src/orisys_autocal/ui/site_export.py new file mode 100644 index 0000000..b9f5e81 --- /dev/null +++ b/apps/autocal/src/orisys_autocal/ui/site_export.py @@ -0,0 +1,124 @@ +"""The operator's point sheet: one CSV row per queue point. + +This is the file an operator reproduces by hand at the UV map: the pixel to click, +the sensor-frame position behind it, the platform target the simulated mounting +produces, and the mapping identity that makes the pixel coordinates reproducible. +It is an *operation sheet*, not an executed sequence. + +The field list is one definition because two readers depend on it: the exporter +below and the test that checks it against +``apps/autocal/docs/calibration-data-format.zh.md``. A second copy of the names is +how the table and the document drift apart. + +The platform columns are the point's stored solved pose — the same numbers the +align move commands — so the sheet says what the machine would do, not a second +solution computed for the sheet. +""" + +from __future__ import annotations + +import csv +from pathlib import Path + +import numpy as np +from orisys_app_shared.i18n import t + +from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin + +#: Column order of the operation table. First block is the pixel, second the +#: sensor-frame position, third the platform target, last the mapping identity — +#: the same grouping the data-format document uses. +OPERATION_TABLE_FIELDS: tuple[str, ...] = ( + "order", + "site_id", + "u_px", + "v_px", + "x_sensor_mm", + "y_sensor_mm", + "z_sensor_mm", + "x_platform_mm", + "y_platform_mm", + "z_platform_mm", + "yaw_deg", + "pitch_deg", + "origin_raster_x_px", + "origin_raster_y_px", + "raster_width_px", + "raster_height_px", + "u_positive", + "v_positive", + "mapping_sha256", +) + +#: One pixel of the 1024-wide production raster is about 0.0147 mm, so a gap +#: larger than this means the recorded point and the map no longer agree. +_POINT_MATCH_MM = 1e-9 + + +def operation_table_rows(rows, flat: FlatSurface) -> list[dict]: + """One dict per queue row, in queue order, ready for :data:`OPERATION_TABLE_FIELDS`. + + ``rows`` are the controller's ``SiteRow`` snapshots. A row without UV pixels, + without a solved pose, or whose recorded sensor position no longer matches the + current map refuses the export: an operation sheet that points at a pixel the + record does not describe is worse than no sheet. + """ + if flat is None: + raise ValueError(t("cap.queue.export_no_map")) + origin_x, origin_y = uv_origin(flat) + identity = mapping_sha256(flat) + records: list[dict] = [] + for order, row in enumerate(rows, 1): + if row.uv_px is None: + raise ValueError(t("cap.queue.export_missing_uv").format(order=order)) + if row.pose is None: + raise ValueError(t("cap.queue.export_no_pose").format(order=order)) + site = site_at( + flat, + int(row.uv_px[0]), + int(row.uv_px[1]), + origin=(origin_x, origin_y), + mapping_digest=identity, + ) + recorded = np.asarray( + [float(row.position[axis]) for axis in "xyz"], dtype=np.float64 + ) + if not np.allclose(recorded, site.position_sensor_mm, rtol=0, atol=_POINT_MATCH_MM): + raise ValueError(t("cap.queue.export_edited_point").format(order=order)) + pose = row.pose + records.append( + { + "order": order, + "site_id": row.site_id, + "u_px": site.u_px, + "v_px": site.v_px, + **{f"{axis}_sensor_mm": float(row.position[axis]) for axis in "xyz"}, + **{ + f"{axis}_platform_mm": float(pose[axis]) + for axis in ("x", "y", "z") + }, + "yaw_deg": float(pose["yaw"]), + "pitch_deg": float(pose["pitch"]), + "origin_raster_x_px": origin_x, + "origin_raster_y_px": origin_y, + "raster_width_px": flat.width, + "raster_height_px": flat.height, + "u_positive": "right", + "v_positive": "up", + "mapping_sha256": identity, + } + ) + return records + + +def write_operation_table(path: str | Path, rows, flat: FlatSurface) -> Path: + """Write the operation table as UTF-8 CSV with the frozen field order.""" + if not rows: + raise ValueError(t("cap.queue.export_no_points")) + records = operation_table_rows(rows, flat) + destination = Path(path) + with destination.open("w", newline="", encoding="utf-8-sig") as handle: + writer = csv.DictWriter(handle, fieldnames=list(OPERATION_TABLE_FIELDS)) + writer.writeheader() + writer.writerows(records) + return destination diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index 689fe82..c6634ef 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -11,8 +11,6 @@ from __future__ import annotations import logging import math -import subprocess -import sys from pathlib import Path import numpy as np @@ -74,6 +72,7 @@ from .bus_panel import default_bas_dir from .calibration_panel import CalibrationPanel from .device_setup import DeviceSetupDialog from .finger_source_panel import FingerSourcePanel +from .reveal import reveal from .sensor_panel import FingerPanel from .sequence_panel import SequencePanel from .status_strip import RunBar, StatusStrip @@ -143,6 +142,10 @@ class AutoCalMainWindow(QMainWindow): self._last_snapshot = Snapshot() self._pending: dict[int, str] = {} self._last_error = "" + #: Whether the move in flight was requested by the capture page, so that the + #: page can be told the alignment finished or did not. A move the finger page + #: asked for must never mark a capture point as aligned. + self._capture_alignment = False # Apply the remembered language before anything is built. The translator # is process-global, and only the menu ever set it, so a saved language @@ -308,7 +311,13 @@ class AutoCalMainWindow(QMainWindow): source_kind=str(self.prefs.get("finger_source_kind") or "video"), camera_index=self.prefs.get("finger_camera_index") or 0, ) - self.calibration_panel = CalibrationPanel(self.finger_source_panel, body) + self.calibration_panel = CalibrationPanel( + self.finger_source_panel, + body, + prefs=self.prefs, + solver=self.finger_stack, + bus_problem=self._capture_bus_problem, + ) self.calibration_panel.loadBundleRequested.connect(self._on_load_bundle) self.workspace = WorkspaceTabs( self.axis_panel, @@ -381,8 +390,7 @@ class AutoCalMainWindow(QMainWindow): self.finger_panel.executeRequested.connect(self._on_execute_pose) self.finger_panel.stopRequested.connect(self._on_stop_all) self.finger_panel.confirmChanged.connect(self._on_confirm_moves_changed) - self.calibration_panel.multipoint.bind_finger(self.finger_panel) - self.calibration_panel.multipoint.alignRequested.connect(self._on_align_multipoint) + self.calibration_panel.alignRequested.connect(self._on_align_multipoint) self.run_bar.runRequested.connect(self._on_run_requested) self.run_bar.pauseRequested.connect(self._on_run_pause) self.run_bar.abortRequested.connect(self._on_run_abort) @@ -416,6 +424,20 @@ class AutoCalMainWindow(QMainWindow): if problem: self.finger_panel.set_move_status(f"✗ {problem}") + def _capture_bus_problem(self) -> str: + """Why the capture page's bus check fails, or ``""`` when it passes. + + The capture page may only start on a connected controller with an + initialised bus and a sound axis mapping; the page renders this answer and + does not read the service itself. + """ + service = self.poller.service + if not service.connected: + return "the controller is not connected" + if not service.bus.initialized: + return "the bus is not initialised" + return service.roles_problem() + @property def _press_axis(self) -> PressAxis: """How the press axis's coordinate relates to a contact height. @@ -443,7 +465,7 @@ class AutoCalMainWindow(QMainWindow): export, a missing file or an unreadable mesh must leave the rest of the app alone and report the reason. """ - if self.calibration_panel.trial.session is not None: + if self.calibration_panel.session_open: raise ValueError(t("calibration.close_before_mapping")) bundle = load_bundle( path, @@ -552,6 +574,10 @@ class AutoCalMainWindow(QMainWindow): self.prefs["finger_sensor_offset_mm"] = list(self.finger_panel.sensor_offset_mm) self.prefs["finger_sensor_yaw_deg"] = self.finger_panel.sensor_yaw_deg self.prefs["finger_tool_xy_mm"] = list(self.finger_panel.tool_xy_mm) + # The queue's poses were solved with the machinery that just changed, so the + # capture page re-solves them: writes back while idle, display-only when the + # queue is frozen between trials. + self.calibration_panel.resolve_queue() # ---------------------------------------------------------------- run bar @@ -833,6 +859,7 @@ class AutoCalMainWindow(QMainWindow): self.mover = PoseMover(self.poller.service, self.poller.submit, self) self.mover.failed.connect(self._on_move_failed) self.mover.finished.connect(self._on_move_finished) + self.mover.cancelled.connect(self._on_move_cancelled) # Ages the poll-health chip even when no snapshot arrives. Runs on the # GUI thread and only reads a timestamp — the controller is untouched — @@ -1099,6 +1126,7 @@ class AutoCalMainWindow(QMainWindow): self._pending[token] = "" def _on_jog(self, axis: int, forward: bool) -> None: + self._capture_platform_moved() token = self.poller.submit(lambda service: service.jog(axis, forward)) self._pending[token] = "" @@ -1153,11 +1181,16 @@ class AutoCalMainWindow(QMainWindow): self._start_move(plan, t("move.execute")) def _on_align_multipoint(self, targets: dict) -> None: - """Same five-axis move as the finger screen, for the point chosen on 选择测点. + """Same five-axis move as the finger screen, for the current capture point. - The calibration panel hands over geometry, not controller coordinates, so + The calibration page hands over geometry, not controller coordinates, so the press-axis relation is applied here as well — one place decides which roles a solved point may command, whichever screen asked for the move. + + The page is told the outcome: only a move that finished may mark the point + aligned, so a refused, cancelled or failed move leaves "start capture" + unclickable. That report is why ``_capture_alignment`` exists: a move the + finger screen asked for must never mark a capture point aligned. """ targets, held = press_targets(targets, self._press_axis) plan = plan_pose_move( @@ -1167,12 +1200,27 @@ class AutoCalMainWindow(QMainWindow): hold=held, together=True, ) + self._capture_alignment = True self._start_move( plan, t("multipoint.align"), - report=self.calibration_panel.multipoint.message.setText, + report=self.calibration_panel.set_message, ) + def _capture_platform_moved(self) -> None: + """A move the capture page did not ask for: its "aligned" no longer holds.""" + self.calibration_panel.alignment_invalidated() + + def _capture_move_outcome(self, ok: bool, reason: str = "") -> None: + """Tell the capture page how the move it asked for ended, once.""" + if not self._capture_alignment: + return + self._capture_alignment = False + if ok: + self.calibration_panel.alignment_succeeded() + else: + self.calibration_panel.alignment_failed(reason) + def _on_return_to_initial(self) -> None: """Yaw, pitch and Z back to zero. Table X/Y park at the stored initial.""" @@ -1201,6 +1249,7 @@ class AutoCalMainWindow(QMainWindow): if refusal is not None: say(refusal.message) self._refuse_move(refusal.message) + self._capture_move_outcome(False, refusal.message) return # Confirm before the platform moves, with the numbers rather than a summary of # them: this is the operator's chance to notice that the simulation and the @@ -1236,12 +1285,16 @@ class AutoCalMainWindow(QMainWindow): if answer != QMessageBox.StandardButton.Yes: say(t("move.cancelled")) self.finger_panel.set_move_status(t("move.cancelled")) + self._capture_move_outcome(False, t("move.cancelled")) return if not self.mover.start(plan).ok: reason = self.mover.state.failure or t("move.busy") say(reason) self._refuse_move(reason) + self._capture_move_outcome(False, reason) return + if not self._capture_alignment: + self._capture_platform_moved() self.finger_panel.set_move_status(plan.describe()) self.status_strip.set_action_result(f"{title}: {plan.describe()}", True) @@ -1258,6 +1311,7 @@ class AutoCalMainWindow(QMainWindow): if plan is not None: self.finger_panel.set_move_status(f"✓ {plan.title or t('move.done')}") self.status_strip.set_action_result(t("move.done"), True) + self._capture_move_outcome(True) def _on_move_failed(self, message: str) -> None: # A mover that gave up leaves an axis wherever it stopped: stop the rest @@ -1265,15 +1319,25 @@ class AutoCalMainWindow(QMainWindow): self.finger_panel.set_move_status(f"✗ {message}") self.status_strip.set_action_result(f"{t('move.failed')}: {message}", False) self.statusBar().showMessage(f"{t('move.failed')}: {message}") + self._capture_move_outcome(False, message) self.poller.submit(lambda service: service.stop_all()) + def _on_move_cancelled(self) -> None: + """The operator stopped a move: the axes stay where they stopped.""" + self.finger_panel.set_move_status(t("move.cancelled")) + self.status_strip.set_action_result(t("move.cancelled"), False) + self._capture_move_outcome(False, t("move.cancelled")) + def _on_move_abs(self, axis: int, target: float) -> None: + self._capture_platform_moved() self.poller.submit(lambda service: service.move_abs(axis, target)) def _on_move_rel(self, axis: int, distance: float) -> None: + self._capture_platform_moved() self.poller.submit(lambda service: service.move_rel(axis, distance)) def _on_home(self, axis: int) -> None: + self._capture_platform_moved() self.poller.submit(lambda service: service.home(axis)) def _on_clear_alarm(self, axis: int) -> None: @@ -1353,20 +1417,10 @@ class AutoCalMainWindow(QMainWindow): def _on_reveal_config(self) -> None: from orisys_app_shared.paths import config_dir - self._reveal(config_dir()) + reveal(config_dir()) def _on_reveal_outputs(self) -> None: - self._reveal(outputs_dir()) - - @staticmethod - def _reveal(path: Path) -> None: - try: - if sys.platform == "win32": - subprocess.Popen(["explorer", str(path)]) - else: # pragma: no cover - the platform is Windows - subprocess.Popen(["xdg-open", str(path)]) - except OSError: - logger.exception("Could not open %s", path) + reveal(outputs_dir()) def _on_about(self) -> None: QMessageBox.information( diff --git a/apps/autocal/tests/evidence_helpers.py b/apps/autocal/tests/evidence_helpers.py index 1d32734..05b11d6 100644 --- a/apps/autocal/tests/evidence_helpers.py +++ b/apps/autocal/tests/evidence_helpers.py @@ -290,6 +290,7 @@ def _write_multipoint_trial( raw_per_n: float, samples_per_hold: int, frame_start: int, + capture_status: str | None = None, ) -> list[dict]: write_csv( path / "reference.csv", @@ -302,14 +303,17 @@ def _write_multipoint_trial( ["t_host_mono_ns", "t_host_utc_ns", "kind", "detail"], [], ) - write_json_file(path / "session.json", { + manifest = { "trial_id": f"{site_id}-repeat-{repeat_index:03d}", "site_id": site_id, "status": "complete", "uv_px": {"u": 128, "v": 64}, "images": {"captured": with_capture, "reason": "" if with_capture else "camera not attached yet"}, - }) + } + if capture_status is not None: + manifest["capture_status"] = capture_status + write_json_file(path / "session.json", manifest) if not with_capture: return [] frames: list[dict] = [] @@ -324,8 +328,13 @@ def _write_multipoint_trial( ) frames.extend(hold_frames) forces.extend(hold_forces) - write_capture(path / "capture", frames, forces, fields=None if video else FRAMES_FIELDS, - video_segments=1) + if not video: + # A data-only recording: the rows exist, no frame was written to a video. + frames = [ + {**row, "segment": 0, "segment_frame": 0, "in_video": 0} for row in frames + ] + write_capture(path / "capture", frames, forces, fields=FRAMES_FIELDS, + video_segments=1 if video else 0) return [] @@ -343,8 +352,17 @@ def multipoint_session( simulate: bool = False, trial_status: str = "complete", extra_holds: list[Hold] | None = None, + recording_block: dict | None = None, + capture_status: str | None = None, ) -> Path: - """A complete multi-point session directory through the review contract.""" + """A complete multi-point session directory through the review contract. + + ``video=False`` writes a data-only recording: frame rows exist with + ``in_video=0`` and no MP4 is created. ``recording_block`` writes the root + manifest's ``recording`` block; ``None`` keeps the fixture shaped like a + session recorded before that block existed. ``capture_status`` puts the + lifecycle into the trial records, as the campaign does. + """ session_id = f"finger-SN1_{root.name}_test0001" trials: list[dict] = [] completion: list[dict] = [] @@ -379,21 +397,25 @@ def multipoint_session( raw_per_n=raw_per_n, samples_per_hold=samples_per_hold, frame_start=1, + capture_status=capture_status, ) - trials.append({ + trial_record = { "trial_id": f"{site_id}-repeat-{repeat:03d}", "site_id": site_id, "repeat_index": repeat, "directory": directory, "status": trial_status, "reason": "" if trial_status == "complete" else "stopped by the operator", - }) + } + if capture_status is not None: + trial_record["capture_status"] = capture_status + trials.append(trial_record) if trial_status == "complete": complete += 1 completion.append({"site_id": site_id, "repeats_planned": repeats, "repeats_complete": complete, "complete": complete >= repeats}) - write_json_file(root / "session.json", { + manifest = { "format_version": "autocal-multipoint-1.0", "session_id": session_id, "sensor_model": "finger", @@ -411,7 +433,10 @@ def multipoint_session( "[t_start_host_mono_ns, t_end_host_mono_ns). Rows are not paired."}, "platform_motion": False, "automatic_force_control": False, - }) + } + if recording_block is not None: + manifest["recording"] = recording_block + write_json_file(root / "session.json", manifest) return root diff --git a/apps/autocal/tests/test_binding_purity.py b/apps/autocal/tests/test_binding_purity.py index 80ccccd..7f239e0 100644 --- a/apps/autocal/tests/test_binding_purity.py +++ b/apps/autocal/tests/test_binding_purity.py @@ -34,6 +34,14 @@ FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"} #: The one binding allowed, and where it may be named. ALLOWED_BINDING = "PySide6" +#: ``ui/`` modules that deliberately name no binding. ``reveal.py`` hands a path +#: to the OS file manager; ``site_export.py`` writes the queue's CSV point sheet. +#: Neither builds a widget, so neither has a binding to declare. The rule below +#: exists so a UI module cannot *use* a binding it does not name; a module that +#: uses none cannot break it, and importing PySide6 only to satisfy the rule would +#: be the false declaration the rule is meant to prevent. +_BINDING_FREE_UI = {"reveal.py", "site_export.py"} + #: Scoped enum holders we care about, with the value names actually used. #: #: Hand-maintained, and therefore incomplete by construction: the PySide6 migration @@ -147,6 +155,8 @@ def test_every_ui_module_imports_the_allowed_binding(): for path in _python_files(APP_SRC / "orisys_autocal" / "ui"): if path.name == "__init__.py": continue # a re-export barrel, not a widget module + if path.name in _BINDING_FREE_UI: + continue if ALLOWED_BINDING not in _imported_packages(path): offenders.append(path.name) assert not offenders, f"these UI modules do not import {ALLOWED_BINDING}: {offenders}" diff --git a/apps/autocal/tests/test_capture_controller.py b/apps/autocal/tests/test_capture_controller.py new file mode 100644 index 0000000..73229d4 --- /dev/null +++ b/apps/autocal/tests/test_capture_controller.py @@ -0,0 +1,1777 @@ +"""Table-driven checks for the headless capture controller. + +The controller is what the new capture page renders, so every test here drives a +real :class:`~orisys_autocal.multipoint.Campaign` through its steps: no Qt window, +no camera, no platform. The campaigns run ``simulate=True``, the only mode the +AutoCal suite may use (see ``conftest.py``); a faked ``capture_state`` is used +only where the test is about the recording guard itself. + +Acceptance coverage (plan P3): the complete load ladder, liftoff confirmation, +point change, skip (the Campaign advance, its counter rollback and its guard), +remeasure, reacquire, stop from every state, alignment results reported by the +window (a request alone is never a finished move), a reason for every disabled +action, and the same ``session.json`` / ``reference.csv`` structure whether the +campaign is driven directly or through the controller. +""" + +from __future__ import annotations + +import ast +import csv +import json +import time +import types +from pathlib import Path + +import pytest +from orisys_autocal import capture_controller +from orisys_autocal.capture_controller import ( + READINESS_ITEMS, + READINESS_KEYS, + ActionBlocked, + CaptureController, + ReadinessCheck, +) +from orisys_autocal.multipoint import Campaign + +HOLD_S = 0.02 +MAX_FORCE = 20.0 + +#: A pose the campaign may command; it is what the finger page would have stored. +POSE = { + "x": 10.0, + "y": 20.0, + "z": 30.0, + "yaw": 5.0, + "pitch": 10.0, + "residual_deg": 0.01, + "upright": True, + "in_range": True, + "ok": True, + "normal_aligned_to": "tool_axis:z", +} + +#: Every primary key the frozen interface allows. +PRIMARY_KEYS = frozenset( + { + "align_point", + "start_session", + "confirm_target", + "confirm_hold", + "confirm_liftoff", + "finish_session", + "closing", + } +) +SECONDARY_KEYS = ("remeasure", "skip", "stop") + +LOCALES = Path(__file__).resolve().parents[1] / "config" / "locales" + +_OMITTED = object() +_SOLVER = object() + + +# --------------------------------------------------------------------------- setup + + +def _campaign(*, levels=(5.0,), hold_s=HOLD_S, repeats=1, sites=("P1",)) -> Campaign: + """A campaign whose points are solved, as the finger page would have left them.""" + campaign = Campaign() + for site_id in sites: + campaign.add_solved_site( + site_id, + 1.0, + 2.0, + 3.0, + (0.0, 0.0, 1.0), + dict(POSE), + list(levels), + hold_s, + repeats, + max_force_n=MAX_FORCE, + ) + return campaign + + +def _start_arguments(root): + """The window state ``Campaign.start`` needs, as the page supplies it.""" + + def arguments(): + return { + "root": root, + "sensor_model": "finger", + "sensor_sn": "SN1", + "coordinate_frame": "sensor", + "max_force_n": MAX_FORCE, + "simulate": True, + } + + return arguments + + +def _controller( + campaign, + root, + *, + solver=_OMITTED, + readiness=None, + max_force_n=MAX_FORCE, + start_arguments=_OMITTED, +) -> CaptureController: + return CaptureController( + campaign, + solver=_SOLVER if solver is _OMITTED else solver, + readiness=readiness, + max_force_n=max_force_n, + start_arguments=( + _start_arguments(root) if start_arguments is _OMITTED else start_arguments + ), + ) + + +def _sleep_hold() -> None: + """Wait past the configured hold, which ``complete_hold`` rejects before.""" + time.sleep(HOLD_S + 0.01) + + +class _FakeRecorder: + """The writer interface ``Campaign`` reads through its guard and summary. + + Only the attributes used by the skip guard and ``capture_summary`` exist: the + test is about refusing to skip, not about recording frames. + """ + + is_recording = True + + def stats(self) -> types.SimpleNamespace: + return types.SimpleNamespace() + + +def _aligned_and_started(controller: CaptureController, campaign: Campaign) -> None: + """Walk the idle rung: request the move, confirm it, then start the session.""" + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + controller.perform("start_session") + assert campaign.phase == "load" + + +def _drive_step(controller: CaptureController, campaign: Campaign) -> None: + """Confirm the target and finish the hold of the step the campaign is on.""" + controller.perform("confirm_target", force_n=float(campaign.progress()["target_n"])) + assert campaign.phase == "hold" + _sleep_hold() + controller.perform("confirm_hold") + + +def _drive_repeat(controller: CaptureController, campaign: Campaign) -> None: + """Drive the open repeat(s) of the point in focus, stopping at liftoff or done.""" + while campaign.phase == "load": + _drive_step(controller, campaign) + + +def _assert_action_reasons(controller: CaptureController) -> None: + """The P3 exit rule: a disabled action always names a locale key, a ready one never.""" + actions = [controller.primary_action(), *controller.secondary_actions()] + for action in actions: + assert action.key in PRIMARY_KEYS | set(SECONDARY_KEYS), action + if action.enabled: + assert action.blocked_reason == "", action + else: + assert action.blocked_reason, action + assert action.blocked_reason.startswith("cap."), action + + +def _manifest(campaign: Campaign) -> dict: + return json.loads((campaign.directory / "session.json").read_text(encoding="utf-8")) + + +def _reference_rows(campaign: Campaign) -> dict[str, list[dict]]: + rows: dict[str, list[dict]] = {} + for trial in _manifest(campaign)["trials"]: + path = campaign.directory / trial["directory"] / "reference.csv" + with path.open(encoding="utf-8-sig", newline="") as handle: + rows[trial["directory"]] = list(csv.DictReader(handle)) + return rows + + +def _campaign_events(campaign: Campaign) -> list[dict]: + """The session-level events, one row per event.""" + path = campaign.directory / "events.csv" + with path.open(encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def _flat_locale(name: str) -> set[str]: + data = json.loads((LOCALES / f"{name}.json").read_text(encoding="utf-8")) + keys: set[str] = set() + + def walk(node, prefix=""): + for key, value in node.items(): + path = f"{prefix}{key}" + if isinstance(value, dict): + walk(value, f"{path}.") + else: + keys.add(path) + + walk(data) + return keys + + +# --------------------------------------------------------------------------- readiness + + +def test_readiness_reports_all_six_rows_in_the_frozen_order(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + + checks = controller.readiness() + + assert [check.key for check in checks] == list(READINESS_KEYS) + assert list(READINESS_KEYS) == [ + "calibration_bundle", + "input_frames", + "bus_ready", + "output_writable", + "sensor_sn", + "max_force", + ] + assert [check.label_key for check in checks] == [ + label for _key, label in READINESS_ITEMS + ] + assert all(check.ok for check in checks) + assert controller.ready + + +def test_a_failing_check_keeps_all_six_rows_and_blocks_the_start(tmp_dir): + def probe(): + return [ + ReadinessCheck( + "calibration_bundle", False, "cap.check.bundle", "bundle missing" + ) + ] + + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out", readiness=probe) + checks = {check.key: check for check in controller.readiness()} + + assert [check.key for check in controller.readiness()] == list(READINESS_KEYS) + assert checks["calibration_bundle"].ok is False + assert checks["calibration_bundle"].detail == "bundle missing" + # An unreported fact is not a satisfied one. + for key in ("input_frames", "bus_ready", "output_writable", "sensor_sn"): + assert checks[key].ok is False + assert checks[key].detail == "the readiness probe did not report this check" + assert controller.ready is False + + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + action = controller.primary_action() + assert action.key == "start_session" + assert action.enabled is False + assert action.blocked_reason == "cap.block.not_ready" + with pytest.raises(ActionBlocked) as info: + controller.perform("start_session") + assert info.value.reason == "cap.block.not_ready" + + +def test_a_probe_that_raises_does_not_break_the_page(tmp_dir): + def probe(): + raise RuntimeError("probe exploded") + + controller = _controller(_campaign(), tmp_dir / "out", readiness=probe) + checks = {check.key: check for check in controller.readiness()} + + assert checks["calibration_bundle"].ok is False + assert checks["calibration_bundle"].detail == "probe exploded" + assert checks["max_force"].ok is True # the controller owns that fact + assert controller.ready is False + + +@pytest.mark.parametrize("max_force", [None, 0.0, -1.0]) +def test_max_force_is_the_controllers_own_fact(tmp_dir, max_force): + def probe(): + # Even a probe that claims the force is fine cannot set this fact. + return [ReadinessCheck(key, True, label) for key, label in READINESS_ITEMS] + + controller = _controller( + _campaign(), tmp_dir / "out", readiness=probe, max_force_n=max_force + ) + checks = {check.key: check for check in controller.readiness()} + + assert checks["max_force"].ok is False + assert checks["max_force"].detail == "max_force_n is not set" + assert controller.ready is False + + +def test_a_max_force_above_zero_passes_the_sixth_check(tmp_dir): + controller = _controller(_campaign(), tmp_dir / "out", max_force_n=0.001) + checks = {check.key: check for check in controller.readiness()} + assert checks["max_force"].ok is True + assert checks["max_force"].detail == "" + + +# --------------------------------------------------------------------------- the ladder + + +def test_the_primary_button_walks_the_whole_load_ladder(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + observed: list[tuple[str, str, bool]] = [] + + def snapshot(label: str): + action = controller.primary_action() + _assert_action_reasons(controller) + observed.append((label, action.key, action.enabled)) + return action + + action = snapshot("idle") + assert (action.key, action.enabled) == ("align_point", True) + controller.perform("align_point") + request = controller.pending_alignment + assert request is not None + assert request.site_id == "P1" + assert request.targets == { + key: POSE[key] for key in ("x", "y", "yaw", "pitch", "z") + } + controller.pending_alignment = None + assert controller.pending_alignment is None + # The request alone did not move anything; the window confirms the move. + controller.mark_aligned() + + action = snapshot("aligned") + assert (action.key, action.enabled) == ("start_session", True) + controller.perform("start_session") + assert campaign.phase == "load" + + for index in range(3): + action = snapshot(f"load{index}") + assert (action.key, action.enabled) == ("confirm_target", True) + # Simulated capture never records, so the alignment guard is not involved. + assert campaign.capture_state == "idle" + controller.perform( + "confirm_target", force_n=float(campaign.progress()["target_n"]) + ) + action = snapshot(f"hold{index}-waiting") + assert action.key == "confirm_hold" + assert action.enabled is False + assert action.blocked_reason == "cap.block.hold_running" + _sleep_hold() + action = snapshot(f"hold{index}") + assert (action.key, action.enabled) == ("confirm_hold", True) + controller.perform("confirm_hold") + + action = snapshot("done") + assert (action.key, action.enabled) == ("finish_session", True) + controller.perform("finish_session") + # Finishing closes the session out; the files stay as they were. + assert campaign.phase == "idle" + + assert observed == [ + ("idle", "align_point", True), + ("aligned", "start_session", True), + ("load0", "confirm_target", True), + ("hold0-waiting", "confirm_hold", False), + ("hold0", "confirm_hold", True), + ("load1", "confirm_target", True), + ("hold1-waiting", "confirm_hold", False), + ("hold1", "confirm_hold", True), + ("load2", "confirm_target", True), + ("hold2-waiting", "confirm_hold", False), + ("hold2", "confirm_hold", True), + ("done", "finish_session", True), + ] + assert _manifest(campaign)["status"] == "complete" + rows = _reference_rows(campaign)["sites/P1/repeat-001"] + assert [row["branch"] for row in rows] == ["baseline", "load", "unload"] + assert [row["hold_complete"] for row in rows] == ["1", "1", "1"] + + +def test_the_next_repeat_of_a_point_needs_no_alignment(tmp_dir): + campaign = _campaign(repeats=2) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + + for _step in range(3): # one repeat: baseline, load, unload + _drive_step(controller, campaign) + + assert campaign.phase == "load" # the second repeat opened directly + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_target", True) + + for _step in range(3): + _drive_step(controller, campaign) + assert campaign.phase == "done" + assert _manifest(campaign)["status"] == "complete" + assert len(_reference_rows(campaign)) == 2 # two repeats recorded + + +def test_the_next_point_is_aligned_during_liftoff(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + + assert campaign.phase == "liftoff" + state = controller.poll() + assert state["pending_alignment_site"] == "P2" + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", True) + controller.perform("align_point") + request = controller.pending_alignment + assert request is not None and request.site_id == "P2" + controller.pending_alignment = None + controller.mark_aligned() + + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_liftoff", True) + controller.perform("confirm_liftoff") + assert campaign.phase == "load" + assert campaign.progress()["site_id"] == "P2" + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_target", True) + + _drive_repeat(controller, campaign) + assert campaign.phase == "done" + assert [row.site_id for row in controller.model().sites] == ["P1", "P2"] + + +def test_without_a_solver_the_liftoff_still_advances(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + + # The solver goes away after the session started: aligning the next point is + # impossible, so the ladder must still offer the liftoff confirmation rather + # than leave the operator stuck. + controller.solver = None + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_liftoff", True) + controller.perform("confirm_liftoff") + assert campaign.phase == "load" + assert campaign.progress()["site_id"] == "P2" + + +# --------------------------------------------------------------------------- alignment confirmation + + +def test_an_alignment_request_alone_does_not_make_the_point_ready(tmp_dir): + """A requested move can still be refused, cancelled or fail: no auto-confirm.""" + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "idle") + + controller.perform("align_point") + + assert controller.pending_alignment is not None # the request is out + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", True) + # Start is not even offered: an unconfirmed pose must never begin a session. + with pytest.raises(ValueError): + controller.perform("start_session") + + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "liftoff") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + assert campaign.phase == "liftoff" + + controller.perform("align_point") + assert controller.pending_alignment is not None + assert controller.primary_action().key == "align_point" + with pytest.raises(ValueError): + controller.perform("confirm_liftoff") + + +def test_marking_the_move_on_offers_the_next_step(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "idle") + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + + action = controller.primary_action() + assert (action.key, action.enabled) == ("start_session", True) + + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "liftoff") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_liftoff", True) + + +def test_a_failed_move_leaves_the_point_unaligned_and_retryable(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + controller.perform("align_point") + controller.pending_alignment = None + + controller.mark_alignment_failed() # refused, cancelled or failed on the way + + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", True) + with pytest.raises(ValueError): + controller.perform("start_session") + + # The retry path is still available, and an explicit site id is accepted. + controller.perform("align_point") + assert controller.pending_alignment is not None + controller.pending_alignment = None + controller.mark_aligned("P1") + assert controller.primary_action().key == "start_session" + + controller.mark_alignment_failed("P1") + assert controller.primary_action().key == "align_point" + + +def test_the_result_report_tolerates_a_queue_with_no_point_in_focus(tmp_dir): + controller = _controller(_campaign(sites=()), tmp_dir / "empty") + + controller.mark_alignment_failed() # an error path must never raise + with pytest.raises(ValueError): + controller.mark_aligned() # a confirmation with no point is a caller bug + + +# --------------------------------------------------------------------------- disabled actions + + +def test_start_session_reports_each_missing_precondition(tmp_dir): + empty = _campaign(sites=()) + action = _controller(empty, tmp_dir / "empty").primary_action() + assert (action.key, action.enabled) == ("start_session", False) + assert action.blocked_reason == "cap.block.no_sites" + + no_arguments = _campaign() + controller = _controller(no_arguments, tmp_dir / "arguments", start_arguments=None) + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + action = controller.primary_action() + assert (action.key, action.enabled) == ("start_session", False) + assert action.blocked_reason == "cap.block.no_session" + with pytest.raises(ActionBlocked) as info: + controller.perform("start_session") + assert info.value.reason == "cap.block.no_session" + + +def test_align_point_reports_whether_the_point_can_be_commanded(tmp_dir): + no_solver = _controller(_campaign(), tmp_dir / "solver", solver=None) + action = no_solver.primary_action() + assert (action.key, action.enabled) == ("align_point", False) + assert action.blocked_reason == "cap.block.no_solver" + with pytest.raises(ActionBlocked) as info: + no_solver.perform("align_point") + assert info.value.reason == "cap.block.no_solver" + + unposed = Campaign() + unposed.add_site("P1", 1.0, 2.0, 3.0, [5.0], HOLD_S, 1, max_force_n=MAX_FORCE) + controller = _controller(unposed, tmp_dir / "pose") + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", False) + assert action.blocked_reason == "cap.block.no_pose" + with pytest.raises(ActionBlocked) as info: + controller.perform("align_point") + assert info.value.reason == "cap.block.no_pose" + + +def test_a_move_in_the_middle_of_a_recording_is_refused(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + # What ``campaign.attach_capture`` does once the page has a camera frame. + campaign.capture_state = "recording" + + # The normal ladder never offers a move here; the guard is for a direct call. + assert controller.primary_action().key == "confirm_target" + with pytest.raises(ActionBlocked) as info: + controller.perform("align_point") + assert info.value.reason == "cap.block.align_while_recording" + assert info.value.label_key == "cap.action.align" + + controller.perform("confirm_target", force_n=0.0) + assert campaign.phase == "hold" + with pytest.raises(ActionBlocked) as info: + controller.perform("align_point") + assert info.value.reason == "cap.block.align_while_recording" + + +def _sweep_states(tmp_dir): + """Every distinct controller state a disabled reason can come from.""" + + def failing_probe(): + return [ReadinessCheck("bus_ready", False, "cap.check.bus")] + + cases = [] + + campaign = _campaign(sites=()) + cases.append(("empty", _controller(campaign, tmp_dir / "empty"), campaign)) + + campaign = _campaign() + cases.append(("idle", _controller(campaign, tmp_dir / "idle"), campaign)) + + campaign = _campaign() + cases.append( + ( + "idle-no-solver", + _controller(campaign, tmp_dir / "nossolver", solver=None), + campaign, + ) + ) + + unposed = Campaign() + unposed.add_site("P1", 1.0, 2.0, 3.0, [5.0], HOLD_S, 1, max_force_n=MAX_FORCE) + cases.append(("idle-no-pose", _controller(unposed, tmp_dir / "nopose"), unposed)) + + campaign = _campaign() + cases.append( + ( + "idle-not-ready", + _controller(campaign, tmp_dir / "notready", readiness=failing_probe), + campaign, + ) + ) + + campaign = _campaign() + cases.append( + ( + "idle-no-session", + _controller(campaign, tmp_dir / "nosession", start_arguments=None), + campaign, + ) + ) + + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "load") + _aligned_and_started(controller, campaign) + cases.append(("load", controller, campaign)) + + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "hold") + _aligned_and_started(controller, campaign) + controller.perform("confirm_target", force_n=0.0) + cases.append(("hold", controller, campaign)) + + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "recording") + _aligned_and_started(controller, campaign) + campaign.capture_state = "recording" + cases.append(("recording", controller, campaign)) + + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "liftoff") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + cases.append(("liftoff", controller, campaign)) + + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "closing") + campaign.capture_state = "closing" + cases.append(("closing", controller, campaign)) + + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "done") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + cases.append(("done", controller, campaign)) + + return cases + + +def test_every_disabled_action_names_a_locale_reason_and_the_key_exists(tmp_dir): + referenced: set[str] = set() + for name, controller, _campaign_ in _sweep_states(tmp_dir): + actions = [controller.primary_action(), *controller.secondary_actions()] + for action in actions: + assert action.key in PRIMARY_KEYS | set(SECONDARY_KEYS), (name, action) + referenced.add(action.label_key) + if action.enabled: + assert action.blocked_reason == "", (name, action) + else: + assert action.blocked_reason, (name, action) + assert action.blocked_reason.startswith("cap."), (name, action) + referenced.add(action.blocked_reason) + + # A reason is a locale key: a key the pages cannot translate is a blank label. + for language in ("zh_CN", "en_US"): + missing = referenced - _flat_locale(language) + assert not missing, (language, sorted(missing)) + + +# --------------------------------------------------------------------------- skip + + +def test_skip_is_offered_while_a_point_is_open_and_not_recording(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + + action = {item.key: item for item in controller.secondary_actions()}["skip"] + assert action.key == "skip" + assert action.enabled is True + assert action.blocked_reason == "" + + +@pytest.mark.parametrize( + ("sites", "expected_phase", "expected_status"), + [ + (("P1", "P2"), "liftoff", "in_progress"), + (("P1",), "done", "incomplete"), + ], +) +def test_skip_advances_the_campaign_past_the_current_point( + tmp_dir, sites, expected_phase, expected_status +): + campaign = _campaign(sites=sites) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + if len(sites) > 1: + _drive_step(controller, campaign) # leaves one finished hold to roll back + assert campaign._completed_holds == 1 # the counter is the claim + + controller.perform("skip") + + assert campaign.phase == expected_phase + assert campaign._completed_holds == 0 + manifest = _manifest(campaign) + assert manifest["status"] == expected_status + assert manifest["trials"] == [] # the unfinished record is removed, not re-statused + assert not (campaign.directory / "sites" / "P1" / "repeat-001").exists() + assert [site.site_id for site in campaign.sites] == list(sites) + + skipped = [ + row for row in _campaign_events(campaign) if row["kind"] == "site_skipped" + ] + assert skipped + assert json.loads(skipped[-1]["detail"])["site_id"] == "P1" + + if expected_phase == "liftoff": + assert campaign.pending_alignment_site == "P2" + else: + assert controller.primary_action().key == "finish_session" + + +def test_skip_never_touches_a_recording_repeat(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + # What the page looks like once ``campaign.attach_capture`` owns the repeat. + campaign._recorder = _FakeRecorder() # the writer guard is the claim + campaign.capture_state = "recording" + + with pytest.raises(RuntimeError, match="stop the recording before skipping"): + campaign.skip_current_point() + + action = {item.key: item for item in controller.secondary_actions()}["skip"] + assert action.enabled is False + assert action.blocked_reason == "cap.block.skip_live" + with pytest.raises(ActionBlocked) as info: + controller.perform("skip") + assert info.value.reason == "cap.block.skip_live" + + +@pytest.mark.parametrize("state", ["idle", "done", "closing"]) +def test_skip_is_busy_when_no_repeat_is_open(tmp_dir, state): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / state) + if state == "done": + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + assert campaign.phase == "done" + elif state == "closing": + campaign.capture_state = "closing" + + action = {item.key: item for item in controller.secondary_actions()}["skip"] + assert action.enabled is False + assert action.blocked_reason == "cap.block.busy" + with pytest.raises(ActionBlocked) as info: + controller.perform("skip") + assert info.value.reason == "cap.block.busy" + + +# --------------------------------------------------------------------------- stop / closing + + +def _state_for(root: Path, state: str) -> tuple[CaptureController, Campaign]: + if state == "idle": + campaign = _campaign() + return _controller(campaign, root / "idle"), campaign + campaign = _campaign(sites=("P1", "P2")) if state == "liftoff" else _campaign() + controller = _controller(campaign, root / state) + _aligned_and_started(controller, campaign) + if state == "hold": + controller.perform("confirm_target", force_n=0.0) + elif state in {"liftoff", "done"}: + _drive_repeat(controller, campaign) + return controller, campaign + + +@pytest.mark.parametrize("state", ["idle", "load", "hold", "liftoff", "done"]) +def test_stop_is_offered_exactly_while_a_session_is_running(tmp_dir, state): + controller, campaign = _state_for(tmp_dir, state) + action = {item.key: item for item in controller.secondary_actions()}["stop"] + + if state in {"idle", "done"}: + assert action.enabled is False + assert action.blocked_reason == "cap.block.stop_unavailable" + with pytest.raises(ActionBlocked) as info: + controller.perform("stop") + assert info.value.reason == "cap.block.stop_unavailable" + return + + assert action.enabled is True + controller.perform("stop") + assert campaign.phase == "aborted" + assert campaign.is_active is False + manifest = _manifest(campaign) + assert manifest["status"] == "incomplete" + assert manifest["reason"] == "operator stopped" + action = controller.primary_action() + assert (action.key, action.enabled) == ("finish_session", True) + + +def test_stop_can_carry_the_shutdown_reason(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + + controller.perform("stop", reason="application closed") + + assert campaign.phase == "aborted" + assert _manifest(campaign)["reason"] == "application closed" + + +def test_a_blank_stop_reason_is_refused_before_stopping(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + + with pytest.raises(ValueError): + controller.perform("stop", reason=" ") + + # The refusal happens before the campaign is touched: the session still runs. + assert campaign.phase == "load" + assert campaign.is_active is True + + controller.perform("stop", reason=None) # the default wording still applies + assert campaign.phase == "aborted" + assert _manifest(campaign)["reason"] == "operator stopped" + + +def test_closing_is_a_read_only_primary_action(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + campaign.capture_state = "closing" + + action = controller.primary_action() + assert (action.key, action.label_key) == ("closing", "cap.block.closing") + assert action.enabled is False + assert action.blocked_reason == "cap.block.closing" + with pytest.raises(ActionBlocked) as info: + controller.perform("closing") + assert info.value.reason == "cap.block.closing" + assert info.value.label_key == "cap.block.closing" + + # poll() is what finalises the close; the page must call it every tick. + state = controller.poll() + assert state["capture_state"] != "closing" + + +# --------------------------------------------------------------------------- queue editing + + +def _six_axis_rotation() -> list[float]: + return [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + + +def test_the_model_carries_every_field_the_queue_renders(tmp_dir): + campaign = Campaign() + controller = _controller(campaign, tmp_dir / "out") + controller.add_point( + "P1", + 1.0, + 2.0, + 3.0, + (0.0, 0.0, 1.0), + dict(POSE), + [5.0, 10.0], + HOLD_S, + 2, + uv_px=(11.0, 22.0), + ) + + row = controller.model().sites[0] + site = campaign.sites[0] + assert row.site_id == site.site_id == "P1" + assert row.position == {"x": 1.0, "y": 2.0, "z": 3.0} + assert row.repeats_planned == site.repeats == 2 + assert row.repeats_complete == 0 + assert row.complete is False + assert row.reachable is True + assert row.levels == tuple(site.levels_n) == (5.0, 10.0) + assert row.hold_s == site.hold_s == HOLD_S + assert row.uv_px == site.uv_px == (11.0, 22.0) + assert row.normal == site.normal == (0.0, 0.0, 1.0) + assert row.pose == site.pose + assert row.pose["x"] == POSE["x"] + assert row.pose is not site.pose # a snapshot, not a handle into the campaign + assert row.position_frame == site.position_frame == "sensor" + assert row.skipped is False + + # A point without a solved pose still renders: the optional fields are None. + plain = Campaign() + plain.add_site("P9", 1.0, 2.0, 3.0, [5.0], HOLD_S, 1, max_force_n=MAX_FORCE) + blank = _controller(plain, tmp_dir / "plain").model().sites[0] + assert (blank.normal, blank.pose, blank.uv_px) == (None, None, None) + assert blank.position_frame == "" + + +def test_the_queue_can_be_edited_while_idle(tmp_dir): + campaign = Campaign() + controller = _controller(campaign, tmp_dir / "out") + controller.add_point( + "P1", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ) + controller.add_point( + "P2", 4.0, 5.0, 6.0, (0.0, 0.0, 1.0), dict(POSE), [10.0], HOLD_S, 2 + ) + assert [site.site_id for site in campaign.sites] == ["P1", "P2"] + + controller.update_point("P2", 7.0, 8.0, 9.0, [10.0, 15.0], 0.5, 3, new_id="P2b") + + updated = campaign.sites[1] + assert updated.site_id == "P2b" + assert (updated.x, updated.y, updated.z) == (7.0, 8.0, 9.0) + assert tuple(updated.levels_n) == (10.0, 15.0) + assert updated.hold_s == 0.5 + assert updated.repeats == 3 + + controller.move_point("P2b", -1) + assert [site.site_id for site in campaign.sites] == ["P2b", "P1"] + + controller.set_force_rotation(_six_axis_rotation()) + assert campaign._rotation is not None # the rotation the campaign will use + + controller.remove_point("P1") + assert [site.site_id for site in campaign.sites] == ["P2b"] + + +@pytest.mark.parametrize("state", ["load", "hold", "liftoff", "done"]) +def test_the_queue_cannot_be_edited_outside_idle(tmp_dir, state): + controller, campaign = _state_for(tmp_dir, state) + before = [(site.site_id, site.x, site.y, site.z) for site in campaign.sites] + calls = ( + lambda: controller.add_point( + "PX", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ), + lambda: controller.update_point("P1", 1.0, 2.0, 3.0, [5.0], HOLD_S, 1), + lambda: controller.remove_point("P1"), + lambda: controller.move_point("P1", 1), + lambda: controller.set_force_rotation(_six_axis_rotation()), + lambda: controller.store_solved_pose("P1", dict(POSE)), + ) + for call in calls: + with pytest.raises(ActionBlocked) as info: + call() + assert info.value.reason == "cap.block.queue_busy" + assert [(site.site_id, site.x, site.y, site.z) for site in campaign.sites] == before + + +def test_adding_or_updating_a_point_needs_a_maximum_force(tmp_dir): + campaign = Campaign() + controller = _controller(campaign, tmp_dir / "out", max_force_n=None) + + with pytest.raises(ActionBlocked) as info: + controller.add_point( + "P1", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ) + assert info.value.reason == "cap.block.no_max_force" + with pytest.raises(ActionBlocked) as info: + controller.update_point("P1", 1.0, 2.0, 3.0, [5.0], HOLD_S, 1) + assert info.value.reason == "cap.block.no_max_force" + assert campaign.sites == [] + + # Both reasons are locale keys the pages can translate. + for language in ("zh_CN", "en_US"): + keys = _flat_locale(language) + assert {"cap.block.queue_busy", "cap.block.no_max_force"} <= keys + + +def test_storing_a_re_solved_pose_reaches_the_model_as_a_copy(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + before = controller.model().sites[0].pose + assert before is not None + + solved = dict(POSE, x=42.0, y=-3.0, z=17.5, residual_deg=0.25) + controller.store_solved_pose("P1", solved) + + row = controller.model().sites[0] + site = campaign.sites[0] + assert row.pose == site.pose + assert row.pose["x"] == 42.0 + assert row.pose["residual_deg"] == 0.25 + assert row.pose is not site.pose # the row is a snapshot, not the record + assert row.normal == site.normal # a pose store does not touch the normal + + # A re-solve drops the "this point is aligned" flag: the old move aimed elsewhere. + controller.mark_aligned() + assert controller.primary_action().key == "start_session" + controller.store_solved_pose("P1", dict(POSE, residual_deg=0.5)) + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", True) + + +def test_setting_the_maximum_force_reopens_the_queue(tmp_dir): + campaign = Campaign() + controller = _controller(campaign, tmp_dir / "out", max_force_n=None) + assert {check.key: check for check in controller.readiness()}[ + "max_force" + ].ok is False + with pytest.raises(ActionBlocked) as info: + controller.add_point( + "P1", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ) + assert info.value.reason == "cap.block.no_max_force" + + controller.set_max_force(25.0) + + checks = {check.key: check for check in controller.readiness()} + assert checks["max_force"].ok is True + assert controller.ready is True + controller.add_point( + "P1", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [20.0], HOLD_S, 1 + ) + assert [site.site_id for site in campaign.sites] == ["P1"] + # The new value is what a queue edit is validated against. + with pytest.raises(ValueError): + controller.add_point( + "P2", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [30.0], HOLD_S, 1 + ) + controller.set_max_force(40.0) + controller.add_point( + "P2", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [30.0], HOLD_S, 1 + ) + assert [site.site_id for site in campaign.sites] == ["P1", "P2"] + + controller.set_max_force(None) + assert {check.key: check for check in controller.readiness()}[ + "max_force" + ].ok is False + with pytest.raises(ActionBlocked) as info: + controller.add_point( + "P3", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ) + assert info.value.reason == "cap.block.no_max_force" + + +def test_a_skipped_point_is_marked_in_the_queue(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + assert [row.skipped for row in controller.model().sites] == [False, False] + + controller.perform("skip") + + assert [row.skipped for row in controller.model().sites] == [True, False] + assert [row.site_id for row in controller.model().sites] == ["P1", "P2"] + + +def test_starting_a_session_clears_stale_skip_marks(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + controller._skipped.add("P1") # a mark a finished session would leave behind + assert controller.model().sites[0].skipped is True + + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + controller.perform("start_session") + + assert controller.model().sites[0].skipped is False + + +def test_removing_a_point_forgets_its_skip_mark(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + controller._skipped.add("P2") + assert controller.model().sites[1].skipped is True + + controller.remove_point("P2") + assert [row.site_id for row in controller.model().sites] == ["P1"] + + controller.add_point( + "P2", 1.0, 2.0, 3.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1 + ) + assert controller.model().sites[1].skipped is False + + +# --------------------------------------------------------------------------- poll / model / perform + + +def test_poll_advances_closing_and_reports_the_frozen_contract(tmp_dir, monkeypatch): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + controller.perform("confirm_target", force_n=0.0) + assert campaign.phase == "hold" + + calls: list[bool] = [] + original = campaign.poll + + def spy(): + calls.append(True) + return original() + + monkeypatch.setattr(campaign, "poll", spy) + state = controller.poll() + + assert calls == [True] + assert set(state) == { + "phase", + "capture_state", + "capture_reason", + "remaining_s", + "pending_alignment_site", + } + assert state["phase"] == "hold" + assert state["remaining_s"] > 0 + assert state["pending_alignment_site"] == "" + + +def test_the_model_reports_the_queue_and_follows_the_campaign(tmp_dir): + campaign = _campaign(sites=("P1", "P2"), repeats=2) + controller = _controller(campaign, tmp_dir / "out") + + model = controller.model() + assert [row.site_id for row in model.sites] == ["P1", "P2"] + assert model.sites[0].position == {"x": 1.0, "y": 2.0, "z": 3.0} + assert model.sites[0].repeats_planned == 2 + assert model.sites[0].repeats_complete == 0 + assert model.sites[0].complete is False + assert all(row.reachable for row in model.sites) + assert model.current_index == 0 + assert set(model.progress) == set(campaign.progress()) + + _aligned_and_started(controller, campaign) + for _step in range(3): + _drive_step(controller, campaign) + assert controller.model().sites[0].repeats_complete == 1 + assert controller.model().current_index == 0 + + for _step in range(3): # finish P1's two repeats, then liftoff to P2 + _drive_step(controller, campaign) + model = controller.model() + assert model.current_index == 1 + assert model.sites[0].complete is True + assert model.sites[0].repeats_complete == 2 + + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + controller.perform("confirm_liftoff") + assert controller.model().current_index == 1 + + +def test_the_model_marks_an_unreachable_pose(tmp_dir): + campaign = Campaign() + bad_pose = dict(POSE, in_range=False, ok=False, reason="out of travel") + campaign.add_solved_site( + "P1", + 1.0, + 2.0, + 3.0, + (0.0, 0.0, 1.0), + bad_pose, + [5.0], + HOLD_S, + 1, + max_force_n=MAX_FORCE, + ) + controller = _controller(campaign, tmp_dir / "out") + + model = controller.model() + assert model.sites[0].reachable is False + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", False) + assert action.blocked_reason == "cap.block.no_pose" + + +def test_perform_rejects_keys_and_inputs_the_contract_does_not_offer(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + + with pytest.raises(ValueError): + controller.perform("nonsense") + # confirm_target is not the current action while the page is idle. + with pytest.raises(ValueError): + controller.perform("confirm_target", force_n=1.0) + + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + controller.perform("start_session") + + with pytest.raises(ValueError): + controller.perform("confirm_target") # neither force nor wrench + with pytest.raises(ValueError): + controller.perform("confirm_target", force_n=1.0, wrench=(1.0, 2.0, 3.0)) + with pytest.raises(ValueError): + controller.perform("confirm_target", force_n=1.0, extra=True) + + +def test_a_six_axis_reference_carries_its_rotation(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + rotation = [0.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] + + # A rotation belongs to the six-axis reading, never to a weight reading. + with pytest.raises(ValueError): + controller.perform("confirm_target", force_n=1.0, rotation=rotation) + with pytest.raises(ValueError): + controller.perform("confirm_target", rotation=rotation) # no wrench either + + controller.perform("confirm_target", wrench=(1.0, 2.0, 5.0), rotation=rotation) + + assert campaign.phase == "hold" + manifest = _manifest(campaign) + assert manifest["force_to_tool_rotation"] == rotation + _sleep_hold() + controller.perform("confirm_hold") + + first = _reference_rows(campaign)["sites/P1/repeat-001"][0] + assert first["step_index"] == "0" + assert first["reference_source"] == "wrench" + assert (float(first["fx_N"]), float(first["fy_N"]), float(first["fz_N"])) == ( + 1.0, + 2.0, + 5.0, + ) + # The projection is (R @ F) · tool_axis; it is not a raw axis. + assert float(first["F_ref_normal_N"]) == pytest.approx(5.0) + assert first["force_to_tool_rotation"] == ("0.0 -1.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0") + assert first["hold_complete"] == "1" + + # The rotation is optional: the next confirmation reuses the one just entered. + controller.perform("confirm_target", wrench=(0.0, 0.0, 4.0)) + assert campaign.phase == "hold" + _sleep_hold() + controller.perform("confirm_hold") + second = _reference_rows(campaign)["sites/P1/repeat-001"][1] + assert second["force_to_tool_rotation"] == first["force_to_tool_rotation"] + assert float(second["F_ref_normal_N"]) == pytest.approx(4.0) + + +# --------------------------------------------------------------------------- remeasure / reacquire + + +def test_remeasure_discards_a_hold_in_progress(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + controller.perform("confirm_target", force_n=0.0) + + actions = {item.key: item for item in controller.secondary_actions()} + assert actions["remeasure"].enabled is True + controller.perform("remeasure") + + assert campaign.phase == "load" + assert campaign.progress()["target_n"] == 0.0 # the same step is waiting again + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_target", True) + + controller.perform("confirm_target", force_n=0.0) + _sleep_hold() + controller.perform("confirm_hold") + rows = _reference_rows(campaign)["sites/P1/repeat-001"] + assert [row["hold_complete"] for row in rows] == ["0", "1"] + assert {row["step_index"] for row in rows} == {"0"} + + +def test_remeasure_reopens_the_step_that_just_finished(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _drive_step(controller, campaign) + assert campaign.phase == "load" + assert campaign.progress()["target_n"] == 5.0 + + actions = {item.key: item for item in controller.secondary_actions()} + assert actions["remeasure"].enabled is True + controller.perform("remeasure") + + assert campaign.phase == "load" + assert campaign.progress()["target_n"] == 0.0 # the finished step is open again + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_target", True) + + +def test_reacquire_follows_the_campaign_and_keeps_the_point_aligned(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) # single point: the session finishes + assert campaign.phase == "done" + assert controller.primary_action().key == "finish_session" + + campaign.reacquire("P1") + + state = controller.poll() + assert state["phase"] == "load" + action = controller.primary_action() + assert (action.key, action.enabled) == ("confirm_target", True) # no second move + model = controller.model() + assert model.current_index == 0 + assert model.sites[0].repeats_complete == 1 + + _drive_repeat(controller, campaign) # the reacquired repeat + assert campaign.phase == "liftoff" # reacquire reopens the point by itself + controller.perform("confirm_liftoff") + assert campaign.phase == "done" + model = controller.model() + assert model.sites[0].repeats_complete == 2 + assert model.sites[0].complete is True + + +# --------------------------------------------------------------------------- purity and structure + + +def test_the_controller_module_imports_no_qt_and_no_motion_service(): + path = Path(capture_controller.__file__) + tree = ast.parse(path.read_text(encoding="utf-8")) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + if node.level: + module = "." * node.level + module + imported.add(module) + for module in imported: + assert not module.startswith(("PySide", "PyQt")), module + assert module not in {"orisys_autocal.motion.service", ".motion.service"}, ( + module + ) + assert ".multipoint" in imported # it wraps a Campaign and nothing lower + + +VOLATILE_SESSION_KEYS = {"session_id", "started_at", "ended_at"} +VOLATILE_REFERENCE_COLUMNS = {"t_start_host_mono_ns", "t_end_host_mono_ns"} +VOLATILE_EVENT_COLUMNS = {"t_host_mono_ns", "t_host_utc_ns"} + + +def _masked(node, volatile: set[str]): + if isinstance(node, dict): + return { + key: ("" if key in volatile else _masked(value, volatile)) + for key, value in node.items() + } + if isinstance(node, list): + return [_masked(item, volatile) for item in node] + return node + + +def _masked_rows(rows: list[dict], volatile: set[str]) -> list[dict]: + return [ + {key: ("" if key in volatile else value) for key, value in row.items()} + for row in rows + ] + + +def _masked_events(path: Path) -> list[dict]: + with path.open(encoding="utf-8-sig", newline="") as handle: + return _masked_rows(list(csv.DictReader(handle)), VOLATILE_EVENT_COLUMNS) + + +def _tree(root: Path) -> list[str]: + return sorted( + path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file() + ) + + +def _drive_direct(campaign: Campaign, root: Path) -> Campaign: + """Drive the campaign without the controller, as the old page did.""" + campaign.start( + root, + sensor_model="finger", + sensor_sn="SN1", + coordinate_frame="sensor", + max_force_n=MAX_FORCE, + simulate=True, + ) + for _tick in range(200): + phase = campaign.phase + if phase == "done": + return campaign + if phase == "load": + campaign.confirm_reference(float(campaign.progress()["target_n"])) + elif phase == "hold": + _sleep_hold() + campaign.complete_hold() + elif phase == "liftoff": + campaign.confirm_liftoff() + elif phase == "closing": + campaign.poll() + else: + raise AssertionError(f"unexpected phase {phase}") + raise AssertionError("the directly driven campaign never finished") + + +def _drive_through_controller( + controller: CaptureController, campaign: Campaign +) -> None: + """Drive the same campaign through the primary button only.""" + for _tick in range(200): + if campaign.phase == "done": + return + action = controller.primary_action() + if action.key == "align_point": + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() # the window says the planned move finished + elif action.key == "start_session": + controller.perform("start_session") + elif action.key == "confirm_target": + controller.perform( + "confirm_target", force_n=float(campaign.progress()["target_n"]) + ) + elif action.key == "confirm_hold": + if not action.enabled: + _sleep_hold() + continue + controller.perform("confirm_hold") + elif action.key in {"confirm_liftoff", "finish_session"}: + controller.perform(action.key) + elif action.key == "closing": + controller.poll() + else: + raise AssertionError(f"unexpected primary action {action}") + raise AssertionError("the controller-driven campaign never finished") + + +def test_the_controller_writes_the_same_records_as_the_campaign(tmp_dir): + """The page must not produce a different session format than a direct campaign.""" + + def build() -> Campaign: + return _campaign(levels=(5.0,), repeats=1, sites=("P1", "P2")) + + direct = _drive_direct(build(), tmp_dir / "direct") + campaign = build() + controller = _controller(campaign, tmp_dir / "controller") + _drive_through_controller(controller, campaign) + assert direct.phase == campaign.phase == "done" + + assert direct.directory is not None and campaign.directory is not None + assert _tree(direct.directory) == _tree(campaign.directory) + assert _masked(_manifest(direct), VOLATILE_SESSION_KEYS) == _masked( + _manifest(campaign), VOLATILE_SESSION_KEYS + ) + + direct_rows = _reference_rows(direct) + controller_rows = _reference_rows(campaign) + assert ( + sorted(direct_rows) + == sorted(controller_rows) + == [ + "sites/P1/repeat-001", + "sites/P2/repeat-001", + ] + ) + for directory, rows in direct_rows.items(): + assert list(rows[0]) == list(controller_rows[directory][0]) # header order + assert _masked_rows(rows, VOLATILE_REFERENCE_COLUMNS) == _masked_rows( + controller_rows[directory], VOLATILE_REFERENCE_COLUMNS + ) + + assert _masked_events(direct.directory / "events.csv") == _masked_events( + campaign.directory / "events.csv" + ) + for trial_dir in ("sites/P1/repeat-001", "sites/P2/repeat-001"): + assert _masked_events(direct.directory / trial_dir / "events.csv") == ( + _masked_events(campaign.directory / trial_dir / "events.csv") + ) + assert (direct.directory / "stable_means.csv").read_bytes() == ( + (campaign.directory / "stable_means.csv").read_bytes() + ) + + +# ------------------------------------------------- closing out, auto hold, no hardware + + +def _auto_step( + controller: CaptureController, campaign: Campaign, hold_s: float = HOLD_S +) -> None: + """Confirm the target, then let the planned hold time run out on its own.""" + controller.perform("confirm_target", force_n=float(campaign.progress()["target_n"])) + assert campaign.phase == "hold" + time.sleep(hold_s + 0.03) + controller.poll() + + +def _walk_to_the_end( + controller: CaptureController, campaign: Campaign, hold_s: float = HOLD_S +) -> None: + """Drive a started session to ``done`` using only the page's own calls.""" + for _ in range(60): + action = controller.primary_action() + if campaign.phase in {"done", "aborted"}: + return + if action.key == "confirm_target": + _auto_step(controller, campaign, hold_s) + elif action.key == "confirm_liftoff": + controller.perform("confirm_liftoff") + elif action.key == "align_point": + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + else: # pragma: no cover - a stuck walk is the failure to report + raise AssertionError(f"stuck at {action}") + raise AssertionError("the session never finished") # pragma: no cover + + +def test_finishing_a_session_returns_to_idle_and_a_new_one_can_start(tmp_dir): + """F1: the finish button must lead somewhere, not offer itself forever.""" + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _walk_to_the_end(controller, campaign) + first = campaign.directory + assert controller.primary_action().key == "finish_session" + manifest_before = (first / "session.json").read_bytes() + + controller.perform("finish_session") + + assert campaign.phase == "idle" + # The finished session's evidence is exactly as it was. + assert campaign.directory == first + assert (first / "session.json").read_bytes() == manifest_before + # The point is no longer aligned: the platform may have moved since. + action = controller.primary_action() + assert action.key != "finish_session" + assert (action.key, action.enabled) == ("align_point", True) + # The queue is editable again, and a second session can start and finish. + controller.add_point("P2", 4.0, 5.0, 6.0, (0.0, 0.0, 1.0), dict(POSE), [5.0], HOLD_S, 1) + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned("P1") + controller.perform("start_session") + assert campaign.phase == "load" + assert campaign.directory != first + _walk_to_the_end(controller, campaign) + assert campaign.phase == "done" + + +def test_a_stopped_session_can_also_be_closed_out(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + controller.perform("stop") + assert campaign.phase == "aborted" + assert controller.primary_action().key == "finish_session" + controller.perform("finish_session") + assert campaign.phase == "idle" + assert controller.primary_action().key in {"align_point", "start_session"} + + +@pytest.mark.parametrize("state", ["idle", "load"]) +def test_only_a_finished_session_can_be_closed_out(tmp_dir, state): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + if state == "load": + _aligned_and_started(controller, campaign) + with pytest.raises(RuntimeError): + campaign.end_session() + assert campaign.phase == state + + +def test_a_hold_ends_by_itself_at_the_planned_time(tmp_dir): + """F3: the recorded window is the plan, however late the page noticed.""" + hold_s = 0.2 + campaign = _campaign(hold_s=hold_s) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + + controller.perform( + "confirm_target", force_n=float(campaign.progress()["target_n"]) + ) + # Before the time is up the hold stays open and the button says why. + controller.poll() + assert campaign.phase == "hold" + assert controller.primary_action().blocked_reason == "cap.block.hold_running" + + # The operator is busy for far longer than the plan, then the page ticks. + time.sleep(hold_s + 0.5) + state = controller.poll() + assert state["phase"] == "load" + assert controller.primary_action().key == "confirm_target" + + _walk_to_the_end(controller, campaign, hold_s) + rows = _reference_rows(campaign)["sites/P1/repeat-001"] + assert [row["hold_complete"] for row in rows] == ["1", "1", "1"] + for row in rows: + length = int(row["t_end_host_mono_ns"]) - int(row["t_start_host_mono_ns"]) + assert length == int(hold_s * 1e9), row + + +def test_auto_complete_hold_does_nothing_outside_a_running_hold(tmp_dir): + campaign = _campaign() + assert campaign.auto_complete_hold() is False + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + assert campaign.phase == "load" + assert campaign.auto_complete_hold() is False + + +def _offline_controller(campaign, root, *, sites_solver=None, probe=lambda: False): + """A controller as the page builds it for a simulation with nothing connected.""" + return CaptureController( + campaign, + solver=sites_solver, + readiness=lambda: [ + ReadinessCheck(key, True, label) + for key, label in READINESS_ITEMS + if key != "max_force" + ], + max_force_n=MAX_FORCE, + start_arguments=_start_arguments(root), + alignment_required=probe, + ) + + +def test_a_simulation_with_nothing_connected_runs_without_alignment(tmp_dir): + """F5: no platform, no solver and no move: start, record and finish.""" + campaign = _campaign(sites=("P1", "P2")) + controller = _offline_controller(campaign, tmp_dir / "out", sites_solver=None) + + action = controller.primary_action() + assert (action.key, action.enabled) == ("start_session", True) + controller.perform("start_session") + assert controller.pending_alignment is None + + seen: set[str] = set() + for _ in range(60): + action = controller.primary_action() + seen.add(action.key) + if campaign.phase == "done": + break + if action.key == "confirm_target": + _auto_step(controller, campaign) + elif action.key == "confirm_liftoff": + controller.perform("confirm_liftoff") + else: # pragma: no cover + raise AssertionError(f"stuck at {action}") + assert campaign.phase == "done" + assert "align_point" not in seen + assert "confirm_liftoff" in seen + # Nothing in the run asked the window to move an axis. + assert controller.pending_alignment is None + assert _manifest(campaign)["status"] == "complete" + assert _manifest(campaign)["simulate"] is True + + controller.perform("finish_session") + assert campaign.phase == "idle" + # Closing out still leaves a simulation ready to run again without a move. + assert controller.primary_action().key == "start_session" + + +def test_alignment_stays_required_unless_the_page_says_otherwise(tmp_dir): + campaign = _campaign() + controller = _controller(campaign, tmp_dir / "out") + assert controller.primary_action().key == "align_point" + + def broken() -> bool: + raise RuntimeError("the page is not ready") + + # A probe that fails is the strict answer, never the permissive one. + controller = _offline_controller(campaign, tmp_dir / "out", probe=broken) + assert controller.primary_action().key == "align_point" + + +# ------------------------------------------------ stale alignment, failed skip + + +def test_an_earlier_alignment_is_forgotten_when_the_platform_moved_elsewhere(tmp_dir): + """F4: a jog or a move from another page makes "aligned" untrue.""" + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned() + assert controller.primary_action().key == "start_session" + + controller.invalidate_alignment() + + action = controller.primary_action() + assert (action.key, action.enabled) == ("align_point", True) + with pytest.raises(ValueError, match="not an offered"): + controller.perform("start_session") + + +def test_the_next_point_must_be_aligned_again_after_an_outside_move(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + _drive_repeat(controller, campaign) + assert campaign.phase == "liftoff" + controller.perform("align_point") + controller.pending_alignment = None + controller.mark_aligned("P2") + assert controller.primary_action().key == "confirm_liftoff" + + controller.invalidate_alignment() + + assert controller.primary_action().key == "align_point" + + +def test_a_skip_that_cannot_move_the_folder_leaves_the_repeat_untouched( + tmp_dir, monkeypatch +): + """F6: a locked folder must not leave a half-skipped repeat behind.""" + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + directory = campaign.directory / "sites" / "P1" / "repeat-001" + trials_before = len(campaign._document["trials"]) + + real_replace = Path.replace + + def locked(self, target): + if self == directory: + raise PermissionError(13, "the folder is in use by another process") + return real_replace(self, target) + + monkeypatch.setattr(Path, "replace", locked) + with pytest.raises(RuntimeError, match="could not be set aside"): + controller.perform("skip") + monkeypatch.setattr(Path, "replace", real_replace) + + # Nothing about the campaign changed, and the point was not marked skipped. + assert campaign.phase == "load" + assert len(campaign._document["trials"]) == trials_before + assert directory.is_dir() + assert controller.model().sites[0].skipped is False + # The repeat is still usable: its files were reattached, header intact. + _drive_repeat(controller, campaign) + rows = _reference_rows(campaign)["sites/P1/repeat-001"] + assert [row["hold_complete"] for row in rows] == ["1", "1", "1"] + + +def test_a_skip_sets_the_folder_aside_and_moves_on(tmp_dir): + campaign = _campaign(sites=("P1", "P2")) + controller = _controller(campaign, tmp_dir / "out") + _aligned_and_started(controller, campaign) + directory = campaign.directory / "sites" / "P1" / "repeat-001" + + controller.perform("skip") + + assert not directory.exists() + assert campaign.phase == "liftoff" + assert [t["site_id"] for t in campaign._document["trials"]] == [] + assert controller.model().sites[0].skipped is True diff --git a/apps/autocal/tests/test_capture_page.py b/apps/autocal/tests/test_capture_page.py new file mode 100644 index 0000000..d1ffb69 --- /dev/null +++ b/apps/autocal/tests/test_capture_page.py @@ -0,0 +1,736 @@ +"""The capture page end to end: one queue, one primary button, one message line. + +These are interface tests in the literal sense: they click the widgets an operator +clicks, in the order the controller offers them, and check the files the session +wrote. The platform move is stubbed at the window's move planner (the real move is +hardware-adjacent and already covered elsewhere); everything else runs as shipped. +""" + +from __future__ import annotations + +import csv +import json +import re +import time +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from orisys_app_shared.i18n import t +from orisys_autocal.sensor.uv_flat import FlatSurface + +POINT = (4.872, 5.444, 16.176) +NORMAL = (0.595, 0.673, 0.439) + +#: The only direct Campaign calls the frozen contract keeps in ``ui/``: the +#: recorder's lifecycle is not a workflow decision and not rendered data. +_ALLOWED_CAMPAIGN_MEMBERS = frozenset({"attach_capture", "flush_close"}) + + +def _campaign_accesses(source: str) -> list[str]: + """Every ``campaign.`` access in one module's source, in order.""" + return [match.group(1) for match in re.finditer(r"campaign\.(\w+)", source)] + + +def _campaign_offenders(directory) -> list[str]: + """UI modules reaching past the controller for campaign state or actions.""" + offenders = [] + for path in sorted(directory.rglob("*.py")): + for member in _campaign_accesses(path.read_text(encoding="utf-8")): + if member not in _ALLOWED_CAMPAIGN_MEMBERS: + offenders.append(f"{path.name}: campaign.{member}") + return offenders + + +def _flat() -> FlatSurface: + """A stand-in rasterised surface: every pixel is the same measured point.""" + size = 8 + positions = np.tile(np.asarray(POINT, dtype=np.float64), (size, size, 1)) + normals = np.tile(np.asarray(NORMAL, dtype=np.float64), (size, size, 1)) + return FlatSurface( + size_hw=(size, size), + image_rgb=np.zeros((size, size, 3), dtype=np.uint8), + positions_mm=positions, + normals=normals, + face_index=np.zeros((size, size), dtype=np.int32), + uv_min=(0.0, 0.0), + uv_max=(1.0, 1.0), + ) + + +def _pump(app, seconds: float = 0.3) -> None: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + + +def _page( + window, + qt_app, + tmp_path, + *, + max_force: str = "20", + align: bool = True, + flat: bool = True, +): + """A capture page whose readiness passes in simulation mode. + + ``align=True`` (the default) makes the page behave as it does with a platform + attached: the point must be aligned before a session may start, so the tests + that are about the align step keep covering it. ``align=False`` is the page as + it is with nothing connected, and ``flat=False`` leaves out the calibration + bundle too. + """ + window.resize(1440, 900) + window.show() + window.workspace.set_current_key("sensor") + qt_app.processEvents() + page = window.calibration_panel + page.settings.simulate.setChecked(True) + page.settings.sensor_sn.setText("SN1") + page.settings.max_force.setText(max_force) + page.settings.output_path.setText(str(tmp_path)) + if align: + # The page treats a simulation as needing no move; these tests are about + # the step that does, so they ask for the platform-attached behaviour. + page._simulating = lambda: False # noqa: SLF001 - the seam under test + if flat: + page.set_flat(_flat(), "bundle") + # A hold of a few hundredths of a second keeps the test fast; the session + # contract is the same. + page.hold.setRange(0.05, 30) + page.hold.setValue(0.05) + page.levels.setText("5") + page.repeats.setValue(1) + return page + + +def _started_page(window, qt_app, tmp_path, *, sites=("P1", "P2")): + """Two points added through the page, first aligned, session started.""" + _stub_moves(window, succeeds=True) + page = _page(window, qt_app, tmp_path) + for index, site_id in enumerate(sites): + _add_point(page, site_id, index, index, qt_app) + _click_primary(page, qt_app) # align the first point + assert page.controller.primary_action().key == "start_session" + _click_primary(page, qt_app) # start the session + assert page.controller.poll()["phase"] == "load" + return page + + +def _stub_moves(window, *, succeeds: bool = True) -> list[dict]: + """Replace the window's move planner: record the plan, report the outcome.""" + plans: list[dict] = [] + + def fake_start(plan, title, *, report=None): + plans.append({"title": title, "roles": [step.role for step in plan.steps]}) + if succeeds: + window.calibration_panel.alignment_succeeded() + else: + window.calibration_panel.alignment_failed("refused by the test") + + window._start_move = fake_start # noqa: SLF001 - the seam under test is the page + return plans + + +def _click_primary(page, qt_app, timeout: float = 5.0) -> str: + """Press the primary button once, after filling whatever it needs first.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + qt_app.processEvents() + action = page.controller.primary_action() + if action.enabled and not ( + action.key == "confirm_hold" and page.controller.poll()["remaining_s"] > 0 + ): + # The page redraws on its own timer; read the button the operator would + # click, not just the controller's answer, or a fast step races the redraw. + page._refresh() # noqa: SLF001 - the widget state is what is under test + if not page.primary_button.isEnabled(): + _pump(qt_app, 0.05) + continue + if action.key == "confirm_target": + target = page.controller.model().progress.get("target_n") or 0.0 + page.reference.setText(f"{float(target):g}") + page.primary_button.click() + _pump(qt_app, 0.1) + return action.key + _pump(qt_app, 0.05) + raise AssertionError( + f"the primary action never became available: " + f"{page.controller.primary_action()} / {page.message.text()}" + ) + + +def _add_point(page, site_id: str, u: int, v: int, qt_app) -> None: + page.u_spin.setValue(u) + page.v_spin.setValue(v) + page.site_id.setText(site_id) + page.add_point_button.click() + _pump(qt_app, 0.05) + + +def test_two_point_capture_walkthrough_in_simulation(make_window, qt_app, tmp_path): + """Align, start, load every step, hold, liftoff, align, and finish - by clicking.""" + window = make_window(move_confirm=False) + plans = _stub_moves(window, succeeds=True) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + _add_point(page, "P2", 1, 1, qt_app) + assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"] + + # The first point must be aligned before the session may start. + assert page.controller.primary_action().key == "align_point" + assert t("cap.action.align") == page.primary_button.text() + _click_primary(page, qt_app) + # The press axis is held because its relation to a contact height is not + # calibrated on the test bench; the four movable roles still travel together. + assert plans and plans[-1]["roles"] == ["yaw", "pitch", "x", "y"] + assert page.controller.primary_action().key == "start_session" + assert t("cap.action.start") == page.primary_button.text() + + _click_primary(page, qt_app) # start the session + state = page.controller.poll() + assert state["phase"] == "load" + # Starting a capture does not switch the workspace page. + assert window.workspace.current_key() == "sensor" + # The recording options are locked once the session exists. + assert not page.settings.video_check.isEnabled() + assert not page.settings.locked_label.isHidden() + assert page.settings.locked_label.text() == t("cap.recording.locked") + + seen: list[str] = [] + for _ in range(60): + action = page.controller.primary_action() + seen.append(action.key) + if action.key == "finish_session": + _click_primary(page, qt_app) + break + _click_primary(page, qt_app) + else: # pragma: no cover - a stuck walkthrough is the failure to report + raise AssertionError(f"the session never finished: {seen[-6:]}") + # The hold ends by itself at its planned time, so the button may or may not be + # caught showing "confirm hold" between two ticks; the order of the steps is + # what matters. + assert seen[0] == "confirm_target" + assert seen.count("confirm_target") >= 3 + # Two points, so the second one needs its own alignment between them. + assert "align_point" in seen + assert "confirm_liftoff" in seen + + directory = page._session_directory() # noqa: SLF001 - the recorded folder under test + assert directory is not None + manifest = json.loads((directory / "session.json").read_text(encoding="utf-8")) + assert manifest["status"] == "complete" + assert [site["site_id"] for site in manifest["sites"]] == ["P1", "P2"] + assert [trial["status"] for trial in manifest["trials"]] == ["complete", "complete"] + assert (directory / "stable_means.csv").is_file() + # A simulated run writes no video; that is the recorded state, not a failure. + assert page.controller.poll()["capture_state"] == "not_recorded" + # The end of the session publishes the candidate summary (P1's flat copy). + deadline = time.monotonic() + 20.0 + while not (directory / "summary-latest.json").is_file() and time.monotonic() < deadline: + _pump(qt_app, 0.05) + assert (directory / "summary-latest.json").is_file() + window.close() + + +def test_a_simulation_runs_with_nothing_connected(make_window, qt_app, tmp_path): + """No bundle, no platform, no camera: type two points, start, finish, go again.""" + window = make_window(move_confirm=False) + plans = _stub_moves(window, succeeds=True) + page = _page(window, qt_app, tmp_path, align=False, flat=False) + _add_point(page, "P1", 0, 0, qt_app) + _add_point(page, "P2", 3, 4, qt_app) + assert [row.site_id for row in page.controller.model().sites] == ["P1", "P2"] + + # Every ready check passes without a bundle, a bus or an input frame. + assert all(check.ok for check in page.controller.readiness()), [ + check for check in page.controller.readiness() if not check.ok + ] + # No align step stands between the queue and the session. + assert page.controller.primary_action().key == "start_session" + assert t("cap.action.start") == page.primary_button.text() + + _click_primary(page, qt_app) # start the session + assert page.controller.poll()["phase"] == "load" + + seen: list[str] = [] + for _ in range(60): + action = page.controller.primary_action() + seen.append(action.key) + _click_primary(page, qt_app) + if action.key == "finish_session": + break + else: # pragma: no cover - a stuck walkthrough is the failure to report + raise AssertionError(f"the session never finished: {seen[-6:]}") + assert "align_point" not in seen + assert "confirm_liftoff" in seen + assert plans == [] # nothing asked the window to move an axis + + directory = page._session_directory() # noqa: SLF001 + manifest = json.loads((directory / "session.json").read_text(encoding="utf-8")) + assert manifest["simulate"] is True + assert manifest["status"] == "complete" + assert {site["pose"]["normal_aligned_to"] for site in manifest["sites"]} == { + "simulated" + } + # Closing out leaves the page ready to run the same queue again. + assert page.controller.poll()["phase"] == "idle" + assert page.controller.primary_action().key == "start_session" + window.close() + + +def test_a_refused_alignment_keeps_start_disabled(make_window, qt_app, tmp_path): + """A move the operator did not confirm must never unlock "start capture".""" + window = make_window(move_confirm=False) + _stub_moves(window, succeeds=False) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + + _click_primary(page, qt_app) # align: the window refuses the move + assert page.controller.primary_action().key == "align_point" + assert page.controller.primary_action().key != "start_session" + assert t("cap.action.start") != page.primary_button.text() + assert t("cap.message.align_failed", detail="refused by the test") in page.message.text() + + # A retry whose move is confirmed is what unlocks the session. + _stub_moves(window, succeeds=True) + _click_primary(page, qt_app) + assert page.controller.primary_action().key == "start_session" + window.close() + + +def test_the_capture_page_never_reaches_past_the_controller(): + """P4 exit condition: ``ui/`` renders controller output, not campaign state. + + The guard is a member-access scan rather than a fixed string list: any new + ``campaign.`` in a UI module is a finding unless it is one of the two + recorder-lifecycle methods the frozen contract keeps (interfaces 1.11). That + covers ``phase``, ``sites``, ``_*``, every queue edit and every flow action — + including ones added later. + """ + from pathlib import Path + + ui = Path(__file__).resolve().parents[1] / "src" / "orisys_autocal" / "ui" + assert _campaign_offenders(ui) == [] + + +def test_the_campaign_guard_catches_a_regression(): + """The guard above must fail on a forbidden access, not merely pass by luck.""" + assert _campaign_accesses("x = self.campaign.sites\n") == ["sites"] + assert _campaign_accesses("if self.campaign.phase == 'idle':") == ["phase"] + assert _campaign_accesses("self.campaign._skipped") == ["_skipped"] + assert _campaign_accesses("self.campaign.add_solved_site(a)") == ["add_solved_site"] + assert _campaign_accesses("self.campaign.confirm_reference(1)") == ["confirm_reference"] + assert _campaign_accesses("self.campaign.attach_capture(w)") == ["attach_capture"] + for member in ( + "phase", + "sites", + "_skipped", + "add_solved_site", + "update_site", + "remove_site", + "move_site", + "store_solved_pose", + "set_force_rotation", + "confirm_reference", + "complete_hold", + "stop", + "remeasure_step", + "skip_current_point", + ): + assert member not in _ALLOWED_CAMPAIGN_MEMBERS, member + + +def test_unavailable_codecs_are_disabled_with_their_reason(make_window): + """A codec this machine cannot open is refused here, never silently swapped.""" + window = make_window() + settings = window.calibration_panel.settings + settings.set_availability({"mp4v": True, "avc1": False, "H264": False}) + model = settings.codec.model() + for index in range(settings.codec.count()): + item = model.item(index) + codec = settings.codec.itemData(index) + assert item.isEnabled() is (codec == "mp4v") + assert settings.codec.currentData() == "mp4v" + for codec in ("avc1", "H264"): + assert t("cap.recording.unavailable", codec=codec) in settings.codec_reason.text() + window.close() + + +def test_the_size_estimate_warns_below_twice_the_planned_volume(make_window, monkeypatch): + from orisys_autocal.ui import capture_settings_dialog as module + + window = make_window() + settings = window.calibration_panel.settings + settings.set_availability({"mp4v": True, "avc1": False, "H264": False}) + settings.video_check.setChecked(True) + monkeypatch.setattr( + module.shutil, + "disk_usage", + lambda path: SimpleNamespace(total=0, used=0, free=1_000_000_000), + ) + # One hour of mp4v at the measured bench bitrate is far more than 1 GB. + settings.set_plan(3600.0) + assert t("cap.recording.disk_short") in settings.estimate.text() + assert "12.15 GB" in settings.estimate.text() + monkeypatch.setattr( + module.shutil, + "disk_usage", + lambda path: SimpleNamespace(total=0, used=0, free=10**14), + ) + settings.set_plan(3601.0) + assert t("cap.recording.disk_short") not in settings.estimate.text() + # Recording no video makes the size question disappear, not a zero. + settings.video_check.setChecked(False) + assert settings.estimate.text().startswith(t("cap.recording.off")) + window.close() + + +def test_a_session_recorded_without_video_says_so_in_the_review(qt_app, tmp_path): + from evidence_helpers import multipoint_session + from orisys_autocal.session_review import review_session + from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog + + root = multipoint_session( + tmp_path / "quiet", + sites=("P1",), + repeats=1, + levels=(5.0,), + recording_block={"video": False, "codec": "mp4v", "lossless_frames": False}, + ) + review = review_session(root) + assert review["recording"]["video"] is False + dialog = RecordOverviewDialog(root, review=review) + try: + assert t("overview.video_not_recorded") in dialog.identity.text() + finally: + dialog.close() + # A session that kept its video says nothing about a missing one. + with_video = multipoint_session( + tmp_path / "loud", sites=("P1",), repeats=1, levels=(5.0,) + ) + dialog = RecordOverviewDialog(with_video, review=review_session(with_video)) + try: + assert t("overview.video_not_recorded") not in dialog.identity.text() + finally: + dialog.close() + + +def test_the_page_refuses_start_until_the_ready_checks_pass(make_window, qt_app): + """Every ✗ check names itself and blocks the start with a stated reason.""" + window = make_window(move_confirm=False) + page = window.calibration_panel + window.workspace.set_current_key("sensor") + qt_app.processEvents() + action = page.controller.primary_action() + reason = t(action.blocked_reason) + assert action.enabled is False + assert page.primary_reason.text() == reason + checks = {check.key: check for check in page.controller.readiness()} + assert checks["max_force"].ok is False + assert checks["sensor_sn"].ok is False + for key in ("calibration_bundle", "input_frames", "bus_ready"): + assert checks[key].ok is False + assert page.ready_labels[key].text().startswith("✗") + # Filling one fact turns only that chip over. + page.settings.sensor_sn.setText("SN1") + checks = {check.key: check for check in page.controller.readiness()} + assert checks["sensor_sn"].ok is True + assert page.controller.primary_action().enabled is False + window.close() + + +def test_a_point_needs_a_maximum_force_and_the_controller_is_told( + make_window, qt_app, tmp_path +): + """The queue edit is the controller's, including its maximum-force refusal.""" + window = make_window(move_confirm=False) + page = _page(window, qt_app, tmp_path, max_force="") + page._refresh() # noqa: SLF001 - the page's own render is the unit under test + page.add_point_button.click() + _pump(qt_app, 0.1) + assert page.controller.model().sites == [] + assert page.message.text().endswith(t("cap.block.no_max_force")) + + # The dialog value reaches the controller through set_max_force, not a rebuild. + page.settings.max_force.setText("20") + page._refresh() # noqa: SLF001 + checks = {check.key: check for check in page.controller.readiness()} + assert checks["max_force"].ok is True + _add_point(page, "P1", 0, 0, qt_app) + assert [row.site_id for row in page.controller.model().sites] == ["P1"] + window.close() + + +def test_a_skipped_point_is_rendered_from_the_controller_row( + make_window, qt_app, tmp_path +): + """The skip mark is the controller's; the page only draws ``SiteRow.skipped``.""" + window = make_window(move_confirm=False) + page = _started_page(window, qt_app, tmp_path) + assert page.controller.model().sites[0].skipped is False + + page._refresh() # noqa: SLF001 - render the freshly enabled button + page.skip_button.click() + _pump(qt_app, 0.2) + + assert page.controller.model().sites[0].skipped is True + assert page.queue_table.item(0, 4).text() == t("cap.queue.state_skipped") + assert page.message.text().endswith(t("cap.message.skipped", site="P1")) + window.close() + + +def test_the_stop_button_leaves_the_reason_to_the_controller( + make_window, qt_app, tmp_path +): + """The stop button passes no reason; the controller records the default one.""" + window = make_window(move_confirm=False) + page = _started_page(window, qt_app, tmp_path, sites=("P1",)) + directory = page._session_directory() # noqa: SLF001 + page._refresh() # noqa: SLF001 - render the freshly enabled button + page.stop_button.click() + _pump(qt_app, 0.2) + + manifest = json.loads((directory / "session.json").read_text(encoding="utf-8")) + assert manifest["reason"] == "operator stopped" + assert page.controller.primary_action().key == "finish_session" + window.close() + + +def test_closing_the_window_records_the_application_reason( + make_window, qt_app, tmp_path +): + """Window shutdown names its own reason, so the data can tell the two apart.""" + window = make_window(move_confirm=False) + page = _started_page(window, qt_app, tmp_path, sites=("P1",)) + directory = page._session_directory() # noqa: SLF001 + window.close() + + manifest = json.loads((directory / "session.json").read_text(encoding="utf-8")) + assert manifest["reason"] == "application closed" + + +def test_updating_a_point_retakes_it_and_keeps_its_queue_position( + make_window, qt_app, tmp_path +): + """A retaken point goes through the controller and stays where the operator put it.""" + window = make_window(move_confirm=False) + page = _page(window, qt_app, tmp_path) + for index, site_id in enumerate(("P1", "P2", "P3")): + _add_point(page, site_id, index, index, qt_app) + page.queue_table.selectRow(1) # the operator's row selection + qt_app.processEvents() + assert page._selected_site == "P2" # noqa: SLF001 + page.u_spin.setValue(5) + page.v_spin.setValue(5) + page.site_id.setText("P2b") + page.update_point_button.click() + _pump(qt_app, 0.2) + + rows = page.controller.model().sites + assert [row.site_id for row in rows] == ["P1", "P2b", "P3"] + assert rows[1].site_id == "P2b" + assert rows[1].uv_px is not None + assert page._selected_site == "P2b" # noqa: SLF001 + window.close() + + +# --------------------------------------------------- P5: geometry, sheet, reach + + +def _drive_to_liftoff(page) -> None: + """Finish the first point's repeat directly, leaving the session at liftoff.""" + campaign = page.controller.campaign + while campaign.phase == "load": + target = campaign.progress()["target_n"] + campaign.confirm_reference(target) + campaign.complete_hold( + now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9) + ) + assert campaign.phase == "liftoff" + + +def test_changing_the_mounting_resolves_every_queued_pose(make_window, qt_app, tmp_path): + """stackChanged re-solves the queue and writes the fresh poses back while idle.""" + window = make_window(move_confirm=False) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + _add_point(page, "P2", 1, 1, qt_app) + before = {row.site_id: row.pose["z"] for row in page.controller.model().sites} + + spin = window.finger_panel.sensor_z_spin + spin.setValue(spin.value() + 4.0) # the operator changes the mounting height + _pump(qt_app, 0.2) + + rows = page.controller.model().sites + assert all(row.reachable for row in rows) + for row in rows: + assert row.pose["z"] != pytest.approx(before[row.site_id]), row.site_id + fresh = page._solve_uv(*row.uv_px)[2] # noqa: SLF001 - the solver's answer + assert row.pose["z"] == pytest.approx(fresh["z"]) + window.close() + + +def test_an_unreachable_point_is_marked_and_cannot_be_aligned( + make_window, qt_app, tmp_path +): + """A travel limit that makes a point unreachable disables its align, visibly.""" + window = make_window(move_confirm=False) + emitted = _stub_moves(window, succeeds=True) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + assert page.controller.model().sites[0].reachable is True + emitted.clear() + + window.finger_panel.stack.yaw_limit_deg = 0.0 # the stage can no longer turn + window.finger_panel.stackChanged.emit() + _pump(qt_app, 0.2) + + row = page.controller.model().sites[0] + assert row.reachable is False + assert page.queue_table.item(0, 3).text() == t("cap.queue.unreachable") + action = page.controller.primary_action() + assert (action.key, action.enabled) == ("align_point", False) + assert action.blocked_reason == "cap.block.no_pose" + page._align() # noqa: SLF001 - a direct call must be refused too + assert not emitted + assert t("cap.block.pose_unreachable") in page.message.text() + window.close() + + +def test_a_frozen_queue_is_not_rewritten_when_the_mounting_changes( + make_window, qt_app, tmp_path +): + """Between trials the queue is frozen: reachability is shown, poses are not stored.""" + window = make_window(move_confirm=False) + page = _started_page(window, qt_app, tmp_path) + _drive_to_liftoff(page) + page._refresh() # noqa: SLF001 + recorded = page.controller.model().sites[1].pose["z"] + + spin = window.finger_panel.sensor_z_spin + spin.setValue(spin.value() + 4.0) + _pump(qt_app, 0.2) + + rows = page.controller.model().sites + assert rows[1].pose["z"] == pytest.approx(recorded) # nothing written back + assert page._live_reachable # noqa: SLF001 - the display-only answer exists + fresh = page._solve_uv(*rows[1].uv_px)[2] # noqa: SLF001 + assert page._live_reachable[rows[1].site_id] == bool( # noqa: SLF001 + fresh["ok"] and fresh["in_range"] + ) + window.close() + + +def test_the_point_sheet_matches_the_documented_fields(make_window, qt_app, tmp_path): + """P5 exit: the operation table's fields are the data-format document's, in order.""" + from orisys_autocal.sensor.uv_flat import mapping_sha256, uv_origin + from orisys_autocal.ui.site_export import OPERATION_TABLE_FIELDS, write_operation_table + + document = ( + Path(__file__).resolve().parents[1] + / "docs" + / "calibration-data-format.zh.md" + ).read_text(encoding="utf-8") + # The document spells out the first two blocks; the rest are described as a group. + explicit = ( + "order", "site_id", "u_px", "v_px", + "x_sensor_mm", "y_sensor_mm", "z_sensor_mm", + "x_platform_mm", "y_platform_mm", "z_platform_mm", + "yaw_deg", "pitch_deg", + "origin_raster_x_px", "origin_raster_y_px", + ) + assert OPERATION_TABLE_FIELDS[: len(explicit)] == explicit + for field in explicit: + assert field in document, f"{field} is not named in the format document" + + window = make_window(move_confirm=False) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + _add_point(page, "P2", 1, 1, qt_app) + target = tmp_path / "points.csv" + write_operation_table(target, page.controller.model().sites, page._flat) + + with target.open(encoding="utf-8-sig", newline="") as handle: + header = next(csv.reader(handle)) + written = list(csv.DictReader(target.open(encoding="utf-8-sig", newline=""))) + assert tuple(header) == OPERATION_TABLE_FIELDS + assert [row["site_id"] for row in written] == ["P1", "P2"] + origin = uv_origin(page._flat) + assert int(written[0]["origin_raster_x_px"]) == origin[0] + assert int(written[0]["origin_raster_y_px"]) == origin[1] + assert int(written[0]["raster_width_px"]) == page._flat.width + assert int(written[0]["raster_height_px"]) == page._flat.height + assert written[0]["u_positive"] == "right" + assert written[0]["v_positive"] == "up" + assert written[0]["mapping_sha256"] == mapping_sha256(page._flat) + for record, row in zip(written, page.controller.model().sites): + assert float(record["x_sensor_mm"]) == pytest.approx(row.position["x"]) + assert float(record["x_platform_mm"]) == pytest.approx(row.pose["x"]) + assert float(record["yaw_deg"]) == pytest.approx(row.pose["yaw"]) + + # A recorded coordinate that no longer matches the map refuses the sheet. + page.controller.campaign.sites[0].x += 0.5 + with pytest.raises(ValueError): + write_operation_table(tmp_path / "invalid.csv", page.controller.model().sites, page._flat) + assert not (tmp_path / "invalid.csv").exists() + window.close() + + +def test_the_export_button_writes_the_sheet_and_reports_it( + make_window, qt_app, tmp_path, monkeypatch +): + from orisys_autocal.ui import calibration_panel as module + + window = make_window(move_confirm=False) + page = _page(window, qt_app, tmp_path) + _add_point(page, "P1", 0, 0, qt_app) + target = tmp_path / "sheet.csv" + monkeypatch.setattr( + module.QFileDialog, + "getSaveFileName", + lambda *args, **kwargs: (str(target), ""), + ) + page.export_sheet_button.click() + _pump(qt_app, 0.1) + + assert target.is_file() + assert page.message.text() == t("cap.queue.export_saved", path=str(target)) + # The refusal path names the point and writes nothing. + page.controller.campaign.sites[0].x += 0.5 + page.export_sheet_button.click() + _pump(qt_app, 0.1) + assert t("cap.queue.export_edited_point", order=1) in page.message.text() + window.close() + +def test_a_failed_summary_copy_is_reported_not_called_published(make_window): + """The copy under summary/ is a convenience; a failure must not read as success.""" + window = make_window() + page = window.calibration_panel + detail = "the readable copy under summary/ was not written: no space left" + page._summary_warnings = [detail] + page._summary_finished("/some/published/summary", None) + assert page.message.text() == t("summary.copy_failed").format(detail=detail) + window.close() + + +def test_a_stored_unavailable_codec_does_not_become_the_selection(make_window): + """Loading stored options must not re-select a codec the probe disabled.""" + window = make_window() + settings = window.calibration_panel.settings + settings.prefs["recording"] = { + "video": True, + "codec": "avc1", + "lossless_frames": False, + } + # The dialog probes first and loads the stored options second; that order is + # what used to put the disabled avc1 back into the combo. + settings.set_availability({"mp4v": True, "avc1": False, "H264": False}) + settings._load_preferences() + assert settings.codec.currentData() == "mp4v" + assert settings.recording_options().codec == "mp4v" + window.close() diff --git a/apps/autocal/tests/test_finger_panel.py b/apps/autocal/tests/test_finger_panel.py index 334ad73..27cdb8d 100644 --- a/apps/autocal/tests/test_finger_panel.py +++ b/apps/autocal/tests/test_finger_panel.py @@ -1,7 +1,9 @@ -"""The finger debugger's panel: picking, refusing, and what a row contains. +"""The geometry-and-mounting screen: the single-point trial. Interaction tests use a small dome. Geometry and native-resolution checks keep the real export and skip individually when that optional export is unavailable. +The point queue and its operation-sheet export live on the capture page and are +covered by ``test_capture_page.py``. """ from __future__ import annotations @@ -398,9 +400,9 @@ def test_the_play_button_needs_a_picked_point(panel): assert panel.sim_button.isEnabled() -def test_clearing_the_points_disables_the_simulation_again(panel): +def test_clearing_the_point_disables_the_simulation_again(panel): panel.handle_pick(*_first_surface_pixel(panel.view.flat)) - panel.clear_points() + panel.clear_point() assert not panel.preview.can_simulate assert not panel.sim_button.isEnabled() @@ -539,155 +541,81 @@ def test_the_simulation_cannot_start_without_a_pose(bundle, preview): assert preview._sim_transform() is None # noqa: SLF001 -# ------------------------------------------------------------- the point table +# ------------------------------------------------------------- the single pick -def _pick_three(panel) -> list[dict]: - """Fill the table with three real picks, spread over the surface.""" - flat = panel.view.flat - picked: list[dict] = [] - step = max(1, flat.width // 7) - for y in range(step, flat.height, step): - for x in range(step, flat.width, step): - if flat.sample(x, y) is None: +def _another_surface_pixel(flat, first) -> tuple[int, int]: + """A second surface pixel, so a replacement pick is visibly different.""" + for y in range(0, flat.normals.shape[0], 5): + for x in range(0, flat.normals.shape[1], 5): + if (x, y) == first or flat.sample(x, y) is None: continue - row = panel.handle_pick(x, y) - if row is not None: - picked.append(row) - if len(picked) == 3: - break - if len(picked) == 3: - break - assert len(picked) == 3, f"only picked {len(picked)} points" - return picked + return x, y + raise AssertionError("the flat raster has no second surface pixel") -def test_the_table_has_a_select_column_and_an_editable_body(panel): - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QCheckBox +def test_the_window_names_the_screen_geometry_and_mounting(make_window): + """P5: the debug screen is now the geometry and mounting page, in both places.""" + from orisys_app_shared.i18n import t - _pick_three(panel) - assert panel.table.columnCount() == len(panel.COLUMNS) + 1 - for row in range(panel.table.rowCount()): - holder = panel.table.cellWidget(row, panel.SELECT_COLUMN) - assert holder is not None and holder.findChild(QCheckBox) is not None - # The six input columns are editable; the solved ones are not. - for offset in range(len(panel.COLUMNS)): - item = panel.table.item(0, offset + 1) - editable = bool(item.flags() & Qt.ItemFlag.ItemIsEditable) - assert editable == (offset < panel.INPUT_COLUMNS), panel.COLUMNS[offset] + window = make_window() + try: + title = t("finger.title") + assert title == t("workspace.tab_finger") + assert title == "几何与安装", title # the default locale is zh_CN + assert window.finger_panel.title() == title + index = window.workspace.page_keys().index("finger") + assert window.workspace.tabText(index) == title + finally: + window.close() -def test_editing_a_coordinate_resolves_the_row_again(panel): - """Typing a new sensor coordinate must re-solve, not leave a stale pose.""" - _pick_three(panel) - before = panel.points()[0] +def test_the_screen_no_longer_keeps_a_point_table(panel): + """P5: the queue owns points; this screen is the single-point trial. - item = panel.table.item(0, 1) # the x column - item.setText(f"{before['x'] + 1.0:.3f}") - - after = panel.points()[0] - assert after["x"] == pytest.approx(before["x"] + 1.0, abs=1e-3) - # The solved columns moved with it, which is the point of re-resolving. - assert after["X"] != pytest.approx(before["X"], abs=1e-9) - assert after["residual"] < 1.0 + The list, its editor and its export moved to the capture page's queue, so the + old widgets must not exist here any more. + """ + assert not hasattr(panel, "table") + assert not hasattr(panel, "export_sites") + assert not hasattr(panel, "clear_points") + assert not hasattr(panel, "acquisition_points") + assert not hasattr(panel, "export_sites_button") -def test_a_non_numeric_edit_is_refused_and_the_old_value_kept(panel): - _pick_three(panel) - before = panel.points()[0]["x"] - - panel.table.item(0, 1).setText("not a number") - - assert panel.points()[0]["x"] == pytest.approx(before) - assert panel.status_label.text() - - -def test_ticking_a_row_selects_it_and_clears_again(panel): - from PySide6.QtWidgets import QCheckBox - - _pick_three(panel) - first = panel.table.cellWidget(0, panel.SELECT_COLUMN).findChild(QCheckBox) - third = panel.table.cellWidget(2, panel.SELECT_COLUMN).findChild(QCheckBox) - - first.setChecked(True) - third.setChecked(True) - assert panel.selected_rows() == [0, 2] - - first.setChecked(False) - assert panel.selected_rows() == [2] - - -def test_removing_selected_drops_exactly_those_rows(panel): - from PySide6.QtWidgets import QCheckBox - - _pick_three(panel) - panel.table.cellWidget(1, panel.SELECT_COLUMN).findChild(QCheckBox).setChecked(True) - - removed = panel.remove_selected() - - assert removed == 1 - assert panel.point_count() == 2 - assert not panel.selected_rows() - - -def test_removing_with_nothing_ticked_drops_the_current_row(panel): - """The obvious fallback: no tick means 'the row I am looking at'.""" - _pick_three(panel) - panel.table.setCurrentCell(1, 1) - - assert panel.remove_selected() == 1 - assert panel.point_count() == 2 - - -def test_a_row_can_be_moved_up_and_down(panel): - _pick_three(panel) - before = [row["x"] for row in panel.points()] - - panel.table.setCurrentCell(0, 1) - assert panel.move_selected(1) - assert [row["x"] for row in panel.points()] == [before[1], before[0], before[2]] - - assert panel.move_selected(-1) - assert [row["x"] for row in panel.points()] == before - - -def test_a_row_cannot_be_moved_off_the_end(panel): - _pick_three(panel) - before = [row["x"] for row in panel.points()] - panel.table.setCurrentCell(0, 1) - assert not panel.move_selected(-1) - assert [row["x"] for row in panel.points()] == before - - -def test_selection_marks_the_point_in_both_views(panel): - """The red dot: the whole reason for selecting a row.""" - _pick_three(panel) - panel.table.setCurrentCell(2, 1) - - record = panel.points()[2] - assert panel.view.selected_pixel is not None - # And it is the right pixel: the one showing this row's 3D point. - pixel = panel.view.selected_pixel - point, _normal, _face = panel.view.flat.sample(*pixel) - assert point == pytest.approx([record["x"], record["y"], record["z"]], abs=0.05) - assert panel.preview._highlight_mm is not None # noqa: SLF001 - - -def test_a_pick_marks_its_own_row_as_selected(panel): +def test_a_pick_keeps_exactly_one_point(panel): flat = panel.view.flat - x, y = _first_surface_pixel(flat) + first = _first_surface_pixel(flat) + second = _another_surface_pixel(flat, first) + + first_row = panel.handle_pick(*first) + assert first_row is not None + assert panel.point_count() == 1 + assert panel.points()[0]["u_px"] == first_row["u_px"] + + second_row = panel.handle_pick(*second) + assert second_row is not None + # A second click replaces the first point rather than adding a row. + assert panel.point_count() == 1 + stored = panel.points()[0] + assert (stored["u_px"], stored["v_px"]) == (second_row["u_px"], second_row["v_px"]) + assert stored["u_px"] != first_row["u_px"] + + +def test_a_pick_marks_the_point_in_both_views(panel): + x, y = _first_surface_pixel(panel.view.flat) panel.handle_pick(x, y) assert panel.view.selected_pixel is not None assert panel.preview._highlight_mm is not None # noqa: SLF001 -def test_clearing_the_points_removes_the_markers(panel): - _pick_three(panel) - panel.clear_points() +def test_clearing_the_point_removes_the_markers(panel): + panel.handle_pick(*_first_surface_pixel(panel.view.flat)) + panel.clear_point() assert panel.point_count() == 0 + assert panel.points() == [] assert panel.view.selected_pixel is None assert panel.preview._highlight_mm is None # noqa: SLF001 @@ -1042,13 +970,13 @@ def test_picking_with_no_bundle_says_so(qt_app): widget.deleteLater() -def test_clearing_points_empties_the_table(panel): +def test_clearing_the_point_empties_the_screen(panel): flat = panel.view.flat x, y = _first_surface_pixel(flat) panel.handle_pick(x, y) assert panel.point_count() == 1 - panel.clear_points() + panel.clear_point() assert panel.point_count() == 0 assert panel.points() == [] @@ -1078,10 +1006,10 @@ def test_the_sensor_z_offset_reaches_the_solve(panel): assert 0.0 < thick.contact_z_mm - thin.contact_z_mm < 4.0 assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm) # Picking through the panel uses the same fields, so the two must agree. - panel.clear_points() + panel.clear_point() panel.set_sensor_setup((0.0, 0.0, 0.0), 0.0) thin_row = panel.handle_pick(x, y) - panel.clear_points() + panel.clear_point() panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0) thick_row = panel.handle_pick(x, y) assert thin_row is not None and thick_row is not None diff --git a/apps/autocal/tests/test_manual_trial.py b/apps/autocal/tests/test_manual_trial.py index e36a61e..16e4967 100644 --- a/apps/autocal/tests/test_manual_trial.py +++ b/apps/autocal/tests/test_manual_trial.py @@ -243,103 +243,112 @@ def test_input_end_aborts_active_hold(trial_setup, clock): trial.finish_session() -def test_operator_export_reproduces_uv_sites_and_rejects_edited_xyz(qt_app, flat, tmp_path): - from orisys_autocal.sensor.export_bundle import SensorBundle - from orisys_autocal.ui.sensor_panel import FingerPanel +def test_a_trial_recorded_without_video_finishes_complete(trial_setup, clock): + """Data-only recording: the force rows are the evidence and the verdict says so.""" + from orisys_autocal.recording_options import RecordingOptions - panel = FingerPanel() - bundle = SensorBundle( - path=tmp_path, vertices_mm=np.array([[2, 1, 3], [4, 1, 3], [2, 4, 3]]), - faces=np.array([[0, 1, 2]]), uv_data=np.array([[0, 0], [1, 0], [0, 1]]), - loop_to_vertex=np.array([0, 1, 2]), - ) - panel.set_bundle(bundle, flat) - panel.handle_pick(2, 4) - panel.handle_pick(4, 1) - panel.table.setCurrentCell(1, 1) - panel.move_selected(-1) - destination = panel.export_sites(tmp_path / "sites.csv") - from orisys_autocal.record_overview import read_plan, plan_points - from orisys_autocal.sensor.uv_flat import mapping_sha256 - - definition = {"origin_raster_xy_px": uv_origin(flat), "raster_size_hw": [flat.height, flat.width], - "mapping_sha256": mapping_sha256(flat), "u_positive": "right", "v_positive": "up"} - imported = plan_points(read_plan(destination, definition), definition) - assert [point["uv_xy_px"] for point in imported] == [[1, 2], [-1, -1]] - with destination.open(encoding="utf-8-sig", newline="") as handle: - rows = list(csv.DictReader(handle)) - assert [(int(r["u_px"]), int(r["v_px"])) for r in rows] == [(1, 2), (-1, -1)] - for row in rows: - site = site_at(flat, int(row["u_px"]), int(row["v_px"])) - assert site.position_sensor_mm == tuple(float(row[f"{axis}_sensor_mm"]) for axis in "xyz") - assert row["mapping_sha256"] == site.mapping_sha256 - panel.table.item(0, 1).setText("999") - with pytest.raises(ValueError): - panel.export_sites(tmp_path / "invalid.csv") - assert not (tmp_path / "invalid.csv").exists() - panel.deleteLater() - - -def test_gui_hold_controls_and_session_lifecycle(make_window, trial_setup, clock, flat, tmp_path, qt_app): - from orisys_app_shared.i18n import t - - window = make_window() - panel = window.calibration_panel worker = Worker() - panel.source_panel.worker = worker - panel.trial.worker = worker - panel.source_panel.mode = SimpleNamespace(fps=30) - panel.source_panel.config_ref.setText(trial_setup["config_ref"]) - panel.set_flat(flat, trial_setup["bundle_path"]) - panel._picked(2, 1) - panel.output_path.setText(str(trial_setup["root"])) - panel.sensor_sn.setText("F-001") - assert panel.sensor_model.currentText() == "finger" - panel.levels.setText("5, 10") - panel.start_button.click() - assert panel.trial.status == "recording" - manifest = json.loads((panel.trial.session.directory / "session.json").read_text(encoding="utf-8")) - assert manifest["uv_outline"]["surface"] - assert manifest["uv_outline"]["mapping_sha256"] == manifest["uv_definition"]["mapping_sha256"] + trial = ManualTrial(worker) + directory = trial.start(**{**trial_setup, "recording": RecordingOptions(video=False)}) + + for index, (_branch, target) in enumerate(trial.steps): + trial.confirm(target) + # Two frames inside the second half of the hold: the summary's own rule. + # Each frame carries its own id: a repeated id has no single acquisition + # time and the review would exclude it. + for delay, frame_id in ((15, index * 2 + 2), (1, index * 2 + 3)): + clock.advance(delay) + worker.packet = replace( + worker.packet, capture_mono_ns=clock.now, + frame_id=frame_id, source_frame_id=frame_id, + ) + worker.emit() + clock.advance(4) + trial.poll() + assert trial.step_index == index + 1 + + trial.finish() + assert trial.recorder.stop(wait=True).ok + assert trial.poll() == "complete" + assert not list(directory.glob("raw_*.mp4")) + root = json.loads( + (directory.parent.parent / "session.json").read_text(encoding="utf-8") + ) + assert root["recording"] == { + "video": False, "codec": "mp4v", "lossless_frames": False} + + from orisys_autocal.summary import build_summary + + summary = build_summary(directory.parent.parent) + assert all(row["eligible"] for row in summary["windows"]), [ + (row["excluded_reason"], row["capture_status"], row["samples_valid"]) + for row in summary["windows"] + ] + assert all(row["capture_status"] == "complete" for row in summary["windows"]) + trial.finish_session() + + +def test_recording_settings_are_locked_by_the_first_trial(trial_setup, clock): + """One session, one recording choice: the manifest must not change mid-session.""" + from orisys_autocal.recording_options import RecordingOptions + + worker = Worker() + trial = ManualTrial(worker) + directory = trial.start( + **{**trial_setup, "recording": RecordingOptions(video=False)} + ) + root = json.loads( + (directory.parent.parent / "session.json").read_text(encoding="utf-8") + ) + assert root["recording"] == { + "video": False, "codec": "mp4v", "lossless_frames": False} + worker.emit() - assert panel.views.currentWidget() is panel.source_panel - assert not panel.sensor_sn.isEnabled() - assert not panel.close_session_button.isEnabled() - for index, force in enumerate((0, 5, 10, 5, 0)): - panel.reference.setText(str(force)) - panel.confirm_button.click() - assert panel.trial.holding - assert not panel.confirm_button.isEnabled() - assert not panel.finish_button.isEnabled() - assert panel.trial.step_index == index - assert t("calibration.holding").split("{")[0] in panel.target_label.text() - if index == 0: - panel.resize(1120, 740) - panel.show() - qt_app.processEvents() - assert panel.grab().save(str(tmp_path / "operator-hold.png")) - clock.advance(20) - panel._poll() - assert panel.trial.step_index == index + 1 - assert panel.finish_button.isEnabled() - panel.finish_button.click() - panel.trial.recorder.stop(wait=True) - panel._poll() - assert panel.close_session_button.isEnabled() - assert not panel.sensor_sn.isEnabled() - panel.close_session_button.click() - assert panel.trial.session is None - assert panel.sensor_sn.isEnabled() + trial.finish(aborted=True, reason="test ended") + trial.recorder.stop(wait=True) + trial.poll() + + with pytest.raises(ValueError, match="locked"): + trial.start(**{**trial_setup, "recording": RecordingOptions(video=True)}) + # The locked settings are what a later trial of this session uses. + second = trial.start( + **{**trial_setup, "recording": RecordingOptions(video=False)} + ) + assert trial.recording.video is False + assert second.parent.parent == directory.parent.parent + trial.recorder.stop(wait=True) + trial.poll() + trial.finish_session() + + +def test_the_capture_page_no_longer_drives_a_manual_trial(make_window): + """D1: a new capture is a one-point queue in the ``sites/`` format. + + The ManualTrial flow stays only so old ``trials/`` sessions keep reading; the + capture page owns a Campaign plus the workflow controller. The two-page + single/multi-point split it used to switch between is gone. + """ + window = make_window() + page = window.calibration_panel + assert not hasattr(page, "trial") + assert not hasattr(page, "multipoint") + assert page.campaign is not None + assert page.controller is not None window.close() -def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view( - make_window, +def test_the_capture_page_keeps_the_picked_uv_point_while_the_live_view_is_visible( + make_window, qt_app ): - from orisys_app_shared.i18n import t + """Both images are on the page at once; picking on the map keeps the point.""" from orisys_autocal.sensor.uv_flat import FlatSurface window = make_window() + window.resize(1440, 900) + window.show() + qt_app.processEvents() + window.workspace.set_current_key("sensor") + qt_app.processEvents() mask = np.full((5, 7), -1, dtype=np.int32) mask[1:4, 2:5] = 2 positions = np.zeros((5, 7, 3), dtype=np.float64) @@ -353,21 +362,61 @@ def test_calibration_page_keeps_precise_uv_site_when_switching_to_live_view( uv_min=(0, 0), uv_max=(1, 1), ) - panel = window.calibration_panel - for key, button in ( - ("start", panel.start_button), - ("confirm", panel.confirm_button), - ("finish", panel.finish_button), - ("abort", panel.abort_button), - ): - assert button.text() == t(f"calibration.{key}") - assert button.toolTip() == t(f"calibration.{key}_tip") - assert panel.target_label.text() == t("calibration.before_start") - panel.set_flat(flat, "bundle") - panel._picked(2, 1) - assert (panel.u_spin.value(), panel.v_spin.value()) == (-1, 2) - assert panel._site.raster_xy_px == (2, 1) - assert "2.000" in panel.position_value.text() - panel.views.setCurrentWidget(panel.source_panel) - assert panel._site.raster_xy_px == (2, 1) + page = window.calibration_panel + page.set_flat(flat, "bundle") + page._picked(2, 1) + assert (page.u_spin.value(), page.v_spin.value()) == (-1, 2) + assert page._site.raster_xy_px == (2, 1) + assert "2.000" in page.position_value.text() + # The UV map and the live 2-D image are visible in the same row: selecting one + # used to hide the other behind a tab. + assert page.flat_view.isVisible() and page.live_view.isVisible() + window.close() + + +def test_the_session_row_opens_the_folder_the_summary_and_the_path( + make_window, tmp_path, monkeypatch +): + """The operator can reach the recording without copying a path by hand.""" + from PySide6.QtWidgets import QApplication + from orisys_app_shared.i18n import t + + from evidence_helpers import multipoint_session + from orisys_autocal.summary import build_summary + from orisys_autocal.summary_export import publish_summary + from orisys_autocal.ui import calibration_panel as panel_module + + window = make_window() + panel = window.calibration_panel + opened: list = [] + monkeypatch.setattr( + panel_module, "reveal", lambda path, select=None: opened.append((path, select)) or True + ) + + # Nothing has been recorded yet: each action is disabled and says why. + panel._refresh() + assert not panel.open_session_button.isEnabled() + assert not panel.open_summary_button.isEnabled() + assert not panel.copy_path_button.isEnabled() + assert panel.open_session_button.toolTip() == t("cap.block.no_directory") + assert panel.open_summary_button.toolTip() == t("cap.block.no_summary") + + root = multipoint_session(tmp_path / "session", sites=("P1",), repeats=1, levels=(5.0,)) + publish_summary(root, build_summary(root)) + # The page finds the session the campaign is recording into. + panel.campaign.directory = root + panel._refresh() + + assert panel._session_directory() == root + assert panel.open_session_button.isEnabled() + assert panel.open_summary_button.isEnabled() + assert panel.copy_path_button.isEnabled() + assert panel.open_session_button.toolTip() == t("cap.action.open_session") + + panel.open_session_button.click() + assert opened[-1][0] == root + panel.open_summary_button.click() + assert opened[-1][0] == root / "summary" + panel.copy_path_button.click() + assert QApplication.clipboard().text() == str(root) window.close() diff --git a/apps/autocal/tests/test_multipoint_align.py b/apps/autocal/tests/test_multipoint_align.py index 1af4075..38e98e7 100644 --- a/apps/autocal/tests/test_multipoint_align.py +++ b/apps/autocal/tests/test_multipoint_align.py @@ -1,13 +1,16 @@ -"""Aligning the normal solves the point again instead of trusting the stored pose. +"""Aligning a point re-solves it against the geometry in force now. The stored pose is a record of what the solver said when the point was taken. Mounting offsets, the mounting angle, the tool position and the travel limits are all editable afterwards, so a stale pose can name a target this machine no longer -reaches. These tests drive the panel's own align path and watch what it emits. +reaches. These tests drive the capture page's own align path and watch what it +emits; the window that would plan the move is not involved. """ from __future__ import annotations +import json + import numpy as np import pytest @@ -41,13 +44,14 @@ def _prefix(key: str) -> str: return t(key).split("{")[0] -def _add_site(panel, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None): +def _add_site(page, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None): solved = stack.solve( np.asarray(point, dtype=np.float64), np.asarray(normal, dtype=np.float64), ) assert solved.ok - panel.campaign.add_solved_site( + page.controller.set_max_force(20.0) + page.controller.add_point( site_id, float(point[0]), float(point[1]), @@ -68,107 +72,110 @@ def _add_site(panel, stack, *, site_id="P1", point=POINT, normal=NORMAL, uv=None [5.0], 1.0, 1, - max_force_n=20.0, uv_px=uv, ) return solved -def _watch(panel, window) -> list[dict]: - """Record what the panel emits, without letting the window plan a move.""" - panel.alignRequested.disconnect() +def _watch(page) -> list[dict]: + """Record what the page emits, without letting the window plan a move.""" + page.alignRequested.disconnect() emitted: list[dict] = [] - panel.alignRequested.connect(emitted.append) + page.alignRequested.connect(emitted.append) return emitted def test_aligning_solves_the_point_against_the_mounting_in_force_now(make_window): window = make_window() - panel = window.calibration_panel.multipoint - stored = _add_site(panel, window.finger_stack) - panel._selected = "P1" # the panel is the unit under test - the table's selection, set without a click - emitted = _watch(panel, window) + page = window.calibration_panel + page._flat = _flat() # the page re-solves a UV point against the current map + stored = _add_site(page, window.finger_stack, uv=(0.0, 0.0)) + emitted = _watch(page) - panel._align() # the panel is the unit under test - assert emitted, panel.message.text() + page._align() # the page is the unit under test + assert emitted, page.message.text() assert emitted[-1]["z"] == pytest.approx(stored.contact_z_mm) + # The window reports the move cancelled, so the point stays unaligned and the + # page keeps offering an align for a retry. + page.alignment_failed("cancelled") # The operator raises the sensor on its plate: the same point now needs a - # different pose, and the panel has to ask the solver again. + # different pose, and the page has to ask the solver again. window.finger_panel.sensor_z_spin.setValue(12.0) - panel._align() # the panel is the unit under test - assert len(emitted) == 2, panel.message.text() - fresh = panel.campaign.sites[0].pose + page._align() # the page is the unit under test + assert len(emitted) == 2, page.message.text() + fresh = page.controller.campaign.sites[0].pose assert fresh["z"] == pytest.approx(emitted[-1]["z"]) assert fresh["z"] != pytest.approx(stored.contact_z_mm) + window.close() def test_a_point_the_map_has_moved_asks_to_be_taken_again(make_window): window = make_window() - panel = window.calibration_panel.multipoint - window.calibration_panel._flat = _flat() # the panel is the unit under test - the loaded surface - _add_site(panel, window.finger_stack, uv=(0.0, 0.0)) + page = window.calibration_panel + page._flat = _flat() + _add_site(page, window.finger_stack, uv=(0.0, 0.0)) # The record says the point is half a millimetre from where the map puts it. - panel.campaign.sites[0].x += 0.5 - panel._selected = "P1" # the panel is the unit under test - emitted = _watch(panel, window) + page.controller.campaign.sites[0].x += 0.5 + emitted = _watch(page) - panel._align() # the panel is the unit under test + page._align() assert not emitted - assert _prefix("multipoint.point_moved") in panel.message.text() + assert _prefix("multipoint.point_moved") in page.message.text() + window.close() def test_a_point_whose_normal_has_moved_asks_to_be_taken_again(make_window): window = make_window() - panel = window.calibration_panel.multipoint - window.calibration_panel._flat = _flat() # the panel is the unit under test - _add_site(panel, window.finger_stack, uv=(0.0, 0.0)) + page = window.calibration_panel + page._flat = _flat() + _add_site(page, window.finger_stack, uv=(0.0, 0.0)) tilted = np.asarray(NORMAL, dtype=np.float64) tilted = tilted / np.linalg.norm(tilted) - panel.campaign.sites[0].normal = (float(tilted[0]), float(tilted[1]), float(-tilted[2])) - panel._selected = "P1" # the panel is the unit under test - emitted = _watch(panel, window) + page.controller.campaign.sites[0].normal = (float(tilted[0]), float(tilted[1]), float(-tilted[2])) + emitted = _watch(page) - panel._align() # the panel is the unit under test + page._align() assert not emitted - assert _prefix("multipoint.normal_moved") in panel.message.text() + assert _prefix("multipoint.normal_moved") in page.message.text() + window.close() def test_a_point_without_a_usable_source_is_refused(make_window): window = make_window() - panel = window.calibration_panel.multipoint - _add_site(panel, window.finger_stack) - panel.campaign.sites[0].position_frame = "fixture" - panel._selected = "P1" # the panel is the unit under test - emitted = _watch(panel, window) + page = window.calibration_panel + _add_site(page, window.finger_stack) + page.controller.campaign.sites[0].position_frame = "fixture" + emitted = _watch(page) - panel._align() # the panel is the unit under test + page._align() assert not emitted - assert _prefix("multipoint.point_unmapped") in panel.message.text() + assert _prefix("multipoint.point_unmapped") in page.message.text() + window.close() def test_a_pose_the_travel_no_longer_reaches_is_never_commanded(make_window): window = make_window() - panel = window.calibration_panel.multipoint - _add_site(panel, window.finger_stack) - panel._selected = "P1" # the panel is the unit under test - emitted = _watch(panel, window) + page = window.calibration_panel + page._flat = _flat() + _add_site(page, window.finger_stack, uv=(0.0, 0.0)) + emitted = _watch(page) # A yaw axis with no travel cannot reach any of the measured points, and the # stored pose still says it could. window.finger_panel.stack.yaw_limit_deg = 0.0 - panel._align() # the panel is the unit under test + page._align() assert not emitted - assert _prefix("multipoint.pose_refused") in panel.message.text() + assert _prefix("multipoint.pose_refused") in page.message.text() + window.close() def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path): window = make_window() - panel = window.calibration_panel.multipoint - _add_site(panel, window.finger_stack) - panel._selected = "P1" # the panel is the unit under test - emitted = _watch(panel, window) - panel.campaign.start( + page = window.calibration_panel + _add_site(page, window.finger_stack) + emitted = _watch(page) + page.campaign.start( tmp_path, sensor_model="finger", sensor_sn="SN1", @@ -176,42 +183,60 @@ def test_a_point_cannot_be_aligned_in_the_middle_of_a_run(make_window, tmp_path) max_force_n=20.0, simulate=True, ) + page._refresh() - panel._align() # the panel is the unit under test + page._align() assert not emitted - assert t("multipoint.align_busy") in panel.message.text() + assert t("multipoint.align_busy") in page.message.text() + window.close() -def test_next_site_can_be_aligned_between_trials(make_window, tmp_path): - import json +def test_next_site_is_aligned_from_its_recorded_pose_between_trials(make_window, tmp_path): + """Between trials the queue is frozen, so the recorded pose is what moves. + + Re-solving and storing is an idle-only queue edit (interfaces 1.10/1.11), so the + page does not pretend to retarget the next point mid-session: the point's stored + pose is used unchanged, and re-solving after a mounting change inside a running + session is P5's job. The guard that the point itself did not move still runs. + """ window = make_window() - panel = window.calibration_panel.multipoint - _add_site(panel, window.finger_stack, site_id="P1") - _add_site(panel, window.finger_stack, site_id="P2") - emitted = _watch(panel, window) - panel.campaign.start(tmp_path, sensor_model="finger", sensor_sn="SN1", - coordinate_frame="sensor", max_force_n=20.0, simulate=True) - while panel.campaign.phase == "load": - target = panel.campaign.progress()["target_n"] - panel.campaign.confirm_reference(target) - panel.campaign.complete_hold( - now_ns=panel.campaign._hold_started_ns + int(panel.campaign._hold_s * 1e9)) - assert panel.campaign.phase == "liftoff" - panel._selected = "P2" - panel._refresh() - assert panel.align_button.isEnabled() - previous = json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text()) + page = window.calibration_panel + page._flat = _flat() + _add_site(page, window.finger_stack, site_id="P1", uv=(0.0, 0.0)) + _add_site(page, window.finger_stack, site_id="P2", uv=(1.0, 1.0)) + emitted = _watch(page) + campaign = page.controller.campaign + campaign.start( + tmp_path, sensor_model="finger", sensor_sn="SN1", + coordinate_frame="sensor", max_force_n=20.0, simulate=True, + ) + while campaign.phase == "load": + target = campaign.progress()["target_n"] + campaign.confirm_reference(target) + campaign.complete_hold( + now_ns=campaign._hold_started_ns + int(campaign._hold_s * 1e9)) + assert campaign.phase == "liftoff" + page._refresh() + assert page.controller.primary_action().key == "align_point" + previous = json.loads( + (campaign.directory / "sites/P1/repeat-001/session.json").read_text() + ) + recorded = page.controller.model().sites[1].pose["z"] window.finger_panel.sensor_z_spin.setValue(12.0) - panel._align() + page._align() assert len(emitted) == 1 - manifest = json.loads((panel.campaign.directory / "session.json").read_text()) - assert manifest["sites"][1]["pose"]["z"] == emitted[0]["z"] - panel.campaign.confirm_liftoff() - repeat = json.loads((panel.campaign.directory / "sites/P2/repeat-001/session.json").read_text()) + assert emitted[0]["z"] == pytest.approx(recorded) + # The move is reported finished by the window; until then no start is offered. + page.alignment_succeeded() + assert page.controller.primary_action().key == "confirm_liftoff" + page.primary_button.click() # confirm liftoff -> the next point's first repeat + assert campaign.phase == "load" + manifest = json.loads((campaign.directory / "session.json").read_text()) + assert manifest["sites"][1]["pose"]["z"] == pytest.approx(recorded) + repeat = json.loads((campaign.directory / "sites/P2/repeat-001/session.json").read_text()) assert repeat["pose"] == manifest["sites"][1]["pose"] - assert json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous - panel._selected = "P1" - panel._align() + assert json.loads((campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous + page._align() assert len(emitted) == 1 - panel.campaign.stop("test finished") - + campaign.stop("test finished") + window.close() diff --git a/apps/autocal/tests/test_multipoint_recording.py b/apps/autocal/tests/test_multipoint_recording.py index 2b21253..75b93f5 100644 --- a/apps/autocal/tests/test_multipoint_recording.py +++ b/apps/autocal/tests/test_multipoint_recording.py @@ -15,6 +15,7 @@ import pytest from evidence_helpers import FakeWorker from orisys_autocal.multipoint import Campaign +from orisys_autocal.recording_options import RecordingOptions class _Clock: @@ -46,10 +47,18 @@ def _campaign(*, repeats: int = 2) -> Campaign: return campaign -def _start(campaign: Campaign, root, *, simulate: bool = False) -> "object": +def _start( + campaign: Campaign, + root, + *, + simulate: bool = False, + recording: RecordingOptions | None = None, + codec_availability: dict | None = None, +) -> "object": return campaign.start( root, sensor_model="finger", sensor_sn="SN1", coordinate_frame="fixture", max_force_n=20, simulate=simulate, + recording=recording, codec_availability=codec_availability, ) @@ -323,74 +332,134 @@ def test_a_repeat_directory_that_already_exists_is_never_reused(tmp_path): campaign._advance_after_repeat() -def test_the_confirm_button_waits_until_the_capture_is_recording(make_window, tmp_path): - """A hold confirmed before the writer exists would be evidence with no video.""" +def test_a_live_start_attaches_the_recorder_before_offering_a_confirmation( + make_window, tmp_path +): + """A hold confirmed before the writer exists would be evidence with no video. + + The page owns the attach, so by the time it reports a started session the + repeat is already recording and the controller offers the confirmation. + """ from types import SimpleNamespace from orisys_app_shared.i18n import t - window = make_window() - panel = window.calibration_panel - multipoint = panel.multipoint - worker = FakeWorker() - panel.source_panel.worker = worker - panel.source_panel.mode = SimpleNamespace(fps=10) - multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) - panel.output_path.setText(str(tmp_path)) - panel.sensor_sn.setText("SN1") + from orisys_autocal.motion import BusStatus - # A started campaign whose capture was never attached: not ready. - multipoint.campaign.start( - tmp_path, sensor_model="finger", sensor_sn="SN1", - coordinate_frame="sensor", max_force_n=20, simulate=False, + window = make_window(move_confirm=False) + window.poller.service._spec = object() # noqa: SLF001 - connected, without opening anything + window.poller.service._bus = BusStatus( # noqa: SLF001 + bus_type="ecat", init_status=1, total_axes=5 ) - panel._sync() - assert multipoint.campaign.phase == "load" - assert not panel.confirm_button.isEnabled() - assert t("multipoint.capture_wait") in panel.target_label.text() - panel.reference.setText("0") - panel._confirm() - assert multipoint.campaign.phase == "load" + page = window.calibration_panel + window.workspace.set_current_key("sensor") + worker = FakeWorker() + page.source_panel.worker = worker + page.source_panel.mode = SimpleNamespace(fps=10) + page._flat = object() # a loaded bundle is all the readiness check needs here + page.settings.output_path.setText(str(tmp_path)) + page.settings.sensor_sn.setText("SN1") + page.settings.max_force.setText("20") + page.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) + # The window confirmed the move, so the session may start. + page.alignment_succeeded() + page._refresh() - multipoint.campaign.attach_capture(worker, fps=10.0) - panel._sync() - assert panel.confirm_button.isEnabled() - assert multipoint.campaign.capture_ready + action = page.controller.primary_action() + assert action.key == "start_session", (action, page.message.text()) + page.primary_button.click() + + campaign = page.campaign + assert campaign.phase == "load" + assert campaign.capture_state == "recording" + assert page.controller.primary_action().key == "confirm_target" + assert t("multipoint.capture_recording") in page.capture_label.text() window.close() -def test_a_restarted_source_is_reported_on_the_panel(make_window, tmp_path): +def test_a_restarted_source_is_reported_on_the_capture_page(make_window, tmp_path): from types import SimpleNamespace from orisys_app_shared.i18n import t - window = make_window() - panel = window.calibration_panel - multipoint = panel.multipoint - worker = FakeWorker() - panel.source_panel.worker = worker - panel.source_panel.mode = SimpleNamespace(fps=10) - multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) - multipoint.campaign.start( - tmp_path, sensor_model="finger", sensor_sn="SN1", - coordinate_frame="sensor", max_force_n=20, simulate=False, + from orisys_autocal.motion import BusStatus + + window = make_window(move_confirm=False) + window.poller.service._spec = object() # noqa: SLF001 + window.poller.service._bus = BusStatus( # noqa: SLF001 + bus_type="ecat", init_status=1, total_axes=5 ) - multipoint.campaign.attach_capture(worker, fps=10.0) - multipoint.campaign.poll() + page = window.calibration_panel + window.workspace.set_current_key("sensor") + worker = FakeWorker() + page.source_panel.worker = worker + page.source_panel.mode = SimpleNamespace(fps=10) + page._flat = object() + page.settings.output_path.setText(str(tmp_path)) + page.settings.sensor_sn.setText("SN1") + page.settings.max_force.setText("20") + page.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) + page.alignment_succeeded() + page._refresh() + page.primary_button.click() + campaign = page.campaign + assert campaign.capture_state == "recording" worker.restart() worker.emit() - multipoint._poll() - _drain(multipoint.campaign) - multipoint._refresh() + page._poll() + _drain(campaign) + page._refresh() - assert multipoint.campaign.phase == "aborted" - assert t("multipoint.capture_incomplete") in multipoint.capture_status.text() - assert t("multipoint.capture_reason_restarted") in multipoint.capture_status.text() + assert campaign.phase == "aborted" + assert t("multipoint.capture_incomplete") in page.capture_label.text() + assert t("multipoint.capture_reason_restarted") in page.capture_label.text() manifest = json.loads( - (multipoint.campaign.directory / "sites" / "P1" / "repeat-001" / "session.json") + (campaign.directory / "sites" / "P1" / "repeat-001" / "session.json") .read_text(encoding="utf-8") ) assert manifest["capture_status"] == "incomplete" assert manifest["capture_reason"] == "source_restarted" window.close() + + +def test_a_repeat_recorded_without_video_finishes_complete(tmp_path, clock): + """Choosing data only must not make every repeat incomplete.""" + from orisys_autocal.session_review import review_session + from orisys_autocal.summary import build_summary + + campaign = _campaign(repeats=1) + root = _start(campaign, tmp_path, recording=RecordingOptions(video=False)) + worker = FakeWorker(clock=lambda: clock.now) + campaign.attach_capture(worker, fps=10.0) + _record_repeat(campaign, worker, clock=clock) + assert campaign.phase == "done" + + session = _session(root) + assert session["recording"] == { + "video": False, "codec": "mp4v", "lossless_frames": False} + trial = session["trials"][0] + assert trial["status"] == "complete" + assert trial["capture_status"] == "complete" + capture = root / trial["directory"] / "capture" + assert not list(capture.glob("raw_*.mp4")) + + review = review_session(root) + assert review["recording"]["video"] is False + summary = build_summary(root) + assert all(row["eligible"] for row in summary["windows"]) + assert all(row["FN_mean"] is not None for row in summary["windows"]) + + +def test_an_unavailable_codec_is_refused_before_the_session_starts(tmp_path): + """No silent fallback to mp4v: an encoder that failed the probe stops the start.""" + campaign = _campaign(repeats=1) + availability = {"mp4v": True, "avc1": False, "H264": False} + + with pytest.raises(ValueError, match="avc1"): + _start(campaign, tmp_path, recording=RecordingOptions(codec="avc1"), + codec_availability=availability) + + assert campaign.directory is None + assert campaign.phase == "idle" + assert list(tmp_path.iterdir()) == [] diff --git a/apps/autocal/tests/test_preferences_isolation.py b/apps/autocal/tests/test_preferences_isolation.py index 481c2ee..8d1d702 100644 --- a/apps/autocal/tests/test_preferences_isolation.py +++ b/apps/autocal/tests/test_preferences_isolation.py @@ -75,3 +75,28 @@ def test_a_window_that_closes_still_lands_in_the_scratch_directory(qt_app): after = INSTALLED.read_bytes() if INSTALLED.is_file() else None assert after == before, "closing a test window rewrote the installed preferences" + + +def test_a_stored_recording_block_without_the_newer_keys_gets_each_default(): + """The upgrade path for the recording preference, through the same net. + + A stored ``recording`` block replaces the whole default block, exactly like + ``safety_limits``: a codec or the lossless flag added later would otherwise + read as absent and the recorder would be configured from a hole. + """ + import json + + from orisys_app_shared.paths import preference_path + + from orisys_autocal.preferences import load_preferences + from orisys_autocal.recording_options import RecordingOptions, default_options + + path = preference_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"recording": {"video": False}}), encoding="utf-8") + + options = default_options(load_preferences()) + + assert options == RecordingOptions(video=False) + assert options.codec == "mp4v" + assert options.lossless_frames is False diff --git a/apps/autocal/tests/test_recording_options.py b/apps/autocal/tests/test_recording_options.py new file mode 100644 index 0000000..ef20bbe --- /dev/null +++ b/apps/autocal/tests/test_recording_options.py @@ -0,0 +1,100 @@ +"""Recording options: the settings a session is recorded with, and what can encode. + +The defaults are a decision, not a preference: the measured mp4v bitrate is the +baseline until H.264 equivalence is shown (plan D2), so a missing block must mean +"video expected, mp4v" rather than whatever the machine happens to encode. +""" + +from __future__ import annotations + +import pytest + +from orisys_autocal.recording_options import ( + CODEC_CANDIDATES, + DEFAULT_CODEC, + DEFAULT_LOSSLESS_FRAMES, + DEFAULT_VIDEO, + RecordingOptions, + default_options, + make_record_config, + probe_codecs, + require_codec_available, +) + + +def test_the_defaults_are_the_confirmed_recording_decision(): + options = RecordingOptions() + + assert options.video is DEFAULT_VIDEO is True + assert options.codec == DEFAULT_CODEC == "mp4v" + assert options.lossless_frames is DEFAULT_LOSSLESS_FRAMES is False + assert options.as_block() == { + "video": True, "codec": "mp4v", "lossless_frames": False} + + +def test_the_block_round_trips_through_a_session_manifest(): + options = RecordingOptions(video=False, codec="avc1", lossless_frames=True) + + assert RecordingOptions.from_block(options.as_block()) == options + + +def test_a_missing_or_partial_block_means_video_was_expected(): + """Old sessions have no block; their video is required, so they keep behaving.""" + assert RecordingOptions.from_block(None) == RecordingOptions() + assert RecordingOptions.from_block({}) == RecordingOptions() + assert RecordingOptions.from_block("not a block") == RecordingOptions() + # One stored key is not a reason to discard the others. + assert RecordingOptions.from_block({"video": False}) == RecordingOptions(video=False) + assert RecordingOptions.from_block({"codec": "avc1"}) == RecordingOptions(codec="avc1") + + +def test_make_record_config_maps_every_option_and_keeps_the_trial_window(): + options = RecordingOptions(video=False, codec="avc1", lossless_frames=True) + + config = make_record_config(options, 12.5) + + assert config.video is False + assert config.codec == "avc1" + assert config.lossless_frames is True + assert config.fps == 12.5 + # One continuous recording per trial: the recorder is never segmented. + assert config.segment_seconds == 24 * 3600.0 + assert config.preserve_all_frames is True + + +def test_the_default_options_read_the_preference_block_with_fallbacks(): + assert default_options({}) == RecordingOptions() + assert default_options(None) == RecordingOptions() + assert default_options({"recording": {"video": False, "codec": "avc1", + "lossless_frames": True}}) == RecordingOptions( + video=False, codec="avc1", lossless_frames=True) + # The upgrade path: a block saved before a key existed still gets its default. + assert default_options({"recording": {"video": False}}) == RecordingOptions(video=False) + + +def test_probing_reports_every_candidate_and_leaves_no_probe_files(tmp_path): + scratch = tmp_path / "scratch" + + availability = probe_codecs(scratch) + + assert set(availability) == set(CODEC_CANDIDATES) + # The suite writes real mp4v clips everywhere else, so this one must be found. + assert availability["mp4v"] is True + assert all(isinstance(value, bool) for value in availability.values()) + assert list(scratch.rglob("*.mp4")) == [] + + +def test_an_unavailable_codec_is_refused_before_recording(): + availability = {"mp4v": True, "avc1": False, "H264": False} + + with pytest.raises(ValueError, match="avc1"): + require_codec_available(RecordingOptions(codec="avc1"), availability) + + # The probes that passed are accepted, and a data-only recording needs no codec. + require_codec_available(RecordingOptions(codec="mp4v"), availability) + require_codec_available(RecordingOptions(video=False, codec="avc1"), availability) + + +def test_an_unprobed_codec_is_left_to_the_caller(): + """Availability is probed once at start-up; without a result nothing is assumed.""" + require_codec_available(RecordingOptions(codec="avc1"), None) diff --git a/apps/autocal/tests/test_reveal.py b/apps/autocal/tests/test_reveal.py new file mode 100644 index 0000000..a2681d6 --- /dev/null +++ b/apps/autocal/tests/test_reveal.py @@ -0,0 +1,94 @@ +"""Opening a session folder in the platform file manager. + +The operator needs the folder the recording is in, not a dialog that only shows a +path string. The launch is mocked here, because the test must assert the exact +argument the platform file manager receives — ``explorer /select,`` is a +single argument whose comma and quoting are the part that breaks silently. +""" + +from __future__ import annotations + +import pytest + +from orisys_autocal.ui import reveal as reveal_module + + +def _capture_launch(monkeypatch) -> list: + calls: list = [] + monkeypatch.setattr(reveal_module.subprocess, "Popen", lambda command: calls.append(command)) + return calls + + +@pytest.mark.parametrize( + ("platform", "executable"), + [("win32", "explorer"), ("linux", "xdg-open"), ("darwin", "open")], +) +def test_revealing_a_folder_uses_the_platform_file_manager( + tmp_path, monkeypatch, platform, executable +): + folder = tmp_path / "session" + folder.mkdir() + monkeypatch.setattr(reveal_module.sys, "platform", platform) + calls = _capture_launch(monkeypatch) + + assert reveal_module.reveal(folder) is True + + assert calls == [[executable, str(folder)]] + + +def test_a_selected_file_is_highlighted_with_explorer(tmp_path, monkeypatch): + folder = tmp_path / "session" + folder.mkdir() + video = folder / "raw_0001.mp4" + video.write_bytes(b"video") + monkeypatch.setattr(reveal_module.sys, "platform", "win32") + calls = _capture_launch(monkeypatch) + + assert reveal_module.reveal(folder, select=video) is True + + # One command-line string: the comma belongs to the switch and the path is + # quoted, which is what explorer expects for a path with spaces. + assert calls == [f'explorer /select,"{video}"'] + + +def test_a_selected_file_that_is_gone_falls_back_to_the_folder(tmp_path, monkeypatch): + folder = tmp_path / "session" + folder.mkdir() + missing = folder / "raw_0001.mp4" + monkeypatch.setattr(reveal_module.sys, "platform", "win32") + calls = _capture_launch(monkeypatch) + + assert reveal_module.reveal(folder, select=missing) is True + + assert calls == [["explorer", str(folder)]] + + +def test_a_missing_path_is_not_opened(tmp_path, monkeypatch): + monkeypatch.setattr(reveal_module.sys, "platform", "win32") + calls = _capture_launch(monkeypatch) + + assert reveal_module.reveal(tmp_path / "gone") is False + + assert calls == [] + + +def test_no_path_is_not_opened(monkeypatch): + calls = _capture_launch(monkeypatch) + + assert reveal_module.reveal(None) is False + assert reveal_module.reveal("") is False + + assert calls == [] + + +def test_a_failed_launch_is_reported_instead_of_raised(tmp_path, monkeypatch): + folder = tmp_path / "session" + folder.mkdir() + monkeypatch.setattr(reveal_module.sys, "platform", "win32") + + def fail(command): + raise OSError("no file manager") + + monkeypatch.setattr(reveal_module.subprocess, "Popen", fail) + + assert reveal_module.reveal(folder) is False diff --git a/apps/autocal/tests/test_summary.py b/apps/autocal/tests/test_summary.py index ac692a1..7a780c4 100644 --- a/apps/autocal/tests/test_summary.py +++ b/apps/autocal/tests/test_summary.py @@ -263,3 +263,45 @@ def test_an_axis_without_samples_is_unavailable_without_blocking_the_others(tmp_ assert by_axis["FY"]["R2"] is None assert by_axis["FY"]["slope_raw_per_N"] == pytest.approx(0.0) assert summary["windows"][0]["axis_samples"]["FX"] == 0 + + +#: The block a session recorded without video writes (interfaces.md 2.2). +VIDEO_OFF = {"video": False, "codec": "mp4v", "lossless_frames": False} + + +def test_a_session_recorded_without_video_is_still_eligible(tmp_path): + """The operator chose data only: the force rows are the evidence, not a video.""" + root = multipoint_session( + tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(), + video=False, recording_block=VIDEO_OFF, capture_status="complete", + ) + + summary = build_summary(root) + + assert summary["windows"] + assert all(row["capture_status"] == "complete" for row in summary["windows"]) + assert all(row["eligible"] for row in summary["windows"]) + assert all(row["FN_mean"] is not None for row in summary["windows"]) + assert summary["sensitivity"] + assert summary["missing_reasons"] == [] + + +def test_a_session_without_a_recording_block_still_expects_video(tmp_path): + """Old sessions have no block, so the video stays required exactly as before.""" + root = multipoint_session( + tmp_path / "rec", sites=("P1",), repeats=1, levels=_levels(), + video=False, # data rows only, and no block saying that was intended + ) + + from orisys_autocal.recording_options import RecordingOptions + from orisys_autocal.session_review import review_session + + review = review_session(root) + assert review["recording"] == RecordingOptions().as_block() + assert RecordingOptions.from_block(review.get("recording")).video is True + + summary = build_summary(root) + assert all(row["eligible"] is False for row in summary["windows"]) + assert all(row["excluded_reason"] == REASON_NO_FRAME_SOURCE + for row in summary["windows"]) + assert REASON_NO_FRAME_SOURCE in summary["missing_reasons"] diff --git a/apps/autocal/tests/test_summary_export.py b/apps/autocal/tests/test_summary_export.py index c1b5fa2..185b7dc 100644 --- a/apps/autocal/tests/test_summary_export.py +++ b/apps/autocal/tests/test_summary_export.py @@ -3,7 +3,9 @@ from __future__ import annotations import csv +import hashlib import json +import logging import pytest @@ -14,11 +16,16 @@ from orisys_autocal.summary_export import ( POINTER_NAME, SummaryExportError, load_summary, + published_summary_directory, publish_summary, render_html, ) +def _sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + @pytest.fixture def session(tmp_path): root = multipoint_session( @@ -173,3 +180,85 @@ def test_the_sensitivity_csv_has_one_row_per_point_trial_axis(session): assert {row["review_status"] for row in rows} == {"candidate"} fn = [row for row in rows if row["component"] == "FN"] assert all(float(row["slope_raw_per_N"]) == pytest.approx(40.0) for row in fn) + + +def test_the_published_files_are_mirrored_under_the_session_root(session): + """The folder the operator opens is the byte-identical copy the pointer names.""" + summary = build_summary(session) + + published = publish_summary(session, summary) + + pointer = _pointer(session) + flat = session / "summary" + assert flat.is_dir() + assert sorted(path.name for path in flat.iterdir()) == sorted(pointer["files"]) + for name, digest in pointer["files"].items(): + assert _sha256(flat / name) == digest, name + assert (flat / name).read_bytes() == (published / name).read_bytes(), name + # The readable copy is not a second pointer: the authoritative path is derived/. + assert pointer["path"] == f"derived/{published.name}" + assert published_summary_directory(session) == flat + assert load_summary(session)[1]["summary_id"] == summary["summary_id"] + + +def test_a_failed_copy_reports_without_moving_the_pointer_back(session, monkeypatch, caplog): + """Losing the readable copy must not lose the published evidence or the pointer.""" + import orisys_autocal.summary_export as module + + first = build_summary(session) + publish_summary(session, first) + flat = session / "summary" + before = {path.name: _sha256(path) for path in flat.iterdir()} + first_pointer = _pointer(session) + + second = build_summary(session) + second["generated_at"] = "2999-01-01T00:00:00+00:00" + reports: list[str] = [] + + def explode(*args, **kwargs): + raise OSError("no space left on device") + + monkeypatch.setattr(module, "_write_flat_summary", explode) + with caplog.at_level(logging.WARNING, logger="orisys_autocal.summary_export"): + published = publish_summary(session, second, on_warning=reports.append) + + pointer = _pointer(session) + assert pointer["summary_id"] == second["summary_id"] + assert pointer["path"] == f"derived/{published.name}" + assert first_pointer["path"] != pointer["path"] + for name, digest in pointer["files"].items(): + assert _sha256(published / name) == digest, name + # The previous readable copy still stands, unchanged. + assert {path.name: _sha256(path) for path in flat.iterdir()} == before + assert reports and "no space left on device" in reports[0] + assert any("no space left on device" in record.message for record in caplog.records) + + +def test_a_stale_readable_copy_is_not_offered_as_the_current_summary(session, monkeypatch): + """A failed mirror keeps the older copy; opening it must not pass it off as current. + + The pointer has moved on, so ``summary/`` describes a superseded version. The + operator must be sent to the directory the pointer names, not to numbers the + pointer no longer claims. + """ + import orisys_autocal.summary_export as module + + first = build_summary(session) + publish_summary(session, first) + + second = build_summary(session) + second["generated_at"] = "2999-01-01T00:00:00+00:00" + + def explode(*args, **kwargs): + raise OSError("no space left on device") + + monkeypatch.setattr(module, "_write_flat_summary", explode) + published = publish_summary(session, second) + + pointer = _pointer(session) + assert pointer["summary_id"] == second["summary_id"] + opened = published_summary_directory(session) + assert opened is not None + assert opened == published, "the stale summary/ was offered as the current summary" + stored = json.loads((opened / "summary.json").read_text(encoding="utf-8")) + assert stored["summary_id"] == second["summary_id"] diff --git a/apps/autocal/tests/test_summary_lifecycle.py b/apps/autocal/tests/test_summary_lifecycle.py index 88ac764..f3dc4dd 100644 --- a/apps/autocal/tests/test_summary_lifecycle.py +++ b/apps/autocal/tests/test_summary_lifecycle.py @@ -32,11 +32,19 @@ def _finish_simulated_campaign(campaign) -> None: def _acquired_hashes(root) -> dict[str, str]: - """Hashes of the recorded evidence only: derived results are not evidence.""" + """Hashes of the recorded evidence only: export artifacts are not evidence. + + The published summary lives in ``derived/`` and its readable mirror in + ``summary/``; both are written by the export, so neither is part of the + acquisition and neither may appear in this comparison. + """ return { str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*") - if path.is_file() and "derived" not in path.parts and path.name != POINTER_NAME + if path.is_file() + and "derived" not in path.parts + and "summary" not in path.parts + and path.name != POINTER_NAME } @@ -78,8 +86,8 @@ def test_a_failed_export_is_reported_and_can_be_retried( _pump(panel) assert not (root / POINTER_NAME).exists() - assert "disk full" in panel.result_label.text() - assert panel.trial.status == "idle" # the acquisition result is untouched + assert "disk full" in panel.message.text() + assert panel._directory() == "" # the acquisition result is untouched monkeypatch.undo() assert panel.publish_summary_for(root) @@ -127,16 +135,16 @@ def test_a_published_candidate_is_republished_when_the_review_is_saved(qt_app, t def test_the_multipoint_end_of_campaign_starts_the_export(make_window, tmp_path): window = make_window() panel = window.calibration_panel - multipoint = panel.multipoint - multipoint.campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) - directory = multipoint.campaign.start( + campaign = panel.campaign + campaign.add_site("P1", 0, 0, 1, [5.0], 1, 1, max_force_n=20) + directory = campaign.start( tmp_path, sensor_model="finger", sensor_sn="SN1", coordinate_frame="sensor", max_force_n=20, simulate=True, ) - _finish_simulated_campaign(multipoint.campaign) - assert multipoint.campaign.phase == "done" + _finish_simulated_campaign(campaign) + assert campaign.phase == "done" - multipoint._poll() + panel._poll() _pump(panel) assert (directory / POINTER_NAME).is_file()