From bcc55a3b977d9c0ab4e092ca571365c8b67ff5f1 Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Thu, 15 Oct 2026 06:52:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=9C=9D=E4=B8=8A=E5=88=A4?= =?UTF-8?q?=E6=96=AD=E3=80=81=E6=8E=A5=E8=A7=A6=E9=AB=98=E5=BA=A6=E4=B8=8E?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E5=A7=BF=E6=80=81=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝; 现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。 离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现 19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。 --- apps/autocal/config/locales/en_US.json | 26 +- apps/autocal/config/locales/zh_CN.json | 26 +- apps/autocal/config/preferences.default.json | 8 +- .../docs/calibration-data-format.zh.md | 4 + apps/autocal/docs/finger-debug-findings.md | 18 +- apps/autocal/docs/finger-preview.md | 2 +- apps/autocal/docs/inverse-kinematics.md | 119 ++++--- .../autocal/docs/motion-correction-plan.zh.md | 62 ++-- .../autocal/docs/multipoint-data-format.zh.md | 61 ++++ apps/autocal/docs/workflow.md | 18 +- .../src/orisys_autocal/motion/__init__.py | 4 + .../src/orisys_autocal/motion/kinematics.py | 252 +++++++++++---- .../src/orisys_autocal/motion/pose_move.py | 72 +++++ .../autocal/src/orisys_autocal/preferences.py | 35 +- .../src/orisys_autocal/record_overview.py | 2 +- .../src/orisys_autocal/session_review.py | 293 ++++++++++++++++- .../src/orisys_autocal/ui/device_setup.py | 125 +++++++- .../src/orisys_autocal/ui/multipoint_panel.py | 151 +++++++-- .../src/orisys_autocal/ui/sensor_panel.py | 55 +++- apps/autocal/src/orisys_autocal/ui/window.py | 53 +++- apps/autocal/tests/test_device_setup.py | 69 +++- apps/autocal/tests/test_finger_panel.py | 83 ++++- apps/autocal/tests/test_ik_analytical.py | 16 +- apps/autocal/tests/test_kinematics.py | 196 +++++++++++- apps/autocal/tests/test_multipoint.py | 12 +- apps/autocal/tests/test_multipoint_align.py | 207 ++++++++++++ apps/autocal/tests/test_pose_move.py | 61 ++++ .../tests/test_session_review_multipoint.py | 298 ++++++++++++++++++ apps/autocal/tools/panel_check.py | 60 +++- 29 files changed, 2179 insertions(+), 209 deletions(-) create mode 100644 apps/autocal/docs/multipoint-data-format.zh.md create mode 100644 apps/autocal/tests/test_multipoint_align.py create mode 100644 apps/autocal/tests/test_session_review_multipoint.py diff --git a/apps/autocal/config/locales/en_US.json b/apps/autocal/config/locales/en_US.json index 4bbdd1c..213312e 100644 --- a/apps/autocal/config/locales/en_US.json +++ b/apps/autocal/config/locales/en_US.json @@ -43,6 +43,13 @@ "role_pitch": "pitch (tilt)", "role_axis": "Drive", "axes_duplicate": "One drive is claimed by more than one direction: {detail}", + "tab_press": "Press zero", + "press_hint": "The solver reports the contact's geometric height above the table's origin plane, in millimetres. The drive is commanded in its own coordinate, counted from its own mechanical zero. One bench measurement relates the two. Until it is entered, Execute holds the press axis rather than passing a geometric height to the controller as a target.", + "press_zero": "Zero (reading with the tip on the origin plane)", + "press_rising": "A larger coordinate lifts the tip", + "press_calibrated": "Measured and confirmed on this machine", + "press_set": "Calibrated: press target = zero ± contact height.", + "press_unset": "Not calibrated: Execute holds the press axis.", "close": "Close", "chip_hint": "Open device setup — {name}" }, @@ -56,7 +63,7 @@ "confirm_title": "Confirm motion", "confirm_execute": "Will move these axes together:\n\n{steps}\n\nCheck this against the machine before continuing.", "confirm_return": "Will move these axes back to zero together:\n\n{steps}", - "hold_note": "Not moved: {roles} (the press axis is P7's business — contact detection and press depth)", + "hold_note": "Not moved: {roles} (these axes stay where they are and take no part in this move)", "cancelled": "Cancelled; nothing was commanded", "confirm_check": "Confirm before moving", "confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.", @@ -359,7 +366,11 @@ "no_map": "Load a sensor bundle so Select point shows the surface.", "no_solver": "The pose solver is not connected.", "align_select": "Select the destination point in the table, then click Align normal.", + "align_busy": "This run is still recording: wait for it to finish before aligning another point, because moving the platform now would invalidate it.", "pose_refused": "This pose cannot be commanded: {reason}", + "point_moved": "This point's recorded position no longer matches the current map (off by {mm} mm). Pick it again on Select point and confirm it; do not move the platform to the old record.", + "normal_moved": "This point's recorded normal no longer matches the current map (off by {deg}°). Pick it again on Select point and confirm it.", + "point_unmapped": "This point has no usable source coordinate: no UV mapping and not a sensor-frame position. Pick it again on Select point.", "no_finger": "The finger page is not connected.", "no_finger_points": "The finger page has no picked points.", "branch_baseline": "baseline", @@ -401,9 +412,20 @@ "row_sensor_normal": "Surface normal", "row_platform_point": "Platform point (mm)", "row_pose": "Pose X / Y / yaw / pitch", + "row_branch": "Chosen pose and why", "row_table_move": "Table move X / Y", + "row_press": "Press-axis target", "row_residual": "Alignment residual", "row_frames": "Frame mapping", + "branch_none": "—", + "branch_value": "{reason} (this pose {travel}° total, the other exact pose {other}°)", + "branch_only": "only exact pose", + "branch_alignment": "smallest residual", + "branch_in_range": "inside travel", + "branch_upright": "face still up", + "branch_smallest": "smallest turn", + "press_unset": "Zero not measured: this move holds the press axis (enter it in Device setup → Press zero)", + "press_target": "{target} mm (geometric contact height {height} mm)", "tool_xy": "Tool X / Y", "tool_hint": "Where the tool is in the table frame. Each point's table move is measured from this position.", "sensor_xy": "Sensor X / Y", @@ -681,6 +703,8 @@ "no_sites": "No sites in this session", "candidate": "Candidate window", "insufficient_samples": "Insufficient valid samples", + "no_frame_source": "This repeat has no recorded frame source: the reference window exists, but no raw normal signal can be computed from it", + "time_basis_not_comparable": "Time base is not comparable with the hold window (the recording is not live capture on this host); it was left out of the calculation", "replay": "Replay debug", "unverified": "Stability unverified", "fit": "Exploratory loading fit: sensitivity {sensitivity_raw_per_n:.6g} raw/N; gain {gain_n_per_raw:.6g} N/raw; offset {offset_n:.6g} N; fit RMSE {rmse_n:.4g} N (not independent validation error)", diff --git a/apps/autocal/config/locales/zh_CN.json b/apps/autocal/config/locales/zh_CN.json index 3b82ff8..7264be3 100644 --- a/apps/autocal/config/locales/zh_CN.json +++ b/apps/autocal/config/locales/zh_CN.json @@ -43,6 +43,13 @@ "role_pitch": "pitch(俯仰)", "role_axis": "驱动器", "axes_duplicate": "同一驱动器被分配给多个方向:{detail}", + "tab_press": "压轴零位", + "press_hint": "解算给出的是接触点在台面原点平面之上的几何高度(mm);驱动器收到的是它自己的坐标,从它自己的机械零位算起。两者只差一个实机测量值。没有标定之前,执行动作会保持压轴不动,而不会把几何高度当成控制器目标直接下发。", + "press_zero": "零点(压头触到台面原点平面时的读数)", + "press_rising": "坐标增大时压头上升", + "press_calibrated": "已在本机测量并确认", + "press_set": "已标定:压轴目标 = 零点 ± 接触高度。", + "press_unset": "未标定:执行动作会保持压轴不动。", "close": "关闭", "chip_hint": "点击打开设备设置 — {name}" }, @@ -56,7 +63,7 @@ "confirm_title": "确认运动", "confirm_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。", "confirm_return": "将同时移回零点:\n\n{steps}\n\n请确认机床上没有干涉。", - "hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)", + "hold_note": "不动作:{roles}(这些轴保持现状,不参与本次动作)", "cancelled": "已取消,未发出任何运动", "confirm_check": "移动前确认", "confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。", @@ -359,7 +366,11 @@ "no_map": "请先载入传感器导出包,让「选择测点」出现曲面。", "no_solver": "姿态求解还没有接上。", "align_select": "请先在左边表里点中要去的测点,再点「对齐法向」。", + "align_busy": "这一轮还在采集:等它结束再对准其它测点,中途移动平台会让这次记录作废。", "pose_refused": "这个点的姿态不能执行:{reason}", + "point_moved": "这个点记录的坐标与当前映射不符(相差 {mm} mm)。请重新在「选择测点」里取这个点并确认,不要用旧记录移动平台。", + "normal_moved": "这个点记录的法向与当前映射不符(相差 {deg}°)。请重新在「选择测点」里取这个点并确认。", + "point_unmapped": "这个点没有可用的来源坐标(既没有 UV 映射,也不是传感器坐标)。请重新在「选择测点」里取这个点。", "no_finger": "手指调试页面还没有接上。", "no_finger_points": "手指调试里还没有选点。", "branch_baseline": "空载", @@ -401,9 +412,20 @@ "row_sensor_normal": "曲面法向", "row_platform_point": "平台坐标 (mm)", "row_pose": "位姿 X / Y / yaw / pitch", + "row_branch": "所选姿态与理由", "row_table_move": "工作台移动 X / Y", + "row_press": "压轴目标", "row_residual": "对齐残差", "row_frames": "坐标映射", + "branch_none": "—", + "branch_value": "{reason}(本解合计 {travel}°,另一精确解 {other}°)", + "branch_only": "唯一精确解", + "branch_alignment": "残差最小", + "branch_in_range": "行程内", + "branch_upright": "传感面仍朝上", + "branch_smallest": "转动量最小", + "press_unset": "未标定零位:本动作保持压轴不动(在 设备设置 → 压轴零位 填入实测零位)", + "press_target": "{target} mm(几何接触高度 {height} mm)", "tool_xy": "刀具 X / Y", "tool_hint": "刀具在工作台坐标系中的位置。每个测点的工作台移动都相对这个位置计算。", "sensor_xy": "传感器 X / Y", @@ -681,6 +703,8 @@ "no_sites": "会话没有测点", "candidate": "候选窗口", "insufficient_samples": "有效样本不足", + "no_frame_source": "本次循环没有录像帧来源:参考窗口已记录,但没有可用于计算的原始法向信号", + "time_basis_not_comparable": "时间基准与保持窗口不可比(录像不是本机实时采集),未参与计算", "replay": "视频调试", "unverified": "稳定性未复核", "fit": "加载分支初步拟合:灵敏度 {sensitivity_raw_per_n:.6g} raw/N;增益 {gain_n_per_raw:.6g} N/raw;偏置 {offset_n:.6g} N;拟合 RMSE {rmse_n:.4g} N(不是独立验证误差)", diff --git a/apps/autocal/config/preferences.default.json b/apps/autocal/config/preferences.default.json index 196146b..aa0b84d 100644 --- a/apps/autocal/config/preferences.default.json +++ b/apps/autocal/config/preferences.default.json @@ -24,14 +24,20 @@ "max_travel_mm": 100.0, "max_descent_mm": 10.0, "max_press_depth_mm": 1.0, + "max_yaw_deg": 180.0, + "max_pitch_deg": 90.0, "follow_error_mm": 0.5, "contact_search_mm": 3.0 }, + "press_axis": { + "zero_mm": 0.0, + "rising": true, + "calibrated": false + }, "sensor_setup": { "sensor_type": "box", "config_ref": "", "geometry_id": "", - "press_axis": 2, "probe_tip_offset_mm": [0.0, 0.0, 0.0], "probe_tip_set": false, "sensor_to_platform": [], diff --git a/apps/autocal/docs/calibration-data-format.zh.md b/apps/autocal/docs/calibration-data-format.zh.md index ba1f99f..5ef22fa 100644 --- a/apps/autocal/docs/calibration-data-format.zh.md +++ b/apps/autocal/docs/calibration-data-format.zh.md @@ -96,6 +96,10 @@ finger_F001_<时间>_<标识>/ 参考窗口采用起点包含、终点不包含的时间范围。换档没有恒定力标签,视频和算法输出仍保存。`trial_finished` 表示请求正常结束;最终是否 complete 以排空后的 manifest 为准。保存失败不能当作成功;重试不覆盖原文件。 +### 多点会话格式 + +多点标定按测点组织(`sites//repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。 + ## 软件数据流及后续 | 输入 → 处理 → 输出 | 所有者 | diff --git a/apps/autocal/docs/finger-debug-findings.md b/apps/autocal/docs/finger-debug-findings.md index 4365b11..42b47c6 100644 --- a/apps/autocal/docs/finger-debug-findings.md +++ b/apps/autocal/docs/finger-debug-findings.md @@ -53,7 +53,7 @@ With the corrected envelope, solved by closed form | Envelope | Worst residual | Within 0.5 deg | In range | |---|---|---|---| -| pitch 360 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** | +| pitch 90 deg, **yaw +-180 deg** (the machine) | **1.2e-06 deg** | 100% | **1032 / 1032** | Every vertex is reachable, and the residual is at the floating-point floor rather than at any clamp. The "0.21 deg worst, genuinely clamped vertex" claim above was @@ -65,11 +65,17 @@ the reachability evidence. Whether they help *repeatability* — a shorter pitch move, a better-conditioned contact — is a separate bench question, not a kinematic one. -One thing the corrected solve did surface: **51 of 1032 vertices end inverted**, -whichever closed-form branch is chosen, because the tool direction is -antiparallel to the sensor's own up axis. That is the tool-polarity question, it -needs the bench, and it is *not* visible in the residual. See -`inverse-kinematics.md` → *Still open*. +**Correction (motion-correction-plan.zh.md task 1).** This note used to claim that +**51 of 1032 vertices end inverted** whichever branch is chosen, because the tool +direction was antiparallel to the sensor's own up axis. That count came from +`uprightness` comparing a platform axis through `to_sensor(np.eye(3)[1])`, which is +the sensor's own **-Z** — a horizontal axis — so a body rolled about the vertical +was reported as turned over. Measured against the post-mount nominal up (the tool +axis) no exported vertex is inverted, and the corrected flag changes no vertex's +branch selection. What did invert poses was the `approach_sign` polarity bug +recorded above; the residual could not see it, and neither could that up test. See +`inverse-kinematics.md` → *the face is aimed at +Z* and *the two exact poses of one +normal*. ## What was wrong before, and why it is worth recording diff --git a/apps/autocal/docs/finger-preview.md b/apps/autocal/docs/finger-preview.md index 6e56f7d..21528a1 100644 --- a/apps/autocal/docs/finger-preview.md +++ b/apps/autocal/docs/finger-preview.md @@ -67,7 +67,7 @@ Three buttons, and where each one lives is deliberate: | Button | Where | What it does | | --- | --- | --- | -| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: **yaw, then pitch, then table X/Y**. The press axis **z is not commanded** — pressing is P7, with contact detection and press depth, neither of which exists here, so a move to the contact height would be a press with none of its guards. | +| **执行动作** Execute | this screen, next to 播放动作 | Sends the selected point's solved pose to the platform: the stages together with the table X/Y, and the press axis **only when its zero is calibrated** (设备设置 → 压轴). The geometric contact height is not a controller coordinate, so an uncalibrated press axis is *held* and reported as held rather than commanded with a number the machine has not measured. | | **停止** Stop | here *and* the strip's 全部停止 | The same command. Two access points, one implementation: during a move the operator's eyes are on this pane, and an emergency stop has to be where they are. | | **回初始位** Return to zero | the axis rail, on every tab | Every drive to 0 — press axis first, then the stages, then the table. A machine-level action, wanted after work in any tab, so it is not repeated inside one. | diff --git a/apps/autocal/docs/inverse-kinematics.md b/apps/autocal/docs/inverse-kinematics.md index ac91e5f..1fbf5da 100644 --- a/apps/autocal/docs/inverse-kinematics.md +++ b/apps/autocal/docs/inverse-kinematics.md @@ -17,7 +17,7 @@ the contact point ends up, and Z is reported, never solved. |---|---|---| | Tool axis | `z` | `motion/frames.py` | | Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` | -| Pitch axis | `y`, travel 360° | `PITCH_AXIS`, `finger_pitch_limit_deg` | +| Pitch axis | `y`, travel **90°** | `PITCH_AXIS`, `finger_pitch_limit_deg` | | Stage order | `R_pitch @ R_yaw` | `stage_rotation` | | Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` | | Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` | @@ -29,9 +29,10 @@ tool feeds **down** onto it. So the sensing face — and the surface normal — along **`+Z`**, and the solver's aim is to bring the normal onto `+Z`. `approach_sign` was **`-1`**, aiming every normal at `-Z` and asking the machine to -turn the sensor over. It survived because the residual is sign-blind -(`arccos` maps a face turned fully over to `0.0000°`, the same as perfectly -aligned) and because every test asserted only that the residual was *small*. The +turn the sensor over. Nothing caught it, because the residual is measured against +the aim vector itself: `acos(turned · tool_direction)` reports `0.0000°` for a face +that landed *on the aim*, and the aim had been flipped — so a pose turned fully over +scored as perfect. Every test asserted only that the residual was *small*. The failure was visible **only** as the model flipping upside down. Measured over the export, counting vertices whose face ends up pointing away from @@ -40,12 +41,15 @@ the tool: | `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) | |---|---|---| | `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` | -| `+1` (now) | **22 / 1032** | **no rotation**; lands on `(0, 0, +1)` | +| `+1` (now) | **0 / 1032** | **no rotation**; lands on `(0, 0, +1)` | -The 22 are the rim of the dome: their normals sit 60–90° off the tool axis, so -aligning them necessarily rolls the body about that normal and the sign is -decided by rounding. They are edge-on, not turned over — the most negative -`uprightness` among them is a hair past zero, not `-1`. +An earlier revision of this document reported 22 vertices not `upright` under `+1`. +That count came from `uprightness` comparing a **platform Y** direction — a +horizontal axis — so a pose that merely rolled the body about the vertical was +reported as turned over. `motion-correction-plan.zh.md` task 1 replaced the +reference with the post-mount nominal up (the tool axis) in the same frame; with +that reference the exported model reports `upright` for **all 1032 vertices**, and +the corrected flag does not change which branch any of them selects. The nominal pose needing **no rotation at all** is the tell: a machine that has to make a half-turn to reach its own commissioned configuration has the polarity @@ -127,9 +131,9 @@ The closed form is **~200,000× more accurate** and **~7× faster**. Verified at That residual is the floating-point floor: `acos` near 1 loses about `sqrt(eps)` in radians, so "exact" cannot be reported as more precise than this. -All 1032 vertices are **in range**, and 1010 report `upright`. The 22 that do not -are the rim of the dome, where the normal is 60–90° off the tool axis — see -*the face is aimed at +Z* above and *still open* below. +All 1032 vertices are **in range**, and all 1032 report `upright` under the +tool-axis reference task 1 introduced. Reachability is the yaw limit, not the rim: +see *the face is aimed at +Z* above and *the two exact poses of one normal* below. ### The two pitch bindings are one family @@ -154,43 +158,78 @@ everything the machine needs to know: | Field | Meaning | |---|---| | `residual_deg` | angle between the rotated normal and the tool direction | -| `upright` | whether the pose leaves the sensing face pointing along the tool | +| `upright` | whether the mounted body's up axis still points along the tool | | `in_range` | whether the pose is inside the stage travel | | `reason` | why not, when it is not | +| `mounted_point_mm` | the picked point after mounting, before the stages turn | +| `contact_z_mm` | the contact height **after** the stages turn | +| `contact_point_mm` | where the move ends: `(tool_x, tool_y, contact_z_mm)` | +| `branch_reason` | which key chose this branch among the exact ones | -A pose that aligns perfectly and turns the sensor over is a different thing from -one that aligns perfectly and does not, and the residual alone cannot tell them -apart. Likewise a pose needing more travel than the stage has: `in_range` says so, -and the solver reports the **best pose the limits do allow** with its real -residual, rather than an angle the machine cannot reach or a residual it cannot -achieve. +`upright` used to be measured against the platform's Y axis, which reports a body +rolled about the vertical as turned over; it now compares the tool axis with the +same axis after the stages turn. A pose that aligns perfectly and turns the sensor +over is a different thing from one that aligns perfectly and does not, and the +residual alone cannot tell them apart — but the up test can only do that if it uses +the axis the machine actually calls up. -## History: the sign-blind residual +Three positions belong to one solve and they are not interchangeable: where the +point sits on the mounted sensor, where the stages leave it, and where the tool has +to be. `mounted_point_mm` answers the first, `contact_z_mm`/`contact_point_mm` the +second and third. The contact height is the **rotated** one: mounting a plate or +offsetting the mount shifts the presented point, so the height after the stages +turn is what the press axis has to reach. -The previous residual was `acos(turned · tool_direction)`, which is **sign-blind**. -A rotation putting the normal *on* the tool axis and one putting it *opposite* -both give `|cos| = 1`, so both scored `0.0000°`. That is how an upside-down pose -read as perfect, and it is why this document previously distrusted the solver's -"100% within 0.5°". +### From contact height to a press axis command + +A geometry height is not a controller coordinate. `motion/pose_move.py::PressAxis` +holds the measured relation (`zero_mm` is the reading where the tool meets the +table's zero plane, `rising` the drive direction) and turns a contact height into +an axis target only when it is marked `calibrated`. An uncalibrated press axis is +**held** — `press_targets` removes it from the move and reports the role as held — +rather than being commanded with a geometric number. The other four axes move +either way. + +## History: a residual that measured the wrong aim + +The previous residual was `acos(turned · tool_direction)`, where `tool_direction` +carried `approach_sign`. With the sign wrong, the aim itself pointed at `-Z`, so a +rotation putting the normal *on* that aim and one putting it on `+Z` were not +distinguished: the first scored `0.0000°` because it landed exactly where the +flipped aim asked. It is not that `arccos` cannot tell a direction from its +opposite — `acos(-1)` is `180°` — it is that the reference it measured against had +been inverted. An upside-down pose read as perfect, and that is why this document +previously distrusted the solver's "100% within 0.5°". The angle itself is correct and is still what `residual_deg` reports; what was -missing was `upright`, which now travels with every pose so the distinction cannot -be lost. The lesson is recorded rather than deleted: a scalar that cannot express -the failure mode it is used to detect will not detect it. +missing was an aim the machine could trust plus `upright`, which now travels with +every pose so the distinction cannot be lost. The lesson is recorded rather than +deleted: a scalar that cannot express the failure mode it is used to detect will +not detect it. -## Still open +## The two exact poses of one normal -**22 of 1032 vertices report not-upright** (16 by more than the tie band). These -are the rim of the dome, where the surface normal is 60–90° from the tool axis. -Aligning an edge-on face necessarily rolls the body about that normal, and which -side of zero it lands on is settled by rounding — the most negative uprightness is -`-0.997` for a vertex whose normal is `90.4°` from the tool axis, i.e. a face being -presented side-on rather than turned over. +Every solvable normal has two exact poses, and they are a **180° roll about the +vertical**, not an upright/inverted pair: `R_branch2 = R_z(180°) @ R_branch1` +agrees with the measured branches to `2.2e-16`. Both leave the body up (the same +tool-axis component), both reach the same contact height, and both put the contact +back under the tool — the table XY differs, because the roll moves the point +sideways and the table compensates. -They are not a solver defect: the closed form is exact for all of them -(`residual < 1.3e-06°`). Whether they matter is a coverage question — a contact -made on the rim is the least well-conditioned one on the patch anyway — and it is -worth a look at the bench rather than a change in the maths. +So the choice is a machine convention, and the solver states it instead of hiding +it: among the branches with the best residual (within `1e-6°`), it prefers one that +is in range, then upright, then the smaller total travel `|yaw| + |pitch|`, and +`Pose.branch_reason` reports which of those keys decided (`only`, `alignment`, +`in_range`, `upright`, `smallest`). An equivalent solution never wins on a +floating-point residue. + +On the two bench screenshots this picks `yaw +36.42° / pitch −47.69°` on the right +and `yaw −61.32° / pitch +45.64°` on the left; the other exact pair is +`−143.58° / +47.69°` and `+118.68° / −45.64°`. A fixed approach sign on the rim +(which of the two the operator wants there) still needs the platform lead's rule — +it is not something the solver may invent, and it is not derivable from the UV +position. A contact made on the rim is the least well-conditioned one on the patch +anyway, so it is worth a look at the bench rather than a change in the maths. The polarity question that used to live here is **settled**: see *the face is aimed at +Z* above. @@ -212,7 +251,7 @@ normals, points = bundle.vertex_normals(), bundle.vertices_mm analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)] residuals = np.array([pose.residual_deg for pose in analytical]) print(residuals.max()) # ~1.2e-06 -print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1010 / 1032 +print(sum(pose.upright for pose in analytical), "/", len(analytical)) # 1032 / 1032 print(all(pose.in_range for pose in analytical)) # True ``` diff --git a/apps/autocal/docs/motion-correction-plan.zh.md b/apps/autocal/docs/motion-correction-plan.zh.md index 5a1b58c..d5ff908 100644 --- a/apps/autocal/docs/motion-correction-plan.zh.md +++ b/apps/autocal/docs/motion-correction-plan.zh.md @@ -1,6 +1,6 @@ # Autocal 运动与多点标定修正计划 -状态:待实施,2026-10-14。审查基准:`orisys-app` 的 `dc5a6f8`,范围为 `d06501d..dc5a6f8`。本文件是工程师任务清单和后续复查依据,不表示实机问题已经修复。整体范围沿用[阶段一计划](implementation-plan.zh.md),数据含义以[标定数据格式](calibration-data-format.zh.md)为准。 +状态:任务 1–4 的软件部分已实施并继续复核(2026-10-14),任务 0 与任务 5 的实机部分待平台负责人参加。工程师证据保留在 `orisys-app/.scratch/autocal-motion-fix/verification.md`,接手后的结果见 `orisys-app/.scratch/autocal-motion-takeover/verification.md`。审查基准:`dc5a6f8`;接手 HEAD:`5339624`。本计划不表示实机错向已修复,整体范围沿用[阶段一计划](implementation-plan.zh.md),字段以[标定数据格式](calibration-data-format.zh.md)为准。 ## 已知事实与待确认项 @@ -31,10 +31,10 @@ | 完成 | 任务 | 依赖 | 估计人时 | |---|---|---|---:| | [ ] | 0:固定坐标和实机约定 | 无 | 2–3 | -| [ ] | 1:修正姿态判断和选解 | 0 | 4–6 | -| [ ] | 2:统一接触高度与执行目标 | 0、1 | 4–6 | -| [ ] | 3:消除旧姿态执行 | 1、2 | 3–4 | -| [ ] | 4:接通多点记录复核 | 多点字段确认 | 6–8 | +| [x] | 1:修正姿态判断和选解 | 0 | 4–6 | +| [x] | 2:统一接触高度与执行目标 | 0、1 | 4–6 | +| [x] | 3:消除旧姿态执行 | 1、2 | 3–4 | +| [x] | 4:接通多点记录复核 | 多点字段确认 | 6–8 | | [ ] | 5:端到端验收和交接 | 1–4 | 3–4 | 软件和离线验证预计 22–31 人时,约单人 3–4 个工作日;额外预留 2–4 小时设备联调,不含硬件故障等待。估算需根据任务 0 的测量结果调整。 @@ -49,9 +49,14 @@ 修改 `motion/kinematics.py`,补充 `test_kinematics.py` 与 `test_ik_analytical.py`。 -- [ ] 朝上参考来自真正的安装后名义朝上方向,在同一坐标系中比较;默认映射下为平台 +Z,而不是平台 +Y。 -- [ ] 候选先满足方向、残差容差、行程和实机约定,再决定分支。等价解不因浮点残差的微小差异改变选择。若两解均允许,采用稳定且有说明的选择规则;需要固定接近姿态时,由平台负责人确认规则,不按 UV 左右硬编码符号。 -- [ ] 测试名义法向、左右截图、中心/边缘和行程边界。用独立旋转矩阵检查 `R @ n` 指向 +Z,角误差不大于 `1e-5°`,并验证平台朝上向量。截图角度允许因舍入与界面值相差 0.1°。 +- [x] 朝上参考来自真正的安装后名义朝上方向,在同一坐标系中比较;默认映射下为平台 +Z,而不是平台 +Y。 +- [x] 候选先满足方向、残差容差、行程和实机约定,再决定分支。等价解不因浮点残差的微小差异改变选择。若两解均允许,采用稳定且有说明的选择规则;需要固定接近姿态时,由平台负责人确认规则,不按 UV 左右硬编码符号。 +- [x] 测试名义法向、左右截图、中心/边缘和行程边界。用独立旋转矩阵检查 `R @ n` 指向 +Z,角误差不大于 `1e-5°`,并验证平台朝上向量。截图角度允许因舍入与界面值相差 0.1°。 + +实施:`uprightness` 改用安装后名义朝上(工具轴)在同一坐标系比较;`select_branch` 依次取 +`in_range`→`upright`→最小 `|yaw|+|pitch|`,`Pose.branch_reason` 报出依据,等价解不因残差改选; +rim 是否需要固定接近姿态仍属任务 0。证据:`test_kinematics.py` 的独立旋转与选解小节、 +`.scratch/autocal-motion-fix/probe_motion_chain.txt`。 在界面显示所选分支及理由,无法满足约定时拒绝执行。不要仅让原测试保持通过;新回归须在审查基准上失败。 @@ -59,9 +64,15 @@ 修改 `PlatformStack.solve` 和两个 UI 执行入口,补充 `test_kinematics.py`、`test_pose_move.py`。 -- [ ] 分开表示安装后、旋转前的点与最终接触点。XY 平移后,最终点应落在工具 XY;接触高度取旋转后坐标。默认偏移 `(0,0,8)` 下,两截图的旋转后 Z 约为 19.051 和 21.578 mm,不是 13.444 和 13.186 mm。 -- [ ] 明确接触高度到压头轴绝对目标的关系,包括机械零位和工具高度。只有该关系确认后才生成运动目标;不要把几何 Z 直接视为已标定的控制器零位。保留接触高度、无接触预定位和加载深度的不同含义。 -- [ ] 预览、目标表、确认框和执行计划使用同一求解结果。以非零安装偏移、安装角和工具 XY 测试,独立计算坐标误差应小于 `1e-6 mm`;实机误差阈值另由设备负责人确定。 +- [x] 分开表示安装后、旋转前的点与最终接触点。XY 平移后,最终点应落在工具 XY;接触高度取旋转后坐标。默认偏移 `(0,0,8)` 下,两截图的旋转后 Z 约为 19.051 和 21.578 mm,不是 13.444 和 13.186 mm。 +- [x] 明确接触高度到压头轴绝对目标的关系,包括机械零位和工具高度。只有该关系确认后才生成运动目标;不要把几何 Z 直接视为已标定的控制器零位。保留接触高度、无接触预定位和加载深度的不同含义。 +- [x] 预览、目标表、确认框和执行计划使用同一求解结果。以非零安装偏移、安装角和工具 XY 测试,独立计算坐标误差应小于 `1e-6 mm`;实机误差阈值另由设备负责人确定。 + +实施:`Pose` 增加 `mounted_point_mm`/`contact_z_mm`/`contact_point_mm`/`table_xy_mm`; +`motion/pose_move.py::PressAxis`(`zero_mm`、`rising`、`calibrated`)把几何高度换算为压轴目标, +未标定时 `press_targets` 把该轴从动作中移除并报为保持;设备设置新增压轴页。证据: +`test_pose_move.py`、`test_device_setup.py`、`probe_motion_chain.txt`(两截图点 19.0510/21.5781)。 +配置中的校准标志必须为布尔 `true`,零位必须有限,方向必须为布尔值;无效配置保持 Z。 更新[逆解说明](inverse-kinematics.md)中的旋转、高度和行程描述;修正其中把 `arccos` 称为正负方向不可区分的说法。不要把单纯改变多轴下发顺序当作修正机械旋转链。 @@ -69,9 +80,14 @@ 修改 `ui/multipoint_panel.py` 和必要的 `SiteSpec` 状态,新增对应 UI 回归测试。 -- [ ] 每次对准前按当前几何和安装设置重新求解,而不是只看缓存的 `in_range`。优先直接复用现有快速求解器,暂不建立第二套缓存失效机制。 -- [ ] 几何版本变化时不能拿旧 UV 映射直接执行;旧 XYZ、法向和当前映射不一致时要求重新确认测点。新的目标须重新经过现有 `MotionService` 行程与连接检查。 -- [ ] 修改安装 X/Y/Z、安装角和工具位置后,确认框及实际提交的目标都更新;无解、越界或非有限值时不发送命令。记录当前求解参数及目标,保持期间不能对准其他点。 +- [x] 每次对准前按当前几何和安装设置重新求解,而不是只看缓存的 `in_range`。优先直接复用现有快速求解器,暂不建立第二套缓存失效机制。 +- [x] 几何版本变化时不能拿旧 UV 映射直接执行;旧 XYZ、法向和当前映射不一致时要求重新确认测点。新的目标须重新经过现有 `MotionService` 行程与连接检查。 +- [x] 修改安装 X/Y/Z、安装角和工具位置后,确认框及实际提交的目标都更新;无解、越界或非有限值时不发送命令。记录当前求解参数及目标,保持期间不能对准其他点。 + +实施:`ui/multipoint_panel.py::_align` 每次都重新求解并重新写入缓存,测点或法向偏移超过 +`0.05 mm`/`1e-3` 时报 `multipoint.point_moved`/`normal_moved` 要求重取,无可用来源报 +`point_unmapped`。加载或保持期间拒绝对准;repeat 关闭后的换点阶段只允许对准待采测点。 +日志位置来自轮询缓存,不为日志在调用线程额外读取控制器。证据:`test_multipoint_align.py`、`test_poller.py`。 数据记录留在 Autocal,纯几何计算留在 kinematics,轴单位和驱动映射留在 motion service。UI 不自行复制矩阵、反转轴符号或导入 Studio 私有模块。 @@ -79,9 +95,19 @@ 修改 `session_review.py` 的读取入口及需要的复核 UI;复用既有保持窗口统计、诊断和拟合能力,不另建一套分析器。 -- [ ] 将 `autocal-multipoint-1.0` 的实际目录、trial、参考窗口和录像索引转换为既有分析输入。先确认时间基准、力单位、模拟标记及帧来源;缺失字段显式报告,不补成有效零值。 -- [ ] 全新进程打开两个测点、每点两个 trial 的会话,显示采集覆盖、有效窗口、加载/卸载曲线和初步法向灵敏度。保持原有 finger 会话读取行为。 -- [ ] 未完成 trial、模拟数据、缺失视频或无效参考不得产生正式标定结论。分析结果写入 derived,原始文件哈希不变。增加完整、缺失、中止及原格式的回归样本。 +- [x] 将 `autocal-multipoint-1.0` 的实际目录、trial、参考窗口和录像索引转换为既有分析输入。先确认时间基准、力单位、模拟标记及帧来源;缺失字段显式报告,不补成有效零值。 +- [x] 全新进程打开两个测点、每点两个 trial 的会话,显示采集覆盖、有效窗口、加载/卸载曲线和初步法向灵敏度。保持原有 finger 会话读取行为。 +- [x] 未完成 trial、模拟数据、缺失视频或无效参考不得产生正式标定结论。分析结果写入 derived,原始文件哈希不变。增加完整、缺失、中止及原格式的回归样本。 + +实施:`review_session()` 按 `format_version` 分流到 `review_multipoint()`,复用窗口统计、拟合与 +留一 trial 验证;缺 `capture/`、时间基准非 `host_camera`、repeat 未完成、窗口非法对应 +`no_frame_source`/`time_basis_not_comparable`/`incomplete_trial`/`invalid reference window`, +`formal_conclusion` 恒为 false,结果只写 `derived/review_<时间>_<标识>/review.json`。格式与复核 +输入契约见[多点标定会话格式](multipoint-data-format.zh.md)。证据:`test_session_review_multipoint.py`、 +`.scratch/autocal-motion-fix/multipoint-review.png`、`multipoint-review-response.png`、 +`multipoint-overview.png`。 +复核读取实际的 `{u,v}`,时间曲线从 `capture/` 取信号;缺少对应视频、模拟或未完成 repeat +不进入拟合。已保存诊断原始信号仍可查看,原始记录不修改。 当前拟合仍为诊断结果。本任务不自动写回 SDK 标定系数,也不引入新采集格式;确有字段歧义时先更新数据契约并说明旧记录的解释限制。 @@ -105,7 +131,7 @@ ```powershell cd D:\orisys_ws\orisys-app -& .venv-build/Scripts/python.exe -m pytest apps/autocal/tests/test_kinematics.py apps/autocal/tests/test_ik_analytical.py apps/autocal/tests/test_pose_move.py apps/autocal/tests/test_multipoint.py apps/autocal/tests/test_session_review.py apps/autocal/tests/test_record_overview.py -p no:cacheprovider --basetemp=.scratch/autocal-motion-fix/pytest-01 +& .venv-build/Scripts/python.exe -m pytest apps/autocal/tests/test_kinematics.py apps/autocal/tests/test_ik_analytical.py apps/autocal/tests/test_pose_move.py apps/autocal/tests/test_multipoint.py apps/autocal/tests/test_multipoint_align.py apps/autocal/tests/test_session_review.py apps/autocal/tests/test_session_review_multipoint.py apps/autocal/tests/test_record_overview.py apps/autocal/tests/test_device_setup.py apps/autocal/tests/test_finger_panel.py -p no:cacheprovider --basetemp=.scratch/autocal-motion-fix/pytest-01 & .venv-build/Scripts/python.exe tools/lint_gate.py ``` diff --git a/apps/autocal/docs/multipoint-data-format.zh.md b/apps/autocal/docs/multipoint-data-format.zh.md new file mode 100644 index 0000000..49cdedd --- /dev/null +++ b/apps/autocal/docs/multipoint-data-format.zh.md @@ -0,0 +1,61 @@ +# Autocal 多点标定会话格式(`autocal-multipoint-1.0`) + +状态:2026-10-14 记录格式与只读复核已接通,仅软件验证,待实机验收。坐标与载荷约定、finger +会话格式见[标定操作与数据格式](calibration-data-format.zh.md);姿态与接触高度见 +[逆运动学说明](inverse-kinematics.md);运动边界与停机见[操作流程](workflow.md)。本页不会自动移动平台。 + +## 目录布局 + +多点标定按测点组织,不按 trial 平铺:每个测点重复若干次,每次一个 repeat 目录。 + +```text +sites//repeat-00N/ + session.json + reference.csv + events.csv + capture/ (接了相机时才有) + session.json + frames.csv + forces.csv + raw_0001.mp4 +``` + +| 文件 | 内容 | +|---|---| +| 根 `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 | +| 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` | +| `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` | +| `events.csv` | 电脑单调时间、UTC、事件类型与明细 | +| `capture/` | 共享记录器的录像会话(`session.json`、`frames.csv`、`forces.csv`、`raw_*.mp4`);模拟运行或未接相机时不产生,原因记在该 repeat 的 `images.reason` | + +## 取值约定 + +| 项目 | 约定 | +|---|---| +| branch | 零载窗口写 `baseline`,其余为 `load`/`unload`,位次由 `load_steps()` 决定;没有 `stability_verified` 列,该标志只由复核时选中的子区间产生 | +| 参考力 | 称重模式是操作员输入的法向力;六轴模式是 `F_ref_normal_N = (R @ F) · tool_axis`,`R` 为本次会话输入力到工具的旋转。两者都是人工声明的参考,不是独立测量,也不能替代 FT | +| 时间关联 | 帧属于某个保持窗口,当且仅当它的 `t_host_mono_ns` 落在 `[t_start_host_mono_ns, t_end_host_mono_ns)`;不按行号配对 | +| 姿态 | `pose` 是求解结果快照(含 `in_range`、`upright`、`residual_deg`、`branch_reason`);执行前按当前安装设置重新求解,记录值只作对照 | +| 目录名 | 会话目录带型号、SN、时间与唯一标识;`repeat-00N` 编号同点重复与中止重试 | + +## 只读复核输入 + +`session_review.review_session()` 按根清单的 `format_version` 分流,多点会话走 +`review_multipoint()`:读取每个 repeat 的声明窗口,并把 `capture/forces.csv` 的 `FN_raw` 按 +`frame_id` 与 `t_host_mono_ns` 关联成实测样本后,复用与 finger 会话相同的窗口统计、线性拟合与 +留一 trial 验证。 + +| 情况 | 复核行为 | +|---|---| +| 缺 `capture/`(模拟运行、未接相机) | 记 `no_frame_source`,窗口标同名状态,样本数为 0、均值为空,不生成拟合 | +| 对应视频分段缺失、为空,或没有保存到录像的帧 | 记 `no_frame_source` 并说明原因;不生成拟合 | +| 模拟会话即使存在帧文件 | 标 `simulation`,仅供诊断,不参与拟合 | +| 帧时间基准不是 `host_camera` | 记 `time_basis_not_comparable`,该 repeat 的窗口不参与计算 | +| repeat 未完成 | 记 `incomplete_trial`,只展示采集与缺失文件,不参与拟合 | +| 窗口起止非法(终点不晚于起点、非有限值、参考力超出会话声明的最大力) | 抛 `invalid reference window`,不静默跳过 | +| 人工选中的子区间 | 必须落在记录窗口内,`stability_verified` 由选中决定;窗口外选择被拒绝 | + +多点复核的 `formal_conclusion` 恒为 `false`:参考力是人工声明,帧来源可能缺失,格式里也没有独立 +验证。结果只写 `derived/review_<时间>_<标识>/review.json`,原始文件哈希不变;未接入 FT/ECAT +前不写回 SDK 系数,也不引入第二种采集格式。 diff --git a/apps/autocal/docs/workflow.md b/apps/autocal/docs/workflow.md index 5c5c503..5a28030 100644 --- a/apps/autocal/docs/workflow.md +++ b/apps/autocal/docs/workflow.md @@ -237,15 +237,15 @@ measured time per point. guard violation. **The single-point dry run is built**, in the finger debug screen: 执行动作 sends the -selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and -does **not** command the press axis, so it cannot reach the sensor. The order is the -model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator -watched in the preview. Before it moves it checks the mapping, the target against the -P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration — -all in `motion/service.py`, so a later caller cannot walk around them — and the drives -are commanded one at a time, each waiting for the previous to arrive, with a stall or -an alarm aborting the rest. 停止 next to it is the same command as the strip's -全部停止; 回初始位 in the rail sends every axis to zero with the press axis first. +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 +model's own composition (`R_pitch · R_yaw`), so every intermediate pose is one the +operator watched in the preview. Before it moves it checks the mapping, the target +against the P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary +calibration — all in `motion/service.py`, so a later caller cannot walk around them. +停止 next to it is the same command as the strip's 全部停止; 回初始位 in the rail sends +every axis to zero with the press axis first. --- diff --git a/apps/autocal/src/orisys_autocal/motion/__init__.py b/apps/autocal/src/orisys_autocal/motion/__init__.py index c7c889e..c44c534 100644 --- a/apps/autocal/src/orisys_autocal/motion/__init__.py +++ b/apps/autocal/src/orisys_autocal/motion/__init__.py @@ -20,8 +20,10 @@ from .pose_move import ( MovePlan, MoveStep, PoseMover, + PressAxis, plan_pose_move, plan_return_to_initial, + press_targets, ) from .service import ( AXIS_ROLES, @@ -76,6 +78,7 @@ __all__ = [ "MoveStep", "NativeTransport", "PoseMover", + "PressAxis", "Snapshot", "ZmcTransport", "clamp_bas_task", @@ -84,6 +87,7 @@ __all__ = [ "normalize_axis_roles", "plan_pose_move", "plan_return_to_initial", + "press_targets", "safe_float", "units_per_degree", "user_units_to_deg", diff --git a/apps/autocal/src/orisys_autocal/motion/kinematics.py b/apps/autocal/src/orisys_autocal/motion/kinematics.py index 1f9fd33..9f0df60 100644 --- a/apps/autocal/src/orisys_autocal/motion/kinematics.py +++ b/apps/autocal/src/orisys_autocal/motion/kinematics.py @@ -72,6 +72,14 @@ _UPRIGHT_TIE = 1e-6 #: has to absorb the sweep's own quantisation, not excuse a real misalignment. _UPRIGHT_TOL_DEG = 0.05 +#: Two exact poses whose residuals differ by less than this are the *same* alignment +#: to any tolerance the machine can hold, so the choice between them must not turn on +#: which one the arithmetic put a hair lower. They are compared as equals and the +#: machine decides (travel, reachability, which way up). The closed form puts both +#: within floating point — or reports the same residual twice — so this band is what +#: keeps the reported pose from moving when nothing moved. +_BRANCH_TIE_DEG = 1e-6 + def rotation_about(axis: str, degrees: float) -> np.ndarray: @@ -100,7 +108,11 @@ class Branch: Both branches put the normal on the tool axis to within floating point, so what separates them is the machine: whether the pose is inside the stage - travel, and which way up it leaves the sensing face. + travel, which way up it leaves the sensing face, and how far the stages have to + turn to get there. The two branches are not two similar poses: they are a + half-turn apart about the vertical (``R_z(180°)`` of each other), so they reach + the same contact height with different body orientations. A half-turn of yaw + does not by itself mean that the sensing face points down. """ yaw_deg: float @@ -109,10 +121,21 @@ class Branch: upright: bool in_range: bool + @property + def travel_deg(self) -> float: + """How far the two stages turn to reach this pose, degrees. + + The sum of the absolute angles, which is what the machine pays: a half-turn + of yaw instead of a small one is the difference between the two branches, + and it is the number the operator needs to hear when it is chosen. + """ + return abs(self.yaw_deg) + abs(self.pitch_deg) + def describe(self) -> str: return ( f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} " f"residual {self.residual_deg:.6f} " + f"travel {self.travel_deg:.3f} " f"{'upright' if self.upright else 'inverted'}" f"{'' if self.in_range else ' out-of-range'}" ) @@ -123,13 +146,31 @@ class Pose: """One solved platform pose, or the reason there is not one.""" ok: bool - #: Platform-frame coordinates of the contact point, mm — where the point *is*, - #: with the sensor's mounting pose applied. + #: Platform-frame XY of the contact point **as mounted**, mm: the sensor's + #: mounting pose and the plate applied, the stages still at zero. It is where + #: the point sits on the machine, not where the machine has to go — the move is + #: :attr:`table_xy_mm`. The stages turn the point away from here, so this is + #: *not* its position while the pose is executed; that is + #: :attr:`contact_point_mm`. target_xy_mm: tuple[float, float] = (0.0, 0.0) - #: Platform-frame Z of the contact point, mm. Carries the mounting plate and - #: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs - #: are computed and then discarded, and nothing downstream can tell. + #: Platform-frame coordinates of the contact point as mounted, mm: the same + #: point as ``target_xy_mm`` with its Z, so the mounting geometry reads as one + #: triple and can be checked against the setup. + mounted_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0) + #: Platform-frame Z of the contact point **once the stages have turned**, mm: + #: the height the contact actually reaches at the solved pose, which is the + #: number any press-axis target has to be related to. Carries the mounting plate + #: and any Z tool offset. The unrotated height is ``mounted_point_mm``'s Z and + #: it is a different number: the stages turn the point about the table origin, + #: so a 47° pose lifts it by several millimetres. Reporting the unrotated Z here + #: is what made every commanded height wrong by that lift. contact_z_mm: float = 0.0 + #: The contact point at the **end of the move**, mm: after the stages turn and + #: the table slides. Its XY is the tool position — bringing the point there is + #: what the move is for — and its Z is ``contact_z_mm``. Reported so the three + #: points in the chain (as mounted, after the stages, at the tool) cannot be + #: confused for one another again. + contact_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0) #: The move the XY table has to make, mm: the tool's position minus where the #: contact ends up after the stages turn. This is the number a run commands, #: and it is what the preview animates — ``target_xy_mm`` says where the point @@ -143,14 +184,25 @@ class Pose: residual_deg: float = 0.0 #: Whether the yaw could be determined at all (indeterminate at the pole). yaw_determined: bool = True - #: Whether the pose leaves the sensing face pointing along the tool axis. A - #: pose can align the normal perfectly and still be turned over; the residual - #: alone cannot say which, so it is reported rather than inferred. + #: Whether the pose leaves the *body* up — the axis the mount sends to the + #: platform's vertical — still pointing up. A pose can align the normal + #: perfectly and still be turned over: the two branches of a measured point are + #: exactly that, half a turn apart about the vertical. The residual cannot tell + #: them apart, so this is measured and reported rather than inferred. upright: bool = True #: False when the chosen pose is outside the stage travel — reported, never #: silently clamped. in_range: bool = True reason: str = "" + #: Which selection key decided this pose, as a key the panel names in words: + #: ``only``, ``alignment``, ``in_range``, ``upright`` or ``smallest``. The rule + #: is a machine convention, so it is shown with the pose instead of assumed. + branch_reason: str = "" + #: How far the stages turn for this pose, degrees. + branch_travel_deg: float = 0.0 + #: Total travel of the pose that was *not* chosen, degrees, when the closed form + #: offered one. Zero when this pose was the only candidate. + other_travel_deg: float = 0.0 @property def reached_tolerance(self) -> bool: @@ -191,11 +243,15 @@ class PlatformStack: #: ``+tool_axis``, and the tool feeds down onto it. #: #: This was ``-1``, which asked the solver to bring every normal onto **-Z**. - #: The residual could not see the difference (``arccos`` is sign-blind, so a - #: face turned fully over still scored 0.0000 deg), so the mistake showed up - #: only as the model flipping upside down: **981 of 1032 vertices** ended with - #: the face pointing down. With ``+1`` that is **51**, and those are edge-on - #: rather than inverted (worst cosine -0.013). + #: Nothing reported that as a fault, because the residual was measured against + #: the same inverted aim: a pose that turned the sensor over aligned with ``-Z`` + #: *perfectly*, scored 0.0000 deg, and passed every test that asked only for a + #: small residual. The mistake showed up as the model flipping upside down: + #: **981 of 1032 vertices** ended with the face pointing down. With ``+1`` the + #: alignment is measured against the direction the face really aims, and the + #: count is 0 — every normal on the export is aligned to within 1.3e-06 deg, + #: with the body's own orientation reported separately by + #: :meth:`PlatformStack.uprightness`. approach_sign: int = 1 #: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight #: above the table origin, which is where a dialled-in machine has it. Every @@ -386,6 +442,12 @@ class PlatformStack: residual = chosen.residual_deg upright = chosen.upright in_range = True + branch_reason = self.branch_reason(usable, chosen) + branch_travel = chosen.travel_deg + other_travel = max( + (branch.travel_deg for branch in usable if branch is not chosen), + default=0.0, + ) elif branches: # Every exact pose needs more travel than the stage has. Report the # best pose the limits *do* allow, with the residual it really @@ -394,14 +456,23 @@ class PlatformStack: yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool) upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 in_range = False + branch_reason = "in_range" + branch_travel = abs(yaw_deg) + abs(pitch_deg) + other_travel = 0.0 else: yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool) upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 in_range = False + branch_reason = "" + branch_travel = abs(yaw_deg) + abs(pitch_deg) + other_travel = 0.0 elif method == "search": yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool) upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 in_range = True + branch_reason = "" + branch_travel = abs(yaw_deg) + abs(pitch_deg) + other_travel = 0.0 else: # pragma: no cover - guarded by the signature raise ValueError(f"unknown method {method!r}") @@ -413,17 +484,29 @@ class PlatformStack: elif residual > self.tolerance_deg: reason = "no pose meets the tolerance" - # The stages turn the contact about the table origin; the table then slides - # it under the tool. Reported rather than animated here, but computed from - # the same angles the pose reports, so a caller cannot disagree with it. + # Three points, and they are three different places: + # + # * ``point`` — the contact as mounted, the stages at zero. + # * ``landed`` — the contact once the stages turn about the table origin. + # * ``landed + table`` — the contact at the end of the move, under the tool. + # + # The contact *height* is the middle one's Z. Reporting the first one's Z + # (as this did) drops the lift the stage rotation gives the point, which is + # several millimetres at the angles a real point needs, and that height is + # what any press-axis target is derived from. landed = self.platform_rotation(yaw_deg, pitch_deg) @ point tool_x, tool_y = (float(value) for value in self.tool_xy_mm) + table_x = tool_x - self._axis(landed, "x") + table_y = tool_y - self._axis(landed, "y") + contact_z = self._axis(landed, "z") return Pose( ok=True, target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")), - contact_z_mm=self._axis(point, "z"), - table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")), + mounted_point_mm=(self._axis(point, "x"), self._axis(point, "y"), self._axis(point, "z")), + contact_z_mm=contact_z, + contact_point_mm=(tool_x, tool_y, contact_z), + table_xy_mm=(table_x, table_y), yaw_deg=yaw_deg, pitch_deg=pitch_deg, tool_z_mm=float(tool_z_mm), @@ -432,6 +515,9 @@ class PlatformStack: upright=upright, in_range=in_range, reason=reason, + branch_reason=branch_reason, + branch_travel_deg=branch_travel, + other_travel_deg=other_travel, ) # ------------------------------------------------------- closed-form solve @@ -582,34 +668,6 @@ class PlatformStack: sine_term = sign_first * u2 * w1 - u1 * w2 return math.degrees(math.atan2(sine_term, cosine_term)) - @staticmethod - def select_branch(branches: list[Branch]) -> Branch: - """Pick from :meth:`alignment_branches`: reachable, then upright, then best. - - Every branch aligns to within floating point, so alignment itself rarely - separates them. What does is the machine: a pose inside the stage travel - beats one outside it, and — among those — a pose that leaves the sensing - face up beats one that turns the sensor over. - - Alignment is still the third key rather than ignored: if one branch really - is materially better than another, that wins, because the point of the - stages is to align. The tie only decides between poses that are the same - to any tolerance the machine can hold. - """ - def rank(branch: Branch) -> tuple: - # Alignment first: if one branch genuinely aligns better, that is the - # answer, because aligning is what the stages are for. Reachability and - # orientation decide between branches that align the same, which is the - # usual case — the closed form puts both within floating point of exact. - return ( - branch.residual_deg, - not branch.in_range, - not branch.upright, - abs(branch.yaw_deg) + abs(branch.pitch_deg), - ) - - return min(branches, key=rank) - def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]: """Whether any pose can align this normal, and why not if none can. @@ -702,12 +760,13 @@ class PlatformStack: """ turned = self._cell(unit, yaw_deg, pitch_deg) tool = self.tool_direction - cosine = ( - turned[0] * float(tool[0]) - + turned[1] * float(tool[1]) - + turned[2] * float(tool[2]) - ) - return float(math.degrees(math.acos(max(-1.0, min(1.0, cosine))))) + x, y, z = turned + tx, ty, tz = (float(value) for value in tool) + cosine = x * tx + y * ty + z * tz + # acos loses angular precision near a dot product of 1, which can make + # equivalent exact branches appear different. atan2 stays well conditioned. + sine = math.hypot(y * tz - z * ty, z * tx - x * tz, x * ty - y * tx) + return float(math.degrees(math.atan2(sine, cosine))) def _turn(self, axis: str, angle_deg: np.ndarray, vector: np.ndarray) -> np.ndarray: """Rotate ``vector`` about a platform axis by each of ``angle_deg``. @@ -972,18 +1031,30 @@ class PlatformStack: def uprightness(self, yaw_deg: float, pitch_deg: float) -> float: """How much the sensing face still points up, in -1..1. - Measured, not derived: the sensor's own up axis (mesh ``+Y``, which - ``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage - rotation and compared with that same axis *unrotated*. ``+1`` is the - nominal pose — no rotation, face straight up — ``-1`` is fully turned over. + Measured, not derived: the body's own up axis — the mesh axis the mount + sends to the platform frame's vertical, i.e. mesh ``+Y`` under the default + ``AXIS_MAP`` — is put through the composed stage rotation and dotted with + that same axis *unrotated*. ``+1`` is the nominal pose (no rotation, face + straight up), ``-1`` is fully turned over. Both vectors are expressed in the same frame, so this is the angle between the face and where it started, and it does not depend on which way the tool points. An earlier version dotted the turned axis against the tool direction, which quietly moved with ``approach_sign`` and so reported the polarity as an orientation. + + The reference axis is the whole point of the measurement, so it is taken + from the mount rather than assumed: ``tool_axis_direction`` is the platform + axis the face aims along (``+Z`` here), and ``to_sensor`` maps it back + through the same ``AXIS_MAP`` the geometry uses. An earlier version again + passed ``eye(3)[1]`` — the platform frame's *Y*, a horizontal axis — so the + number measured how far the body leaned sideways and went on reporting + rolled-over poses (a half-turn about the vertical) as upright. That is the + second half of the false branch decision: with the axis corrected, the two + branches of a measured point are both upright, and the choice falls to the + machine instead of to a mis-measured cosine. """ - reference = to_sensor(np.eye(3)[1], self.axis_map) + reference = to_sensor(self.tool_axis_direction, self.axis_map) reference = reference / max(float(np.linalg.norm(reference)), 1e-12) turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference turned = turned / max(float(np.linalg.norm(turned)), 1e-12) @@ -996,10 +1067,71 @@ class PlatformStack: return True if lean < -_UPRIGHT_TIE: return False - # Indeterminate: the two poses are the same alignment to any tolerance the - # machine has, so take the one needing the smaller move. + # Edge-on: the body's up is perpendicular to the vertical to within the + # noise floor, so neither answer is wrong and the sign is rounding. Prefer + # the pose that asks for the smaller pitch. Arbitrary, but stable, and it + # only ever decides poses no machine tolerance can tell apart. return abs(pitch_deg) <= 90.0 + @staticmethod + def select_branch(branches: list[Branch]) -> Branch: + """Pick from :meth:`alignment_branches`: reachable, upright, then smallest. + + Every branch aligns to within floating point, so alignment itself rarely + separates them. What does is the machine: a pose inside the stage travel + beats one outside it, and — among those — a pose that leaves the body up + beats one that rolls it over, and then the smaller turn wins. + + **Equivalent poses are compared as equals.** Only branches within + :data:`_BRANCH_TIE_DEG` of the best residual compete on the machine keys: + two exact poses of a measured point differ by floating point at most, and + letting that difference choose between them would move the reported pose — + and the machine — for a reason nobody could see or defend. A branch that is + *materially* better aligned still wins, because aligning is what the stages + are for. + + Which key decided the answer is reported separately by + :meth:`branch_reason`, so the panel can say why rather than only what. + """ + if not branches: + raise ValueError("no branches to choose from") + + best = min(branch.residual_deg for branch in branches) + equivalent = [ + branch + for branch in branches + if branch.residual_deg <= best + _BRANCH_TIE_DEG + ] + return min( + equivalent, + key=lambda branch: ( + not branch.in_range, + not branch.upright, + branch.travel_deg, + ), + ) + + @staticmethod + def branch_reason(branches: list[Branch], chosen: Branch) -> str: + """Which selection key decided ``chosen``, as a key for the panel to name. + + Returns ``only`` when there was nothing to choose between, otherwise the + first key that rules the other candidates out: ``alignment`` (a materially + better residual), ``in_range``, ``upright``, or ``smallest`` when the poses + are equivalent and the shorter turn won. + """ + others = [branch for branch in branches if branch is not chosen] + if not others: + return "only" + best = min(branch.residual_deg for branch in branches) + if all(branch.residual_deg > best + _BRANCH_TIE_DEG for branch in others): + return "alignment" + if all(not branch.in_range for branch in others): + return "in_range" + if all(not branch.upright for branch in others): + return "upright" + return "smallest" + def _prefer_smallest( self, unit: np.ndarray, diff --git a/apps/autocal/src/orisys_autocal/motion/pose_move.py b/apps/autocal/src/orisys_autocal/motion/pose_move.py index ab0bb1e..2043bec 100644 --- a/apps/autocal/src/orisys_autocal/motion/pose_move.py +++ b/apps/autocal/src/orisys_autocal/motion/pose_move.py @@ -26,6 +26,7 @@ translation is what the rotations are resolved against. from __future__ import annotations +import math import time from collections.abc import Mapping, Sequence from dataclasses import dataclass, field @@ -55,6 +56,77 @@ MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z") ALIGN_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z") +@dataclass(frozen=True) +class PressAxis: + """How the press axis's coordinate relates to the geometric contact height. + + A solved pose reports ``contact_z_mm``: how high the contact sits above the + table's origin plane, in geometry. The press axis is commanded in its own + coordinate, counted from its own mechanical zero. They are two different + numbers, and only a bench measurement relates them: + + * ``zero_mm`` — the press axis reading at which the tool tip meets the table's + origin plane. The tip's own height is inside this number, which is why it is + not a separate field. + * ``rising`` — the drive's sign: true when a larger coordinate lifts the tip. + * ``calibrated`` — whether somebody has measured the two on *this* machine. + Until then :meth:`target_for` returns ``None`` and the caller holds the press + axis rather than driving it to a geometric height nobody has anchored. + """ + + zero_mm: float = 0.0 + rising: bool = True + calibrated: bool = False + + @classmethod + def from_settings(cls, settings: object) -> PressAxis: + """Malformed or incomplete calibration settings keep the press axis held.""" + if not isinstance(settings, Mapping): + return cls() + if isinstance(settings.get("zero_mm"), bool): + return cls() + try: + zero = float(settings["zero_mm"]) + rising = settings["rising"] + except (KeyError, TypeError, ValueError, OverflowError): + return cls() + if not math.isfinite(zero) or not isinstance(rising, bool): + return cls() + return cls(zero_mm=zero, rising=rising, calibrated=settings.get("calibrated") is True) + + def target_for(self, contact_z_mm: float) -> float | None: + """The axis coordinate that puts the tool tip at ``contact_z_mm``. + + ``None`` means the relation is not calibrated on this machine, and the + caller must hold the press axis: passing the geometric number through as an + absolute target is what this class exists to stop. + """ + if not self.calibrated: + return None + height = float(contact_z_mm) + return self.zero_mm + (height if self.rising else -height) + + +def press_targets( + targets: Mapping[str, float], press: PressAxis +) -> tuple[dict[str, float], tuple[str, ...]]: + """Resolve the press role in ``targets``, or hand it back as a held role. + + Returns the roles to command and the roles to hold. Everything but ``z`` passes + through untouched: the stage angles and the table move are already in the units + their axes report, while the contact height is geometry. + """ + resolved = dict(targets) + if "z" not in resolved: + return resolved, () + coordinate = press.target_for(resolved["z"]) + if coordinate is None: + resolved.pop("z") + return resolved, ("z",) + resolved["z"] = float(coordinate) + return resolved, () + + @dataclass(frozen=True) class MoveStep: """One axis, one target, in that role's own unit.""" diff --git a/apps/autocal/src/orisys_autocal/preferences.py b/apps/autocal/src/orisys_autocal/preferences.py index f9dd66d..ac28efe 100644 --- a/apps/autocal/src/orisys_autocal/preferences.py +++ b/apps/autocal/src/orisys_autocal/preferences.py @@ -106,6 +106,29 @@ def _default_safety_limits() -> dict[str, float]: } +def _default_press_axis() -> dict[str, Any]: + """How the press axis's coordinate relates to the geometric contact height. + + The solved contact sits at ``Pose.contact_z_mm`` above the table's origin + plane, in millimetres of *geometry*. The press axis reports its own coordinate, + in its own units, counted from its own mechanical zero — the two are different + numbers, and only a measurement on the bench relates them. + + ``zero_mm`` is the press axis reading at which the tool tip meets the table's + origin plane (so the tool tip's own height is already inside it), and + ``rising`` is the drive's sign: true when a larger coordinate lifts the tip. + ``calibrated`` stays false until an operator has measured both against the + machine, and until then the pose-move path holds the press axis instead of + driving it to a geometric height nobody has anchored — the previous behaviour, + which passed the geometric number straight through as an absolute target. + """ + return { + "zero_mm": 0.0, + "rising": True, + "calibrated": False, + } + + def _default_sensor_setup() -> dict[str, Any]: """Workflow P3/P4: what is under test, what touches it, and the transform. @@ -204,6 +227,10 @@ def default_preferences() -> dict[str, Any]: "sensor_setup": _default_sensor_setup(), #: P5: grid generation and run parameters. "sequence_profile": _default_sequence_profile(), + #: 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. + "press_axis": _default_press_axis(), # --- finger debug screen --------------------------------------------- #: The export bundle the debug screen loads. Empty means "ask", and the #: default below points at the revision the bench confirmed. @@ -242,7 +269,13 @@ def default_preferences() -> dict[str, Any]: #: a cosmetic problem: the move is refused with "the limit is not set" while the file #: in front of the operator says nothing about it. Filling the block key by key keeps #: what was chosen and supplies only what was never there. -_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile") +_MERGED_BLOCKS = ( + "connection", + "safety_limits", + "sensor_setup", + "sequence_profile", + "press_axis", +) def load_preferences() -> dict[str, Any]: diff --git a/apps/autocal/src/orisys_autocal/record_overview.py b/apps/autocal/src/orisys_autocal/record_overview.py index 8b62862..3d6ae51 100644 --- a/apps/autocal/src/orisys_autocal/record_overview.py +++ b/apps/autocal/src/orisys_autocal/record_overview.py @@ -48,7 +48,7 @@ def read_plan(path: Path, definition: dict) -> dict: def overview_rows(review: dict, plan: dict | None = None) -> list[dict]: planned = plan_points(plan, review["uv_definition"]) if plan is not None else [] planned_uv = {tuple(p["uv_xy_px"]) for p in planned} - actual = {tuple(site["uv_xy_px"]): site for site in review["sites"]} + actual = {tuple(site["uv_xy_px"]): site for site in review["sites"] if site.get("uv_xy_px")} rows = [] for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]: site = actual.get(tuple(point["uv_xy_px"])) diff --git a/apps/autocal/src/orisys_autocal/session_review.py b/apps/autocal/src/orisys_autocal/session_review.py index 9a3a223..7bd82ad 100644 --- a/apps/autocal/src/orisys_autocal/session_review.py +++ b/apps/autocal/src/orisys_autocal/session_review.py @@ -110,6 +110,290 @@ def _quality(windows: list[dict]) -> dict: } +#: The multi-point campaign writes its own manifest and keeps each repeat's frame +#: source in a nested recorder session; both are reviewed through this module's one +#: window contract so the operator sees one review screen for either recording. +MULTIPOINT_FORMAT = "autocal-multipoint-1.0" +CAPTURE_DIRECTORY = "capture" +#: Live capture stamps frames against this host's monotonic clock, the same base the +#: campaign uses for its hold windows. A replayed or absent basis cannot be joined to +#: a reference window, so it is reported instead of being compared. +LIVE_TIME_BASIS = "host_camera" +#: The campaign calls the initial zero-load hold ``baseline``; the review contract names +#: that branch ``zero``, so one fitting and hysteresis rule serves both recordings. +BRANCH_ALIASES = {"baseline": "zero"} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _capture_source(directory: Path, root: Path, inputs: dict) -> 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. + """ + source = { + "recorded": False, "reason": "no_capture_directory", + "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": [], + } + if not directory.is_dir(): + return source + for name in ("session.json", "frames.csv", "forces.csv"): + if not (directory / name).is_file(): + source["reason"] = f"missing {name}" + return source + for name in ("session.json", "frames.csv", "forces.csv"): + inputs[str((directory / name).relative_to(root))] = _sha256(directory / name) + metadata = json.loads((directory / "session.json").read_text(encoding="utf-8")) + frames: dict[int, int] = {} + bases: set[str] = set() + in_video = 0 + segments: set[int] = set() + for row in _rows(directory / "frames.csv"): + if row.get("dropped") != "0" or not row.get("t_host_mono_ns"): + continue + if row.get("in_video") != "1": + continue + segment = int(row["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 + frame_id = int(row["frame_id"]) + if frame_id in frames: + raise ValueError("duplicate frame identity in a recorded frame source") + frames[frame_id] = int(row["t_host_mono_ns"]) + bases.add(row.get("time_basis", "")) + if row.get("in_video") == "1": + in_video += 1 + if row.get("segment"): + segments.add(int(row["segment"])) + if not frames: + source["reason"] = "no_recorded_video_frames" + return source + for segment in segments: + video = directory / f"raw_{segment:04d}.mp4" + inputs[str(video.relative_to(root))] = _sha256(video) + session_id = metadata.get("session_id") + samples = [] + for row in _rows(directory / "forces.csv"): + if row.get("valid") != "1" or not row.get("FN_raw"): + continue + if session_id is not None and int(row["session_id"]) != int(session_id): + raise ValueError("frame source identity changed inside one repeat") + stamp = frames.get(int(row["frame_id"])) + raw = float(row["FN_raw"]) + if stamp is not None and math.isfinite(raw): + samples.append((stamp, int(row["frame_id"]), raw)) + samples.sort() + source.update({ + "recorded": True, "reason": "", "capture_session_id": session_id, + "frames": len(frames), "in_video": in_video, "time_bases": sorted(bases), + "segments": sorted(segments), "samples": samples, + }) + return source + + +def _multipoint_site(record: dict, windows: list[dict]) -> dict: + """Keep the campaign's own record and add the review contract's aliases to it.""" + position = record.get("position") or {} + position_sensor_mm = None + if record.get("position_frame") == "sensor": + try: + values = [float(position[axis]) for axis in "xyz"] + except (KeyError, TypeError, ValueError): + values = [] + if len(values) == 3 and all(math.isfinite(value) for value in values): + position_sensor_mm = values + uv = record.get("uv_px") + if isinstance(uv, dict): + uv = [uv["u"], uv["v"]] if "u" in uv and "v" in uv else None + training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"] + confirmed = [w for w in training if w["stability_verified"]] + # The declared steps in window order are the loading and unloading curve the + # operator asked for; measured means are added where the frame source provided them. + curve = [{ + "trial_id": w["trial_id"], "step_index": w["step_index"], "branch": w["branch"], + "recorded_branch": w["recorded_branch"], + "target_load_n": w["target_load_n"], "reference_n": w["reference_n"], + "samples": w["samples"], "raw_mean": w["raw_mean"], "raw_std": w["raw_std"], + } for w in windows] + return { + **record, + "uv_xy_px": [int(uv[0]), int(uv[1])] if isinstance(uv, (list, tuple)) and len(uv) == 2 else None, + "position_sensor_mm": position_sensor_mm, + "windows": windows, "curve": curve, + "fit": _fit(training), "confirmed_fit": _fit(confirmed), "quality": _quality(windows), + } + + +def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = None) -> dict: + """Review a multi-point session through the same hold-window contract. + + The campaign declares every reference hold and records whether the repeat finished; + the raw normal signal is whatever the shared recorder beside it captured. Nothing is + filled in for a missing frame source, an unfinished repeat or an unverified window. + """ + selections = selections or {} + used: set[str] = set() + issues: list[dict] = [] + windows: list[dict] = [] + coverage: list[dict] = [] + inputs = {"session.json": _sha256(root / "session.json")} + maximum = float(manifest["max_force_n"]) + if not math.isfinite(maximum) or maximum <= 0: + raise ValueError("invalid session force range") + simulated = manifest.get("simulate") is True + for trial in manifest["trials"]: + trial_id = trial["trial_id"] + path = (root / trial["directory"]).resolve() + if not path.is_relative_to(root): + raise ValueError("trial directory is outside the multi-point session") + record = json.loads((path / "session.json").read_text(encoding="utf-8")) + for name in ("session.json", "reference.csv"): + file = path / name + if file.is_file(): + inputs[str(file.relative_to(root))] = _sha256(file) + complete = trial.get("status") == record.get("status") == "complete" + 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) + samples = source["samples"] + unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS] + if not source["recorded"]: + images = record.get("images") or {} + issues.append({"trial_id": trial_id, "reason": "no_frame_source", + "detail": str(images.get("reason") or source["reason"])}) + basis_reason = "no_frame_source" + elif not source["time_bases"] or unusable: + issues.append({"trial_id": trial_id, "reason": "time_basis_not_comparable", + "detail": ", ".join(source["time_bases"]) or "time_basis absent"}) + samples = [] + basis_reason = "time_basis_not_comparable" + else: + basis_reason = "" + blocked = basis_reason or ("simulation" if simulated else "") or ( + "incomplete_trial" if not complete else "") + if simulated and not basis_reason: + issues.append({"trial_id": trial_id, "reason": "simulation", "detail": "debug acquisition"}) + reference_rows = _rows(path / "reference.csv") + curve_reference = [r for r in reference_rows if r.get("hold_complete") == "1"] + coverage.append({ + "trial_id": trial_id, "site_id": trial["site_id"], + "repeat_index": trial.get("repeat_index"), "status": trial.get("status"), + "reason": trial.get("reason") or "", "windows": len(curve_reference), + "frame_source": source["directory"] if source["recorded"] else None, + "frames": source["frames"], "in_video": source["in_video"], + "time_bases": source["time_bases"], "segments": source["segments"], + "usable": bool(samples) and not blocked, + "blocked_by": blocked, + }) + for reference in reference_rows: + if reference.get("hold_complete") != "1": + continue + start = int(reference["t_start_host_mono_ns"]) + end = int(reference["t_end_host_mono_ns"]) + load = float(reference["F_ref_normal_N"]) + if end <= start or not math.isfinite(load) or not 0 <= load <= maximum: + raise ValueError(f"invalid reference window in {trial_id}") + key = f"{trial_id}:{int(reference['step_index'])}" + choice = selections.get(key) + selected_start, selected_end = start, end + verified = False + if choice is not None: + used.add(key) + selected_start = int(choice["t_start_host_mono_ns"]) + selected_end = int(choice["t_end_host_mono_ns"]) + verified = choice["stability_verified"] is True + if not start <= selected_start < selected_end <= end: + raise ValueError(f"selection outside recorded hold: {key}") + trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end] + raw = np.array([value for stamp, _, value in trace if selected_start <= stamp < selected_end], dtype=float) + if verified and raw.size < 2: + raise ValueError(f"not enough valid samples to confirm: {key}") + if blocked: + status = blocked + elif raw.size >= 2: + status = "candidate" + elif basis_reason: + status = basis_reason + else: + status = "insufficient_samples" + windows.append({ + "trial_id": trial_id, "site_id": trial["site_id"], + "step_index": int(reference["step_index"]), + "branch": BRANCH_ALIASES.get(reference["branch"], reference["branch"]), + "recorded_branch": reference["branch"], "reference_n": load, + "target_load_n": float(reference["target_load_n"]), + # Declared references only: a hand-entered force is not an alignment. + "reference_source": reference.get("reference_source", ""), + "hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end, + "t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end, + "duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size), + "trace": trace, + "raw_mean": float(raw.mean()) if raw.size else None, + "raw_std": float(raw.std()) if raw.size else None, + "raw_drift_per_s": float(np.polyfit( + np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace + if selected_start <= stamp < selected_end]), raw, 1)[0]) + if raw.size >= 2 and len({stamp for stamp, _, _ in trace + if selected_start <= stamp < selected_end}) >= 2 else None, + "stability_verified": verified, + "reference_alignment_possible": False, + "frame_source": source["directory"] if source["recorded"] else None, + "status": status, + }) + if set(selections) != used: + raise ValueError("selection refers to an unavailable hold window") + sites = [_multipoint_site(record, [w for w in windows if w["site_id"] == record["site_id"]]) + for record in manifest["sites"]] + completion = {entry["site_id"]: entry for entry in manifest.get("completion") or []} + for site in sites: + entry = completion.get(site["site_id"], {}) + site["coverage"] = { + "repeats_planned": entry.get("repeats_planned"), "repeats_complete": entry.get("repeats_complete"), + "trials": sum(1 for t in coverage if t["site_id"] == site["site_id"]), + "usable_trials": sum(1 for t in coverage + if t["site_id"] == site["site_id"] and t["usable"]), + } + site["formal_conclusion"] = False + site["blockers"] = sorted({ + *[t["blocked_by"] for t in coverage + if t["site_id"] == site["site_id"] and t["blocked_by"]], + *["incomplete_trial" for t in coverage + if t["site_id"] == site["site_id"] and t["status"] != "complete"], + *[f"unverified_window:{w['step_index']}" for w in site["windows"] + if not w["stability_verified"]], + *(["no_fit"] if site["fit"] is None else []), + }) + return { + "format_version": "autocal-review-1.0", "source_format": MULTIPOINT_FORMAT, + "session_id": manifest["session_id"], "source_directory": str(root), + "session_status": manifest["status"], "status": "exploratory", + "formal_conclusion": False, "sites": sites, "issues": issues, + "selections": selections, "input_sha256": inputs, "analysis_inputs": {}, + "trials": manifest["trials"], "coverage": coverage, + "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"), + "completion": manifest.get("completion") or [], + "session_reason": manifest.get("reason") or "", + "sync": manifest.get("sync"), "platform_motion": bool(manifest.get("platform_motion")), + "automatic_force_control": bool(manifest.get("automatic_force_control")), + "force_range_n": [manifest.get("min_force_n"), manifest.get("max_force_n")], + } + + def review_session(directory: Path, *, selections: dict | None = None) -> dict: """Join frame identity to host timestamps and inspect only complete windows. @@ -118,8 +402,8 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: """ root = Path(directory).resolve() manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) - if manifest.get("format_version") == "autocal-multipoint-1.0": - raise ValueError("multi-point sessions are raw acquisition folders; this review only opens finger sessions") + if manifest.get("format_version") == MULTIPOINT_FORMAT: + return review_multipoint(root, manifest=manifest, selections=selections) if manifest.get("format_version") != "autocal-session-1.0": raise ValueError("select a calibration session root, not an individual trial") windows = [] @@ -208,6 +492,7 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict: if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None, "stability_verified": verified, "reference_alignment_possible": bool(metadata["reference_alignment_possible"]), + "frame_source": trial["directory"], "status": "candidate" if raw.size >= 2 else "insufficient_samples", }) if set(selections) != used: @@ -283,7 +568,9 @@ def _save_baselines(result: dict, destination: Path) -> list[dict]: selected = [r for r in window["trace"] if window["t_start_host_mono_ns"] <= r[0] < window["t_end_host_mono_ns"]] frame_id = selected[0][1] trial_id = window["trial_id"] - trial_path = (root / trials[trial_id]["directory"]).resolve() + # A multi-point repeat keeps its frames in the nested capture session, so the + # window, not the format, says where the video index belongs. + trial_path = (root / (window.get("frame_source") or trials[trial_id]["directory"])).resolve() row = next(r for r in _rows(trial_path / "frames.csv") if int(r["frame_id"]) == frame_id) item = {"site_id": site["site_id"], "trial_id": trial_id, "frame_id": frame_id, "role": "reviewed_zero_load_inspection", diff --git a/apps/autocal/src/orisys_autocal/ui/device_setup.py b/apps/autocal/src/orisys_autocal/ui/device_setup.py index 7830a21..34df33b 100644 --- a/apps/autocal/src/orisys_autocal/ui/device_setup.py +++ b/apps/autocal/src/orisys_autocal/ui/device_setup.py @@ -26,9 +26,11 @@ from __future__ import annotations from orisys_app_shared.i18n import t from PySide6.QtCore import Signal from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, + QDoubleSpinBox, QFormLayout, QGroupBox, QLabel, @@ -43,11 +45,13 @@ from .bus_panel import BusPanel from .connection_panel import ConnectionPanel #: Tab identity -> locale key, in the order they are shown. The order is the work: -#: reach the machine, bring its bus up, then say what each drive moves. +#: reach the machine, bring its bus up, then say what each drive moves and how the +#: press axis's coordinate relates to the contact height the solver reports. TABS: tuple[tuple[str, str], ...] = ( ("connection", "setup.tab_connection"), ("bus", "setup.tab_bus"), ("axes", "setup.tab_axes"), + ("press", "setup.tab_press"), ) #: Role -> locale key for the mapping rows. @@ -165,6 +169,116 @@ class AxisRolesPage(QWidget): self._refresh_problem(announce=False) +class PressAxisPage(QWidget): + """How the press axis's coordinate relates to the solved contact height. + + A page of its own because it is a measurement on the machine, not a preference: + the solver reports millimetres of geometry above the table's origin plane, and + the drive is commanded in its own units from its own zero. Nothing in the model + can supply the difference, so the operator does — and until they do, the pose + move holds the press axis instead of driving it to a geometric number that was + never anchored to this machine. + """ + + pressChanged = Signal() + + def __init__( + self, + press: object = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + column = QVBoxLayout(self) + column.setContentsMargins(8, 8, 8, 8) + column.setSpacing(8) + + self.hint_label = QLabel(self) + self.hint_label.setObjectName("hintLabel") + self.hint_label.setWordWrap(True) + column.addWidget(self.hint_label) + + self.box = QGroupBox(self) + self.box.setObjectName("groupBox") + form = QFormLayout(self.box) + form.setContentsMargins(10, 8, 10, 10) + form.setSpacing(6) + + self.zero_label = QLabel(self.box) + self.zero_spin = QDoubleSpinBox(self.box) + self.zero_spin.setDecimals(4) + self.zero_spin.setRange(-10000.0, 10000.0) + self.zero_spin.setSuffix(" mm") + self.zero_spin.valueChanged.connect(self._on_changed) + form.addRow(self.zero_label, self.zero_spin) + + self.rising_check = QCheckBox(self.box) + self.rising_check.toggled.connect(self._on_changed) + form.addRow(self.rising_check) + + self.calibrated_check = QCheckBox(self.box) + self.calibrated_check.toggled.connect(self._on_changed) + form.addRow(self.calibrated_check) + column.addWidget(self.box) + + self.state_label = QLabel(self) + self.state_label.setObjectName("statusValue") + self.state_label.setWordWrap(True) + column.addWidget(self.state_label) + column.addStretch(1) + + self.set_press(press) + self.retranslate() + + # ------------------------------------------------------------------- state + + def set_press(self, press: object) -> None: + """Show the stored relation without announcing a change nobody made.""" + zero = getattr(press, "zero_mm", 0.0) + rising = getattr(press, "rising", True) + calibrated = getattr(press, "calibrated", False) + for widget, value in ( + (self.zero_spin, float(zero)), + (self.rising_check, bool(rising)), + (self.calibrated_check, bool(calibrated)), + ): + widget.blockSignals(True) + try: + if widget is self.zero_spin: + widget.setValue(value) + else: + widget.setChecked(value) + finally: + widget.blockSignals(False) + self._refresh_state(announce=False) + + def press(self) -> dict[str, object]: + return { + "zero_mm": float(self.zero_spin.value()), + "rising": bool(self.rising_check.isChecked()), + "calibrated": bool(self.calibrated_check.isChecked()), + } + + def _on_changed(self, _value: object = None) -> None: + self._refresh_state() + + def _refresh_state(self, *, announce: bool = True) -> None: + self.state_label.setText( + t("setup.press_set") if self.calibrated_check.isChecked() else t("setup.press_unset") + ) + if announce: + self.pressChanged.emit() + + # ------------------------------------------------------------- translations + + def retranslate(self) -> None: + self.hint_label.setText(t("setup.press_hint")) + self.box.setTitle(t("setup.tab_press")) + self.zero_label.setText(t("setup.press_zero")) + self.rising_check.setText(t("setup.press_rising")) + self.calibrated_check.setText(t("setup.press_calibrated")) + self._refresh_state(announce=False) + + class DeviceSetupDialog(QDialog): """Connection and bus initialisation, the once-per-start controls.""" @@ -173,6 +287,7 @@ class DeviceSetupDialog(QDialog): spec: ConnectionSpec, bas_path: str = "", roles: dict[str, int] | None = None, + press: object = None, parent: QWidget | None = None, ) -> None: super().__init__(parent) @@ -195,6 +310,7 @@ class DeviceSetupDialog(QDialog): self.connection_panel = ConnectionPanel(spec, self.tabs) self.bus_panel = BusPanel(bas_path, self.tabs) self.roles_page = AxisRolesPage(roles, self.tabs) + self.press_page = PressAxisPage(press, self.tabs) for key, locale_key in TABS: self.tabs.addTab(self.panel(key), "") column.addWidget(self.tabs, 1) @@ -215,10 +331,12 @@ class DeviceSetupDialog(QDialog): # ----------------------------------------------------------------- content def panel(self, key: str) -> QWidget: - """One of the three pages, by tab key.""" + """One of the four pages, by tab key.""" if key == "connection": return self.connection_panel - return self.bus_panel if key == "bus" else self.roles_page + if key == "bus": + return self.bus_panel + return self.roles_page if key == "axes" else self.press_page def open_for(self, tab: str | None = None) -> None: """Show the dialog, raising it if it is already up, and pick a tab. @@ -283,3 +401,4 @@ class DeviceSetupDialog(QDialog): self.connection_panel.retranslate() self.bus_panel.retranslate() self.roles_page.retranslate() + self.press_page.retranslate() diff --git a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py b/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py index 2c5698a..ee1873b 100644 --- a/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/multipoint_panel.py @@ -25,6 +25,12 @@ from orisys_app_shared.i18n import t from ..multipoint import Campaign, next_point_id 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.""" @@ -275,29 +281,17 @@ class MultipointPanel(QWidget): def _hold(self) -> float: return float(self.host.hold.value()) - 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 + 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")) - 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 - ) - pose = { + return stack + + @staticmethod + def _pose_of(solved, stack) -> dict: + """The record of one solve: the targets, and what the solver said of them.""" + return { "x": float(solved.table_xy_mm[0]), "y": float(solved.table_xy_mm[1]), "yaw": float(solved.yaw_deg), @@ -310,13 +304,42 @@ class MultipointPanel(QWidget): "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, - pose, + 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())) @@ -328,6 +351,70 @@ class MultipointPanel(QWidget): 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.""" + site = next(item for item in self.campaign.sites if item.site_id == site_id) + site.pose = 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: @@ -417,18 +504,26 @@ class MultipointPanel(QWidget): """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) - pose = site.pose - if pose is None or not pose.get("in_range"): - if site.uv_px is None: - raise ValueError(t("multipoint.no_pose")) - point, normal, pose, uv = self._solve_uv(*site.uv_px) - self._store_pose(site.site_id, point, normal, pose, uv) + # 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)) @@ -522,7 +617,7 @@ class MultipointPanel(QWidget): break elif self.campaign.phase != "liftoff": self._offered_liftoff = "" - self.align_button.setEnabled(self.campaign.phase != "hold" and bool(self._selected)) + self.align_button.setEnabled(self._can_align()) branch = { "baseline": t("multipoint.branch_baseline"), "load": t("multipoint.branch_load"), @@ -546,7 +641,7 @@ class MultipointPanel(QWidget): (site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)), None, ) - pose = None if shown is None else shown.pose + 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"], diff --git a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py index c6043e2..492f332 100644 --- a/apps/autocal/src/orisys_autocal/ui/sensor_panel.py +++ b/apps/autocal/src/orisys_autocal/ui/sensor_panel.py @@ -45,6 +45,7 @@ from PySide6.QtWidgets import ( ) from ..motion.kinematics import PlatformStack +from ..motion.pose_move import PressAxis, press_targets from ..sensor.export_bundle import SensorBundle from ..sensor.frames import describe as describe_frames from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin @@ -372,7 +373,9 @@ class FingerPanel(QGroupBox): "sensor_normal", "platform_point", "pose", + "branch", "table_move", + "press", "residual", "frames", ) @@ -751,13 +754,19 @@ class FingerPanel(QGroupBox): finally: self.confirm_check.blockSignals(False) + _press = PressAxis() + def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None: """The selected point's per-role targets, and the roles held back. The two numbers that matter are the *table's* move and the stage angles: the table translation is what the stages are resolved against, and it is what the - preview drew as the cyan path's end. Z is the contact height, commanded with - the other four axes in the same move. + preview drew as the cyan path's end. + + Z is the *geometric* contact height, and it goes through + :func:`press_targets` with the bench's press-axis relation. With no measured + zero that role comes back as held, because a geometric height is not a + controller coordinate and must not be passed off as one. """ pose = self._last_pose if pose is None: @@ -770,7 +779,41 @@ class FingerPanel(QGroupBox): "pitch": float(pose.pitch_deg), "z": float(pose.contact_z_mm), } - return targets, () + return press_targets(targets, self._press) + + def set_press(self, press: PressAxis) -> None: + """Remember how the press axis's coordinate relates to the contact height. + + A bench measurement, entered in device setup. This panel does not invent it: + until it is set the press role is held, and the readout says so rather than + showing a target nobody has anchored to the machine. + """ + self._press = press + self._readout_labels["press"].setText( + "—" if self._last_pose is None else self._press_text(self._last_pose) + ) + + def _branch_text(self, pose) -> str: + """Which of the exact poses was chosen, and which key chose it.""" + if not getattr(pose, "branch_reason", ""): + return t("finger.branch_none") + return t( + "finger.branch_value", + reason=t(f"finger.branch_{pose.branch_reason}"), + travel=f"{pose.branch_travel_deg:.1f}", + other=f"{pose.other_travel_deg:.1f}", + ) + + def _press_text(self, pose) -> str: + """Where the press axis has to read for this contact, or why it is held.""" + target = self._press.target_for(pose.contact_z_mm) + if target is None: + return t("finger.press_unset") + return t( + "finger.press_target", + target=f"{target:+.4f}", + height=f"{pose.contact_z_mm:+.4f}", + ) def _sync_sim_button(self) -> None: self.sim_button.setText( @@ -1212,7 +1255,9 @@ class FingerPanel(QGroupBox): "sensor_normal", "platform_point", "pose", + "branch", "table_move", + "press", "residual", ): self._readout_labels[key].setText("—") @@ -1240,6 +1285,8 @@ class FingerPanel(QGroupBox): ) if pose is not None: self._readout_labels["pose"].setText(pose.describe()) + self._readout_labels["branch"].setText(self._branch_text(pose)) + self._readout_labels["press"].setText(self._press_text(pose)) self._readout_labels["residual"].setText( t( "finger.residual_value", @@ -1326,7 +1373,9 @@ class FingerPanel(QGroupBox): "sensor_normal": "finger.row_sensor_normal", "platform_point": "finger.row_platform_point", "pose": "finger.row_pose", + "branch": "finger.row_branch", "table_move": "finger.row_table_move", + "press": "finger.row_press", "residual": "finger.row_residual", "frames": "finger.row_frames", } diff --git a/apps/autocal/src/orisys_autocal/ui/window.py b/apps/autocal/src/orisys_autocal/ui/window.py index b5f8b34..38471e7 100644 --- a/apps/autocal/src/orisys_autocal/ui/window.py +++ b/apps/autocal/src/orisys_autocal/ui/window.py @@ -39,7 +39,13 @@ from PySide6.QtWidgets import ( from .. import __version__ as app_version from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..motion.kinematics import PlatformStack -from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial +from ..motion.pose_move import ( + PoseMover, + PressAxis, + plan_pose_move, + plan_return_to_initial, + press_targets, +) from ..motion.service import clamp_bas_task from ..motion.units import DEFAULT_UNITS from ..preferences import load_preferences, save_preferences @@ -219,6 +225,7 @@ class AutoCalMainWindow(QMainWindow): self.spec, str(self.prefs.get("bas_path") or ""), self.prefs.get("axis_roles"), + self._press_axis, self, ) self.connection_panel = self.device_setup.connection_panel @@ -226,6 +233,9 @@ class AutoCalMainWindow(QMainWindow): # The mapping is live: the guards read it from the service, so an edit on the # page has to reach the service rather than only the preference file. self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed) + # The press-axis relation is live for the same reason: it decides whether a + # geometric contact height may become a controller coordinate at all. + self.device_setup.press_page.pressChanged.connect(self._on_press_axis_changed) self.axis_readout = AxisReadoutPanel( MotionService().axis_count, self._theme, self.rail_content @@ -289,6 +299,7 @@ class AutoCalMainWindow(QMainWindow): tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2), parent=body, ) + self.finger_panel.set_press(self._press_axis) self.finger_source_panel = FingerSourcePanel( video_path=str(self.prefs.get("finger_video_path") or ""), config_ref=str(self.prefs.get("finger_config_ref") or ""), @@ -404,6 +415,24 @@ class AutoCalMainWindow(QMainWindow): if problem: self.finger_panel.set_move_status(f"✗ {problem}") + @property + def _press_axis(self) -> PressAxis: + """How the press axis's coordinate relates to a contact height. + + A bench measurement, not a derived quantity: the geometric height a pose + produces says nothing about what the controller will read when the press + face reaches it. Until the operator has measured that relation the press + role is held, so an uncalibrated machine cannot be commanded to a height + that means nothing to it. + """ + return PressAxis.from_settings(self.prefs.get("press_axis")) + + def _on_press_axis_changed(self) -> None: + """Apply a press-axis edit to the panel that turns heights into targets.""" + press = self.device_setup.press_page.press() + self.prefs["press_axis"] = dict(press) + self.finger_panel.set_press(self._press_axis) + # ---------------------------------------------------------- finger debug def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE): @@ -1070,9 +1099,11 @@ class AutoCalMainWindow(QMainWindow): def _on_execute_pose(self) -> None: """Move the platform to the solved pose of the finger screen's selection. - Yaw, pitch, table X/Y and the press axis start together. The Z target is - the solved contact height. The numbers in the confirmation are the ones - the preview drew. + Yaw, pitch and table X/Y start together. The press axis joins them only + when the bench has measured how its coordinate relates to a contact + height; otherwise the panel hands back that role as held, so the move + carries the roles it can justify and says which one it left alone. The + numbers in the confirmation are the ones the preview drew. """ solve = self.finger_panel.solved_pose() if solve is None: @@ -1089,12 +1120,18 @@ 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 point chosen on 选择测点. + + The calibration panel 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. + """ + targets, held = press_targets(targets, self._press_axis) plan = plan_pose_move( self.poller.service, targets, title=t("multipoint.align"), - hold=(), + hold=held, together=True, ) self._start_move( @@ -1248,7 +1285,9 @@ class AutoCalMainWindow(QMainWindow): def _apply_theme(self) -> None: app = QApplication.instance() if app is not None: - app.setStyleSheet(build_qss(self._theme)) + stylesheet = build_qss(self._theme) + if app.styleSheet() != stylesheet: + app.setStyleSheet(stylesheet) self.brand_bar.set_theme(self._theme) self.status_strip.set_theme(self._theme) self.run_bar.set_theme(self._theme) diff --git a/apps/autocal/tests/test_device_setup.py b/apps/autocal/tests/test_device_setup.py index 6636539..ceff032 100644 --- a/apps/autocal/tests/test_device_setup.py +++ b/apps/autocal/tests/test_device_setup.py @@ -180,20 +180,21 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win window = make_window() try: dialog = window.device_setup - assert dialog.tabs.count() == 3 + assert dialog.tabs.count() == 4 page = dialog.roles_page assert page.roles() == DEFAULT_AXIS_ROLES assert page.problem() == "" assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES - # Two roles on one drive: reported on the page *and* by the guard. - page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3)) + # Two roles on one drive: reported on the page *and* by the guard. Yaw is + # moved onto pitch's drive, which is the duplicate this page exists to refuse. + page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(3)) assert page.problem() != "" assert window.poller.service.roles_problem() != "" assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None # Correcting it clears both. - page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(2)) + page.combos["yaw"].setCurrentIndex(page.combos["yaw"].findData(2)) assert page.problem() == "" assert window.poller.service.roles_problem() == "" finally: @@ -222,3 +223,63 @@ def test_opening_the_setup_does_not_touch_the_connection(make_window): assert window.connection_panel.open_button.isEnabled() is True finally: window.close() + + +def test_the_press_page_starts_uncalibrated_and_says_so(make_window): + """Until the zero is measured the press axis is not a coordinate anyone knows.""" + window = make_window() + try: + page = window.device_setup.press_page + assert page.press()["calibrated"] is False + assert page.state_label.text() == t("setup.press_unset") + assert window.finger_panel.solved_pose # bound; the pose itself needs a bundle + finally: + window.close() + + +def test_a_press_axis_edit_reaches_the_panel_that_plans_the_move(make_window): + """The page is a measurement, and the move has to obey it immediately. + + A zero typed here but not handed to the solver's consumer would leave the + confirmation box showing a height the press axis was never told about. + """ + window = make_window() + try: + page = window.device_setup.press_page + page.zero_spin.setValue(12.5) + page.calibrated_check.setChecked(True) + assert window.prefs["press_axis"] == { + "zero_mm": 12.5, + "rising": True, + "calibrated": True, + } + assert window.finger_panel._press.calibrated is True # noqa: SLF001 + assert window._press_axis.zero_mm == 12.5 # noqa: SLF001 + assert page.state_label.text() == t("setup.press_set") + + page.rising_check.setChecked(False) + assert window.prefs["press_axis"]["rising"] is False + assert window._press_axis.rising is False # noqa: SLF001 + finally: + window.close() + + +def test_a_stored_press_axis_is_restored_into_the_page(make_window): + window = make_window( + press_axis={"zero_mm": -3.25, "rising": False, "calibrated": True} + ) + try: + page = window.device_setup.press_page + assert page.press() == {"zero_mm": -3.25, "rising": False, "calibrated": True} + assert window.finger_panel._press.zero_mm == -3.25 # noqa: SLF001 + finally: + window.close() +@pytest.mark.parametrize("settings", [ + {"zero_mm": 12.5, "rising": True, "calibrated": "false"}, + {"zero_mm": float("nan"), "rising": True, "calibrated": True}, + {"zero_mm": 12.5, "rising": "false", "calibrated": True}, +]) +def test_invalid_stored_press_relation_does_not_enable_motion(make_window, settings): + window = make_window() + window.prefs["press_axis"] = settings + assert window._press_axis.target_for(19.051) is None diff --git a/apps/autocal/tests/test_finger_panel.py b/apps/autocal/tests/test_finger_panel.py index ff26b0f..012aba7 100644 --- a/apps/autocal/tests/test_finger_panel.py +++ b/apps/autocal/tests/test_finger_panel.py @@ -1168,7 +1168,11 @@ def test_the_sensor_z_offset_reaches_the_solve(panel): panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0) thick = panel.stack.solve(point, normal) - assert thick.contact_z_mm - thin.contact_z_mm == pytest.approx(4.0) + # The mounting height rises by the four millimetres the setup asks for; the + # height the stages *present* rises by 4*cos(tilt) of that, because the offset + # is applied before the rotation. + assert thick.mounted_point_mm[2] - thin.mounted_point_mm[2] == pytest.approx(4.0) + 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() @@ -1179,7 +1183,8 @@ def test_the_sensor_z_offset_reaches_the_solve(panel): thick_row = panel.handle_pick(x, y) assert thin_row is not None and thick_row is not None assert thick_row["residual"] == pytest.approx(thin_row["residual"], abs=1e-6) - assert thick_row["Z"] - thin_row["Z"] == pytest.approx(4.0) + assert thin_row["Z"] == pytest.approx(thin.contact_z_mm) + assert thick_row["Z"] == pytest.approx(thick.contact_z_mm) assert thick_row["X"] == pytest.approx(thin_row["X"]) assert thick_row["Y"] == pytest.approx(thin_row["Y"]) @@ -1202,7 +1207,12 @@ def test_the_mounting_pose_moves_the_point_the_way_the_setup_says(panel): assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(2.0) assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-3.0) - assert moved.contact_z_mm == pytest.approx(base.contact_z_mm) + # An in-plane offset leaves the mounting height alone but not the height the + # stages present: the same angles lift a contact that sits further from the + # trunnion axis. (This line used to compare contact_z_mm on both sides, which + # only held while that field was reported before the rotation.) + assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2]) + assert moved.contact_z_mm != pytest.approx(base.contact_z_mm) # A yaw about the table's z turns the contact about the origin, so it moves the # point and leaves its height alone. @@ -1247,6 +1257,58 @@ def test_the_readouts_name_the_frame_mapping(panel): assert "-y" in text or "−y" in text +def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel): + """The plan's rule: a branch is picked for a reason, and the bench sees which. + + Both closed-form poses align a measured point; they differ by a half turn of + the body about the vertical. Whichever one the machine wants is a bench + convention, so the screen has to say which was taken and what decided it. + """ + from orisys_app_shared.i18n import t + + x, y = _first_surface_pixel(panel.view.flat) + panel.handle_pick(x, y) + + pose = panel._last_pose # noqa: SLF001 + text = panel._readout_labels["branch"].text() # noqa: SLF001 + assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest"} + assert t(f"finger.branch_{pose.branch_reason}") in text + # And the alternative's cost is on screen too, not just the chosen one's. + assert f"{pose.other_travel_deg:.1f}" in text + + +def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel): + """A geometric contact height is not a controller coordinate until it is measured.""" + from orisys_app_shared.i18n import t + from orisys_autocal.motion.pose_move import PressAxis + + x, y = _first_surface_pixel(panel.view.flat) + panel.handle_pick(x, y) + pose = panel._last_pose # noqa: SLF001 + # The row records where the point *is*; the move records where the table goes. + height = pose.contact_z_mm + + # Shipped state: no zero measured, so the press role is held — and the readout + # says so rather than showing a number the machine was never told about. + assert panel._readout_labels["press"].text() == t("finger.press_unset") # noqa: SLF001 + targets, held = panel.solved_pose() + assert held == ("z",) + assert "z" not in targets + assert targets["x"] == pytest.approx(pose.table_xy_mm[0]) + + # With the bench's zero entered, the same contact height becomes a coordinate. + panel.set_press(PressAxis(zero_mm=2.0, rising=True, calibrated=True)) + targets, held = panel.solved_pose() + assert held == () + assert targets["z"] == pytest.approx(height + 2.0) + text = panel._readout_labels["press"].text() # noqa: SLF001 + assert f"{height + 2.0:+.4f}" in text + # A downward-reading press axis flips the relation, and the readout follows. + panel.set_press(PressAxis(zero_mm=2.0, rising=False, calibrated=True)) + targets, _held = panel.solved_pose() + assert targets["z"] == pytest.approx(2.0 - height) + + def test_the_preview_refuses_to_draw_without_a_context(qt_app): from orisys_autocal.ui.pose_preview import PosePreview @@ -1613,3 +1675,18 @@ def test_the_surface_keeps_its_residual_colours_while_the_machine_moves(qt_app, assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001 finally: preview.deleteLater() +def test_reapplying_the_current_theme_does_not_repolish_all_windows(make_window, qt_app, monkeypatch): + window = make_window() + calls = [] + apply_style = qt_app.setStyleSheet + + def record_style(style): + calls.append(style) + apply_style(style) + + monkeypatch.setattr(qt_app, "setStyleSheet", record_style) + window._apply_theme() + assert calls == [] + window._theme = "dark" if window._theme == "light" else "light" + window._apply_theme() + assert len(calls) == 1 diff --git a/apps/autocal/tests/test_ik_analytical.py b/apps/autocal/tests/test_ik_analytical.py index 516a3c7..e27a406 100644 --- a/apps/autocal/tests/test_ik_analytical.py +++ b/apps/autocal/tests/test_ik_analytical.py @@ -199,13 +199,13 @@ def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle): ) -def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle): - """Turning the face over is the failure mode; measure it and bound it. +def test_no_vertex_ends_turned_over(bundle): + """Turning the face over is the failure mode; with the right up axis it is zero. - The remainder are vertices whose normals are 60-90 degrees off the tool axis — - the rim of the dome. Aligning an edge-on face has to roll the body about that - normal, and the sign is decided by rounding, so they cannot be pushed to zero - and should not be asserted away. + ``uprightness`` used to compare the platform's Y axis, so 47 of these vertices read + as turned over even though they only rolled about the vertical. Against the + post-mount nominal up (the tool axis) no vertex ends inverted, and this now fails + if the reference regresses rather than reporting a small count as acceptable. """ stack = PlatformStack() normals = bundle.vertex_normals() @@ -214,9 +214,7 @@ def test_few_vertices_end_turned_over_and_those_are_edge_on(bundle): pose = stack.solve(np.zeros(3), normal) if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5: flipped += 1 - assert flipped <= len(normals) * 0.03, ( - f"{flipped} of {len(normals)} vertices ended turned over" - ) + assert flipped == 0, f"{flipped} of {len(normals)} vertices ended turned over" def test_a_pose_reports_whether_it_is_upright(): diff --git a/apps/autocal/tests/test_kinematics.py b/apps/autocal/tests/test_kinematics.py index 9604313..1e2050d 100644 --- a/apps/autocal/tests/test_kinematics.py +++ b/apps/autocal/tests/test_kinematics.py @@ -19,6 +19,7 @@ import numpy as np import pytest from orisys_autocal.motion.kinematics import ( ALIGNED_TOL_DEG, + Branch, PlatformStack, Pose, residual_for, @@ -512,6 +513,10 @@ def test_the_table_move_lands_the_contact_on_the_tool(): final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0]) assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9) assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9) + # The reported contact is that same landing point, height included — the + # whole point of the move is to arrive there, not merely at its xy. + assert pose.contact_point_mm == pytest.approx(tuple(final), abs=1e-9) + assert pose.contact_z_mm == pytest.approx(float(final[2]), abs=1e-9) def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame(): @@ -526,7 +531,12 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram moved = shifted.solve(point, normal) assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5) assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5) - assert moved.contact_z_mm == pytest.approx(base.contact_z_mm) + # An in-plane offset leaves the *mounting* height alone, but not the height the + # stages present: the same angles now lift a contact that sits further from the + # trunnion axis. This line used to compare contact_z_mm on both sides, which + # only held while that field was reported before the rotation. + assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2]) + assert moved.contact_z_mm - base.contact_z_mm > 0.1 turned = dataclasses.replace(stack, sensor_yaw_deg=35.0) yawed = turned.solve(point, normal) @@ -552,7 +562,11 @@ def test_the_setup_z_is_the_mounting_plate(): lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25)) raised = lifted.solve(point, normal) - assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25) + # The plate lifts the mounting pose by exactly its thickness. The height the + # stages present rises by less, because the lift is applied before the stage + # rotation and the rotation tilts it — 4.2285 mm of the 4.25 mm here. + assert raised.mounted_point_mm[2] - plain.mounted_point_mm[2] == pytest.approx(4.25) + assert 0.0 < raised.contact_z_mm - plain.contact_z_mm < 4.25 assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm) # And it is the same number as the plate parameter it replaced. assert lifted.setup_point(point)[2] == pytest.approx( @@ -605,3 +619,181 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance(): assert loose.reachable far = Pose(ok=True, residual_deg=30.0) assert not far.reached_tolerance and not far.reachable + + +# --------------------------------------- the branch, the height and the up axis + + +def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray: + """``R_pitch @ R_yaw`` from the formulas, not through ``rotation_about``.""" + yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg) + about_yaw = np.array( + [ + [np.cos(yaw), -np.sin(yaw), 0.0], + [np.sin(yaw), np.cos(yaw), 0.0], + [0.0, 0.0, 1.0], + ] + ) + about_pitch = np.array( + [ + [np.cos(pitch), 0.0, np.sin(pitch)], + [0.0, 1.0, 0.0], + [-np.sin(pitch), 0.0, np.cos(pitch)], + ] + ) + return about_pitch @ about_yaw + + +#: The two measured points from the bench screenshots, in the sensor frame: +#: point, normal, mounting height, and the height the stages present. The mounting +#: height is the plate (8 mm) over the point's own height; the presented height is +#: what the plan records as 19.051 and 21.578 mm. +MEASURED = ( + ((4.872, 5.444, 16.176), (0.595, 0.673, 0.439), 13.444, 19.051), + ((-2.698, 5.186, 18.228), (-0.343, 0.699, 0.627), 13.186, 21.578), +) + + +def _measured_stack() -> PlatformStack: + """The bench's mounting: the plate is the mounting pose's z, 8 mm.""" + return PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0)) + + +def test_the_contact_height_is_the_height_after_the_stages_turn(): + """Defect: the pose reported the contact's height *before* the stages turned. + + On the bench that is the difference between 13.444 mm and 19.051 mm — the + stages lift the contact several millimetres, and any press-axis target is + derived from the height the contact is *presented* at, not from where it sits + with the stages at zero. + """ + stack = _measured_stack() + for point, normal, mounting, presented in MEASURED: + pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal)) + assert pose.mounted_point_mm[2] == pytest.approx(mounting, abs=5e-4) + assert pose.contact_z_mm == pytest.approx(presented, abs=5e-4) + + # The same height again, from a rotation built here rather than by the + # solver: the landing point of the reported angles. + landed = _independent_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point( + np.array(point) + ) + assert pose.contact_z_mm == pytest.approx(float(landed[2]), abs=1e-9) + assert pose.contact_point_mm == pytest.approx( + (stack.tool_xy_mm[0], stack.tool_xy_mm[1], float(landed[2])), abs=1e-9 + ) + assert pose.contact_point_mm[2] > pose.mounted_point_mm[2] + + +def test_the_pose_aligns_the_normal_and_keeps_the_body_up(): + """Task-1 acceptance: independent rotation, tool axis and the platform's up. + + "Up" is the direction the sensing face nominally aims — platform +Z, which is + where the mount sends the sensor's own up axis — not a horizontal axis of the + platform frame. + """ + stack = _measured_stack() + up = np.array([0.0, 0.0, 1.0]) + for point, normal, _mounting, _presented in MEASURED: + pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal)) + rotate = _independent_rotation(pose.yaw_deg, pose.pitch_deg) + + aligned = rotate @ stack.setup_normal(np.array(normal)) + angle = np.degrees(np.arccos(np.clip(float(aligned @ up), -1.0, 1.0))) + assert angle <= 1e-5 + + # ``uprightness`` is that same rotation carried onto the up axis: the + # component left pointing up, 1.0 when nothing has tipped at all. + assert stack.uprightness(pose.yaw_deg, pose.pitch_deg) == pytest.approx( + float((rotate @ up)[2]), abs=1e-12 + ) + assert 0.0 < stack.uprightness(pose.yaw_deg, pose.pitch_deg) <= 1.0 + assert pose.upright + + +def test_both_exact_poses_of_a_measured_point_leave_the_sensor_face_up(): + """Defect: the up test measured the body against a *horizontal* axis. + + The two exact poses of a measured point are one pose rolled 180 degrees about + the vertical: same contact, same height, mirrored azimuth. Neither turns the + sensing face over, so flagging one of them inverted was the bug — and the + panel, the preview and the guards all read that flag. + """ + stack = PlatformStack() + first = _independent_rotation(-143.580, 47.692) + second = _independent_rotation(36.420, -47.692) + assert second == pytest.approx(rotation_about("z", 180.0) @ first, abs=1e-9) + + for yaw_deg, pitch_deg in ( + (36.420, -47.692), + (-143.580, 47.692), + (-61.319, 45.636), + (118.681, -45.636), + ): + assert stack.uprightness(yaw_deg, pitch_deg) > 0.0 + assert stack.ends_upright(yaw_deg, pitch_deg) + + +def test_equivalent_poses_are_chosen_by_the_machine_not_by_the_residual(): + """Two exact poses differ by float noise; that must not decide the answer. + + The right-hand screenshot point, both ways round: 84 degrees of stage travel + against 191, and the shorter one carries the marginally *larger* residual. + """ + short = Branch( + yaw_deg=36.420, pitch_deg=-47.692, residual_deg=1e-9, upright=True, in_range=True + ) + long_way = Branch( + yaw_deg=-143.580, pitch_deg=47.692, residual_deg=0.0, upright=True, in_range=True + ) + assert PlatformStack.select_branch([long_way, short]) is short + assert PlatformStack.branch_reason([long_way, short], short) == "smallest" + + +def test_a_materially_better_alignment_still_wins(): + """Equivalent is a tolerance, not an excuse to ignore the residual.""" + coarse = Branch(yaw_deg=0.0, pitch_deg=0.0, residual_deg=3.0, upright=True, in_range=True) + exact = Branch(yaw_deg=80.0, pitch_deg=40.0, residual_deg=0.05, upright=True, in_range=True) + assert PlatformStack.select_branch([coarse, exact]) is exact + assert PlatformStack.branch_reason([coarse, exact], exact) == "alignment" + + +def test_real_roundoff_does_not_choose_a_half_turn_over_the_shorter_pose(): + stack = PlatformStack(pitch_limit_deg=90) + normal = np.array([-0.499590704094966, 0.6734102426572587, -0.5449107940444822]) + unit = stack.setup_normal(normal) + branches = stack.alignment_branches(unit, stack.tool_direction) + short = min(branches, key=lambda branch: branch.travel_deg) + for branch in branches: + turned = stack.platform_rotation(branch.yaw_deg, branch.pitch_deg) @ unit + np.testing.assert_allclose(turned, [0, 0, 1], atol=1e-12) + assert stack.select_branch(branches) is short + + +def test_the_branch_keys_are_applied_in_the_order_the_machine_cares_about(): + """The envelope, then which way the body ends up, then the shorter turn.""" + inside = Branch( + yaw_deg=-61.319, pitch_deg=45.636, residual_deg=0.0, upright=True, in_range=True + ) + outside = Branch( + yaw_deg=0.0, pitch_deg=0.0, residual_deg=0.0, upright=True, in_range=False + ) + assert PlatformStack.select_branch([inside, outside]) is inside + assert PlatformStack.branch_reason([inside, outside], inside) == "in_range" + + upright = Branch( + yaw_deg=118.681, pitch_deg=-45.636, residual_deg=0.0, upright=True, in_range=True + ) + rolled = Branch( + yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=False, in_range=True + ) + assert PlatformStack.select_branch([upright, rolled]) is upright + assert PlatformStack.branch_reason([upright, rolled], upright) == "upright" + + +def test_a_lone_branch_is_its_own_reason_and_an_empty_list_is_refused(): + only = Branch(yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=True, in_range=True) + assert PlatformStack.select_branch([only]) is only + assert PlatformStack.branch_reason([only], only) == "only" + with pytest.raises(ValueError): + PlatformStack.select_branch([]) diff --git a/apps/autocal/tests/test_multipoint.py b/apps/autocal/tests/test_multipoint.py index b46d44e..ab85f6d 100644 --- a/apps/autocal/tests/test_multipoint.py +++ b/apps/autocal/tests/test_multipoint.py @@ -90,8 +90,16 @@ def test_the_next_point_waits_until_the_indenter_is_confirmed_clear(tmp_path): assert all(trial["status"] == "complete" for trial in session["trials"]) assert directory.name.startswith("finger_SN1_") from orisys_autocal.session_review import review_session - with pytest.raises(ValueError, match="finger sessions"): - review_session(directory) + review = review_session(directory) + assert review["source_format"] == "autocal-multipoint-1.0" + assert review["status"] == "exploratory" and review["formal_conclusion"] is False + assert [site["site_id"] for site in review["sites"]] == ["A", "B"] + # This campaign ran without a camera, so every window reports the missing frame + # source instead of looking like a measurement of zero force. + assert {issue["reason"] for issue in review["issues"]} == {"no_frame_source"} + assert all(window["samples"] == 0 and window["raw_mean"] is None + for site in review["sites"] for window in site["windows"]) + assert all(site["fit"] is None and not site["formal_conclusion"] for site in review["sites"]) first = directory / "sites" / "A" / "repeat-001" / "reference.csv" rows = list(csv.DictReader(first.open(encoding="utf-8"))) diff --git a/apps/autocal/tests/test_multipoint_align.py b/apps/autocal/tests/test_multipoint_align.py new file mode 100644 index 0000000..91c2d05 --- /dev/null +++ b/apps/autocal/tests/test_multipoint_align.py @@ -0,0 +1,207 @@ +"""Aligning the normal solves the point again instead of trusting the stored pose. + +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. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from orisys_app_shared.i18n import t +from orisys_autocal.sensor.uv_flat import FlatSurface + +#: The right-hand measured point, as the bench reported it. +POINT = (4.872, 5.444, 16.176) +NORMAL = (0.595, 0.673, 0.439) + + +def _flat(point=POINT, normal=NORMAL) -> FlatSurface: + """A stand-in for the rasterised surface: one valid pixel, one 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)) + mask = np.zeros((size, size), dtype=np.int32) + return FlatSurface( + size_hw=(size, size), + image_rgb=np.zeros((size, size, 3), dtype=np.uint8), + positions_mm=positions, + normals=normals, + face_index=mask, + uv_min=(0.0, 0.0), + uv_max=(1.0, 1.0), + ) + + +def _prefix(key: str) -> str: + """The part of a locale message before its first placeholder.""" + return t(key).split("{")[0] + + +def _add_site(panel, 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( + site_id, + float(point[0]), + float(point[1]), + float(point[2]), + tuple(float(value) for value in normal), + { + "x": float(solved.table_xy_mm[0]), + "y": float(solved.table_xy_mm[1]), + "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.in_range), + "ok": bool(solved.ok), + "normal_aligned_to": f"tool_axis:{stack.tool_axis}", + }, + [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() + emitted: list[dict] = [] + panel.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) + + panel._align() # the panel is the unit under test + assert emitted, panel.message.text() + assert emitted[-1]["z"] == pytest.approx(stored.contact_z_mm) + + # 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. + 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 + assert fresh["z"] == pytest.approx(emitted[-1]["z"]) + assert fresh["z"] != pytest.approx(stored.contact_z_mm) + + +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)) + # 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) + + panel._align() # the panel is the unit under test + assert not emitted + assert _prefix("multipoint.point_moved") in panel.message.text() + + +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)) + 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) + + panel._align() # the panel is the unit under test + assert not emitted + assert _prefix("multipoint.normal_moved") in panel.message.text() + + +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) + + panel._align() # the panel is the unit under test + assert not emitted + assert _prefix("multipoint.point_unmapped") in panel.message.text() + + +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) + + # 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 + assert not emitted + assert _prefix("multipoint.pose_refused") in panel.message.text() + + +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( + tmp_path, + sensor_model="finger", + sensor_sn="SN1", + coordinate_frame="sensor", + max_force_n=20.0, + simulate=True, + ) + + panel._align() # the panel is the unit under test + assert not emitted + assert t("multipoint.align_busy") in panel.message.text() + + +def test_next_site_can_be_aligned_between_trials(make_window, tmp_path): + 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() + panel._align() + assert len(emitted) == 1 + panel._selected = "P1" + panel._align() + assert len(emitted) == 1 + diff --git a/apps/autocal/tests/test_pose_move.py b/apps/autocal/tests/test_pose_move.py index 7dfc458..e5b0fa9 100644 --- a/apps/autocal/tests/test_pose_move.py +++ b/apps/autocal/tests/test_pose_move.py @@ -32,8 +32,10 @@ from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus from orisys_autocal.motion.pose_move import ( MOVE_ORDER, PoseMover, + PressAxis, SETTLE_DEG, SETTLE_MM, + press_targets, ) from orisys_autocal.motion.transport import DemoTransport from orisys_autocal.preferences import default_preferences @@ -465,3 +467,62 @@ def test_the_units_round_trip_through_the_service(): from orisys_autocal.motion import user_units_to_deg assert user_units_to_deg(axis, units) == pytest.approx(value) + + +# ------------------------------------------------------------------ press axis + + +def test_an_uncalibrated_press_axis_is_held_rather_than_commanded(): + """A geometric contact height is not a controller coordinate. + + ``solve`` reports where the point ends up; what the press axis *reads* there + is a separate bench measurement. Until someone has made it, commanding the + geometric number would drive the press to a height that means nothing. + """ + targets, held = press_targets({"x": 1.0, "y": 2.0, "z": 19.051}, PressAxis()) + assert held == ("z",) + assert "z" not in targets + assert targets == {"x": 1.0, "y": 2.0} + + +def test_a_calibrated_press_axis_turns_the_contact_height_into_its_coordinate(): + """The relation has both a zero and a direction, and both come from the bench.""" + rising = PressAxis(zero_mm=12.5, rising=True, calibrated=True) + falling = PressAxis(zero_mm=12.5, rising=False, calibrated=True) + assert rising.target_for(19.051) == pytest.approx(31.551) + assert falling.target_for(19.051) == pytest.approx(-6.551) + targets, held = press_targets({"x": 1.0, "z": 19.051}, rising) + assert held == () + assert targets["z"] == pytest.approx(31.551) + + +def test_planning_the_press_axis_does_not_touch_the_caller_s_mapping(): + """The panel hands over its solved targets; holding z must not edit them.""" + targets = {"x": 1.0, "z": 3.0} + press_targets(targets, PressAxis()) + assert targets == {"x": 1.0, "z": 3.0} + + +def test_a_held_press_axis_still_plans_the_other_roles(): + """Held is not refused: the four axes that are justified still move.""" + service = make_service() + plan = plan_pose_move(service, {"x": 1.0, "y": 2.0}, hold=("z",)) + assert [step.role for step in plan.steps] == ["x", "y"] + assert plan.held == ("z",) + + +@pytest.mark.parametrize("settings", [ + {"zero_mm": 12.5, "rising": True, "calibrated": "false"}, + {"zero_mm": float("nan"), "rising": True, "calibrated": True}, + {"zero_mm": 12.5, "rising": "false", "calibrated": True}, + {"zero_mm": True, "rising": True, "calibrated": True}, + {"calibrated": True}, +]) +def test_invalid_press_settings_keep_the_axis_held(settings): + press = PressAxis.from_settings(settings) + assert press.target_for(19.051) is None + + +def test_valid_press_settings_preserve_the_measured_direction(): + press = PressAxis.from_settings({"zero_mm": 12.5, "rising": False, "calibrated": True}) + assert press.target_for(19.051) == pytest.approx(-6.551) diff --git a/apps/autocal/tests/test_session_review_multipoint.py b/apps/autocal/tests/test_session_review_multipoint.py new file mode 100644 index 0000000..0a6e716 --- /dev/null +++ b/apps/autocal/tests/test_session_review_multipoint.py @@ -0,0 +1,298 @@ +"""Multi-point review: declared hold windows, one frame source per repeat, no invention.""" + +from __future__ import annotations + +import csv +import json +from pathlib import Path + +import pytest + +from orisys_autocal.session_review import review_session, save_review + +REFERENCE_HEADER = ( + "step_index", "branch", "target_load_n", "t_start_host_mono_ns", "t_end_host_mono_ns", + "F_ref_normal_N", "site_id", "x", "y", "z", "coordinate_frame", "repeat_index", + "repeat_count", "hold_complete", "reference_source", "fx_N", "fy_N", "fz_N", + "force_to_tool_rotation", "nx", "ny", "nz", "pose_x_mm", "pose_y_mm", "pose_yaw_deg", + "pose_pitch_deg", "pose_z_mm", "normal_aligned_to", +) +FRAMES_HEADER = ( + "frame_id", "source_frame_id", "t_rel_s", "t_host_mono_ns", "t_host_utc_ns", "acquired_at", + "time_basis", "media_time_ms", "segment", "segment_frame", "in_video", "dropped", + "capture_dropped", +) +FORCES_HEADER = ("session_id", "frame_id", "t_rel_s", "FN_raw", "valid") + +#: The campaign's own step order, and a raw signal linear in the declared force so the +#: reviewed fit is checkable by hand: 0 N -> 100 raw, 5 N -> 300 raw, 10 N -> 500 raw. +STEPS = (("baseline", 0.0), ("load", 5.0), ("load", 10.0), ("unload", 5.0), ("unload", 0.0)) +RAW_BASE, RAW_PER_N = 100.0, 40.0 +SAMPLES_PER_HOLD = 3 +CAPTURE_SESSION_ID = 42 +BRANCHES = ["zero", "load", "load", "unload", "unload"] + + +def _write(path: Path, header, rows) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(header)) + writer.writeheader() + writer.writerows(rows) + + +def _trial(root: Path, site_id: str, repeat: int, *, status: str = "complete", + capture: bool = True, basis: str = "host_camera") -> str: + directory = f"sites/{site_id}/repeat-{repeat:03d}" + trial = root / directory + trial.mkdir(parents=True, exist_ok=True) + (trial / "session.json").write_text(json.dumps({ + "trial_id": f"{site_id}-{repeat}", "site_id": site_id, "repeat_index": repeat, + "status": status, "coordinate_frame": "sensor", + "steps": [{"step_index": index, "branch": branch, "target_load_n": load} + for index, (branch, load) in enumerate(STEPS)], + }), encoding="utf-8") + rows, frames, forces = [], [], [] + base = 1_000_000_000 + 500_000_000 * (site_id != "P1") + 10_000_000 * repeat + frame_id = 10_000 * repeat + 1_000 * (site_id != "P1") + for step, (branch, load) in enumerate(STEPS): + start = base + step * 100_000_000 + end = start + 50_000_000 + rows.append({ + "step_index": step, "branch": branch, "target_load_n": f"{load}", + "t_start_host_mono_ns": start, "t_end_host_mono_ns": end, + "F_ref_normal_N": f"{load}", "site_id": site_id, "x": 1.0, "y": 2.0, "z": 3.0, + "coordinate_frame": "sensor", "repeat_index": repeat, "repeat_count": 2, + "hold_complete": 1, "reference_source": "declared", + }) + for index in range(SAMPLES_PER_HOLD): + frame_id += 1 + frames.append({"frame_id": frame_id, "t_rel_s": 0.0, "segment": 1, + "segment_frame": frame_id, "in_video": 1, "dropped": 0, + "capture_dropped": 0, "time_basis": basis, + "t_host_mono_ns": start + 5_000_000 + index * 15_000_000}) + forces.append({"session_id": CAPTURE_SESSION_ID, "frame_id": frame_id, "t_rel_s": 0.0, + "FN_raw": RAW_BASE + RAW_PER_N * load, "valid": 1}) + _write(trial / "reference.csv", REFERENCE_HEADER, rows) + _write(trial / "events.csv", ("t_host_mono_ns", "kind", "detail"), []) + if capture: + (trial / "capture").mkdir() + (trial / "capture" / "raw_0001.mp4").write_bytes(b"recorded segment fixture") + _write(trial / "capture" / "frames.csv", FRAMES_HEADER, frames) + _write(trial / "capture" / "forces.csv", FORCES_HEADER, forces) + (trial / "capture" / "session.json").write_text(json.dumps({ + "session_id": CAPTURE_SESSION_ID, "metadata": {"images_only": True}, + }), encoding="utf-8") + return directory + + +def _session(root: Path, *, repeats: int = 2, sites=("P1", "P2"), trial_status: str = "complete", + capture: bool = True, basis: str = "host_camera", status: str = "complete") -> Path: + """One multi-point session with the pieces each test varies.""" + trials, completion = [], [] + for site_id in sites: + for repeat in range(1, repeats + 1): + directory = _trial(root, site_id, repeat, status=trial_status, capture=capture, basis=basis) + trials.append({"trial_id": f"{site_id}-{repeat}", "site_id": site_id, + "repeat_index": repeat, "directory": directory, "status": trial_status, + "reason": ""}) + completion.append({"site_id": site_id, "repeats_planned": repeats, + "repeats_complete": repeats if trial_status == "complete" else 0, + "complete": trial_status == "complete"}) + manifest = { + "format_version": "autocal-multipoint-1.0", "session_id": "mp-test", + "sensor_model": "finger", "sensor_sn": "SN1", "status": status, + "started_at": "2026-10-15T00:00:00+00:00", "coordinate_frame": "sensor", + "platform_motion": False, "automatic_force_control": False, + "min_force_n": 0.0, "max_force_n": 10.0, + "sync": {"method": "host_monotonic_time_overlap"}, + "sites": [{"site_id": site_id, "position": {"x": 1.0, "y": 2.0, "z": 3.0}, + "coordinate_frame": "sensor", "position_frame": "sensor", "normal_sensor": None, + "pose": None, "uv_px": {"u": 10 if site_id == "P1" else 30, "v": 20}, + "levels_n": [5.0, 10.0], "hold_s": 1.0, + "repeats": repeats} for site_id in sites], + "trials": trials, "completion": completion, + } + root.mkdir(parents=True, exist_ok=True) + (root / "session.json").write_text(json.dumps(manifest), encoding="utf-8") + return root + + +def test_two_points_with_two_repeats_review_into_windows_curves_and_a_fit(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234") + + review = review_session(root) + + assert review["source_format"] == "autocal-multipoint-1.0" + assert review["session_status"] == "complete" and review["status"] == "exploratory" + assert review["formal_conclusion"] is False and review["issues"] == [] + assert [site["site_id"] for site in review["sites"]] == ["P1", "P2"] + first = review["sites"][0] + assert first["uv_xy_px"] == [10, 20] and first["position_sensor_mm"] == [1.0, 2.0, 3.0] + assert len(first["windows"]) == 2 * len(STEPS) + assert all(window["status"] == "candidate" for window in first["windows"]) + assert all(window["samples"] == SAMPLES_PER_HOLD for window in first["windows"]) + # The declared steps are the loading and unloading curve, in recorded order. + assert [window["branch"] for window in first["curve"]] == BRANCHES * 2 + assert [window["recorded_branch"] for window in first["curve"]][:5] == [s[0] for s in STEPS] + assert first["fit"]["sensitivity_raw_per_n"] == pytest.approx(RAW_PER_N) + assert first["fit"]["windows"] == 6 + assert first["coverage"] == {"repeats_planned": 2, "repeats_complete": 2, + "trials": 2, "usable_trials": 2} + assert {trial["trial_id"] for trial in review["coverage"]} == {"P1-1", "P1-2", "P2-1", "P2-2"} + assert review["coverage"][0]["frames"] == len(STEPS) * SAMPLES_PER_HOLD + assert review["coverage"][0]["segments"] == [1] and review["coverage"][0]["usable"] is True + # Nothing was confirmed window by window, so the review stays exploratory. + assert first["formal_conclusion"] is False and "unverified_window:0" in first["blockers"] + + +def test_a_repeat_without_a_frame_source_reports_the_gap_not_a_zero_reading(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234", capture=False) + + review = review_session(root) + + assert {issue["reason"] for issue in review["issues"]} == {"no_frame_source"} + first = review["sites"][0] + assert all(window["status"] == "no_frame_source" for window in first["windows"]) + assert all(window["samples"] == 0 and window["raw_mean"] is None for window in first["windows"]) + assert first["fit"] is None and first["coverage"]["usable_trials"] == 0 + assert {"no_fit", "no_frame_source"} <= set(first["blockers"]) + assert all(trial["frame_source"] is None and trial["usable"] is False for trial in review["coverage"]) + + +def test_a_replayed_time_basis_is_reported_instead_of_being_joined(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234", basis="host_replay") + + review = review_session(root) + + assert {issue["reason"] for issue in review["issues"]} == {"time_basis_not_comparable"} + assert review["coverage"][0]["time_bases"] == ["host_replay"] + assert all(window["status"] == "time_basis_not_comparable" and window["samples"] == 0 + for window in review["sites"][0]["windows"]) + + +def test_an_unfinished_repeat_cannot_produce_a_conclusion(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234", + trial_status="incomplete", status="incomplete") + + review = review_session(root) + + assert review["session_status"] == "incomplete" and review["formal_conclusion"] is False + assert {issue["reason"] for issue in review["issues"]} == {"incomplete_trial"} + first = review["sites"][0] + assert first["formal_conclusion"] is False and "incomplete_trial" in first["blockers"] + assert first["coverage"]["repeats_complete"] == 0 + assert first["fit"] is None and first["confirmed_fit"] is None + assert first["coverage"]["usable_trials"] == 0 + assert all(trial["status"] == "incomplete" for trial in review["coverage"]) + + +def test_missing_video_cannot_supply_candidate_windows(tmp_path): + root = _session(tmp_path / "session") + for video in root.glob("sites/*/repeat-*/capture/raw_0001.mp4"): + video.unlink() + review = review_session(root) + assert all(site["fit"] is None for site in review["sites"]) + assert all(not trial["usable"] for trial in review["coverage"]) + + +def test_missing_geometry_is_not_reported_as_a_zero_coordinate(tmp_path): + root = _session(tmp_path / "session") + path = root / "session.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + del manifest["sites"][0]["position"]["z"] + path.write_text(json.dumps(manifest), encoding="utf-8") + review = review_session(root) + assert review["sites"][0]["position_sensor_mm"] is None + + +def test_multipoint_overview_reads_capture_trace_and_shows_both_sites(qt_app, tmp_path): + from orisys_autocal.ui.record_overview_dialog import RecordOverviewDialog + + root = _session(tmp_path / "session") + dialog = RecordOverviewDialog(root) + try: + assert dialog.table.rowCount() == 2 + report = dialog.diagnostics["P1-1"] + assert len(report["trace"]) == len(STEPS) * SAMPLES_PER_HOLD + assert not report["errors"] + assert any(series.count() > 0 for series in dialog.timeline_chart.series()) + finally: + dialog.close() + dialog.deleteLater() + + +def test_simulation_with_recorded_samples_cannot_supply_a_fit(tmp_path): + root = _session(tmp_path / "session") + path = root / "session.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["simulate"] = True + path.write_text(json.dumps(manifest), encoding="utf-8") + review = review_session(root) + assert all(site["fit"] is None for site in review["sites"]) + assert all(not trial["usable"] for trial in review["coverage"]) + + +def test_trial_metadata_cannot_override_incomplete_status(tmp_path): + root = _session(tmp_path / "session") + for path in root.glob("sites/*/repeat-*/session.json"): + metadata = json.loads(path.read_text(encoding="utf-8")) + metadata["status"] = "incomplete" + path.write_text(json.dumps(metadata), encoding="utf-8") + review = review_session(root) + assert all(site["fit"] is None for site in review["sites"]) + assert all(not trial["usable"] for trial in review["coverage"]) + + +def test_reference_above_declared_session_range_is_refused(tmp_path): + root = _session(tmp_path / "session") + path = root / "sites/P1/repeat-001/reference.csv" + rows = list(csv.DictReader(path.open(encoding="utf-8"))) + rows[1]["F_ref_normal_N"] = "11" + _write(path, REFERENCE_HEADER, rows) + with pytest.raises(ValueError, match="invalid reference window"): + review_session(root) + + +def test_a_reference_window_that_does_not_hold_is_refused(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234") + reference = root / "sites" / "P1" / "repeat-001" / "reference.csv" + rows = list(csv.DictReader(reference.open(encoding="utf-8"))) + rows[1]["t_end_host_mono_ns"] = rows[1]["t_start_host_mono_ns"] + _write(reference, REFERENCE_HEADER, rows) + + with pytest.raises(ValueError, match="invalid reference window"): + review_session(root) + + +def test_a_confirmed_selection_narrows_the_window_and_saves_a_derived_copy(tmp_path): + root = _session(tmp_path / "finger_SN1_20261015T000000Z_abcd1234") + before = (root / "session.json").read_bytes() + rows = list(csv.DictReader( + (root / "sites" / "P1" / "repeat-001" / "reference.csv").open(encoding="utf-8"))) + start = int(rows[1]["t_start_host_mono_ns"]) + # Three frames sit at +5, +20 and +35 ms inside every hold; confirming the first two + # must narrow the window without touching the recorded file. + selections = {"P1-1:1": {"t_start_host_mono_ns": start + 4_000_000, + "t_end_host_mono_ns": start + 21_000_000, + "stability_verified": True}} + + review = review_session(root, selections=selections) + + chosen = review["sites"][0]["windows"][1] + assert chosen["stability_verified"] is True and chosen["samples"] == 2 + assert chosen["hold_start_host_mono_ns"] == start + assert chosen["raw_mean"] == pytest.approx(RAW_BASE + RAW_PER_N * 5.0) + assert review["sites"][0]["quality"]["basis"] == "confirmed_window_means" + # One confirmed window is a confirmed mean, not a fit; the exploratory fit still uses + # every candidate window the repeats provide. + assert review["sites"][0]["confirmed_fit"] is None + assert review["sites"][0]["fit"]["windows"] == 6 + saved = json.loads(save_review(review).read_text(encoding="utf-8")) + assert saved["source_format"] == "autocal-multipoint-1.0" and saved["baselines"] == [] + assert (root / "session.json").read_bytes() == before + assert (root / "sites" / "P1" / "repeat-001" / "reference.csv").is_file() + with pytest.raises(ValueError, match="outside recorded hold"): + review_session(root, selections={"P1-1:1": {"t_start_host_mono_ns": 0, + "t_end_host_mono_ns": 1, + "stability_verified": True}}) diff --git a/apps/autocal/tools/panel_check.py b/apps/autocal/tools/panel_check.py index 5041b48..88eea3b 100644 --- a/apps/autocal/tools/panel_check.py +++ b/apps/autocal/tools/panel_check.py @@ -100,8 +100,26 @@ def main() -> int: ) # ---- locale completeness --------------------------------------------- + from orisys_autocal import preferences as prefs_mod from orisys_autocal.preferences import load_preferences + # Redirect the preference store into the app's own (gitignored) outputs before + # any window exists. This check opens and closes real windows, and closing one + # writes the whole in-memory document back: a check that dials in a press-axis + # zero would otherwise leave that fabricated measurement on the operator's + # machine, and every later run would start from it. + from orisys_app_shared import paths as paths_mod + from orisys_app_shared import prefs as prefs_pkg + + scratch = APP_ROOT / "outputs" / "panel_check" + scratch.mkdir(parents=True, exist_ok=True) + scratch_prefs = scratch / "preference.json" + if scratch_prefs.exists(): + scratch_prefs.unlink() + paths_mod.preference_path = lambda: scratch_prefs + prefs_pkg.store.preference_path = lambda: scratch_prefs + prefs_mod.preference_path = lambda: scratch_prefs + prefs = load_preferences() for language in ("zh_CN", "en_US"): set_locale(language) @@ -162,9 +180,12 @@ def main() -> int: print(f" {width}x{height}: the device setup fits") # ---- the permanent shell ---------------------------------------- + # The workspace is five pages: three run screens, the sensor page and the + # calibration-record review. (Multi-point replaced the old surface/points + # pair, so this list is the one workspace.TABS declares.) checker.that( window.workspace.page_keys() - == ["axes", "sequence", "finger", "sensor", "surface", "points"], + == ["axes", "sequence", "finger", "sensor", "records"], f"unexpected tab set: {window.workspace.page_keys()}", ) checker.that( @@ -295,16 +316,25 @@ def main() -> int: window.axis_readout.return_button.parent() is window.axis_readout, "the return button is not in the always-visible rail", ) - # The mapping page is the third tab of the device setup, and the service - # already holds the mapping it shows. + # The mapping page is the third tab of the device setup, the press-axis + # relation the fourth, and the service already holds the mapping shown. checker.that( - window.device_setup.tabs.count() == 3, - f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3", + window.device_setup.tabs.count() == 4, + f"the device setup has {window.device_setup.tabs.count()} tabs, expected 4", ) checker.that( window.device_setup.tabs.tabText(2) == t("setup.tab_axes"), f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}", ) + checker.that( + window.device_setup.tabs.tabText(3) == t("setup.tab_press"), + f"the fourth setup tab is {window.device_setup.tabs.tabText(3)!r}", + ) + checker.that( + window.device_setup.press_page.press()["calibrated"] is False, + f"the press page starts calibrated: " + f"{window.device_setup.press_page.press()}", + ) # With no controller, a move must be refused with a reason and the execute # button must say so rather than appearing to work. disconnected = window.poller.service.check_axes_move({"x": 1.0}) @@ -537,6 +567,24 @@ def main() -> int: "the finger screen solved no pose from its point list", ) if solve is not None: + # The shipped machine has no measured press-axis zero, so the press + # role must come back held: a geometric contact height is not a + # controller coordinate until somebody has measured the relation. + held_targets, held_roles = solve + checker.that( + "z" not in held_targets and held_roles == ("z",), + f"an uncalibrated press axis was commanded anyway: " + f"{sorted(held_targets)} held={held_roles}", + ) + print(" press: an uncalibrated press axis is held, not commanded") + # Entering the bench's zero — a dialled-in number, as the operator + # would — turns the same height into a coordinate and the move into + # five axes. + page = window.device_setup.press_page + page.zero_spin.setValue(2.0) + page.calibrated_check.setChecked(True) + app.processEvents() + solve = finger.solved_pose() targets, held = solve checker.that( "z" in targets and not held, @@ -668,7 +716,7 @@ def main() -> int: try: window.poller.shutdown() - except Exception: # noqa: BLE001 - a shutdown must not mask the result + except Exception: # noqa: BLE001,S110 - a shutdown must not mask the result pass window.close() app.processEvents()