Compare commits

...

4 Commits

Author SHA1 Message Date
ykcheungab ad049d12e4 修复 Autocal 压头配置、姿态保存与复核性能
阻止不完整标定配置生成 Z 目标,同步当前测点姿态,并将复核文件校验移到后台。通过 476 项全量测试、53 项最终定向复验和代码检查。
2026-10-15 07:51:39 +08:00
ykcheungab bcc55a3b97 修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。

离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
2026-10-15 06:52:28 +08:00
ykcheungab 5339624ffa 把存储的 yaw 上限归一到 ±180
yaw 台转 ±180,实测点的第二组精确解落在 118° 之外:旧文件里的 ±90 会让求解器把位姿算成可达、运动守卫却拒绝这段 yaw,操作员看到的就是“到不了”。加载时把求解器与守卫两种写法的 yaw 上限一起归一,pitch 包络此前已按同一理由处理。
2026-10-14 20:27:21 +08:00
ykcheungab 3f578c26c4 制定 Autocal 运动与多点标定修正计划
明确姿态选解、接触高度、旧姿态和多点分析的任务与验收证据。文档门禁通过,方案尚未实施。
2026-10-14 20:08:36 +08:00
39 changed files with 2752 additions and 225 deletions
+26 -1
View File
@@ -43,6 +43,13 @@
"role_pitch": "pitch (tilt)", "role_pitch": "pitch (tilt)",
"role_axis": "Drive", "role_axis": "Drive",
"axes_duplicate": "One drive is claimed by more than one direction: {detail}", "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", "close": "Close",
"chip_hint": "Open device setup — {name}" "chip_hint": "Open device setup — {name}"
}, },
@@ -56,7 +63,7 @@
"confirm_title": "Confirm motion", "confirm_title": "Confirm motion",
"confirm_execute": "Will move these axes together:\n\n{steps}\n\nCheck this against the machine before continuing.", "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}", "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", "cancelled": "Cancelled; nothing was commanded",
"confirm_check": "Confirm before moving", "confirm_check": "Confirm before moving",
"confirm_hint": "Unchecked, Execute and Return to zero are sent directly. Stop is never affected.", "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_map": "Load a sensor bundle so Select point shows the surface.",
"no_solver": "The pose solver is not connected.", "no_solver": "The pose solver is not connected.",
"align_select": "Select the destination point in the table, then click Align normal.", "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}", "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": "The finger page is not connected.",
"no_finger_points": "The finger page has no picked points.", "no_finger_points": "The finger page has no picked points.",
"branch_baseline": "baseline", "branch_baseline": "baseline",
@@ -401,9 +412,20 @@
"row_sensor_normal": "Surface normal", "row_sensor_normal": "Surface normal",
"row_platform_point": "Platform point (mm)", "row_platform_point": "Platform point (mm)",
"row_pose": "Pose X / Y / yaw / pitch", "row_pose": "Pose X / Y / yaw / pitch",
"row_branch": "Chosen pose and why",
"row_table_move": "Table move X / Y", "row_table_move": "Table move X / Y",
"row_press": "Press-axis target",
"row_residual": "Alignment residual", "row_residual": "Alignment residual",
"row_frames": "Frame mapping", "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_xy": "Tool X / Y",
"tool_hint": "Where the tool is in the table frame. Each point's table move is measured from this position.", "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", "sensor_xy": "Sensor X / Y",
@@ -662,6 +684,7 @@
"col_range_status": "Range/status" "col_range_status": "Range/status"
}, },
"review": { "review": {
"checking": "Reading and verifying recordings in the background…",
"load_selection": "Load saved analysis…", "load_selection": "Load saved analysis…",
"confirm_stable": "This interval was checked for stability", "confirm_stable": "This interval was checked for stability",
"apply_window": "Confirm interval and recompute", "apply_window": "Confirm interval and recompute",
@@ -681,6 +704,8 @@
"no_sites": "No sites in this session", "no_sites": "No sites in this session",
"candidate": "Candidate window", "candidate": "Candidate window",
"insufficient_samples": "Insufficient valid samples", "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", "replay": "Replay debug",
"unverified": "Stability unverified", "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)", "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)",
+26 -1
View File
@@ -43,6 +43,13 @@
"role_pitch": "pitch(俯仰)", "role_pitch": "pitch(俯仰)",
"role_axis": "驱动器", "role_axis": "驱动器",
"axes_duplicate": "同一驱动器被分配给多个方向:{detail}", "axes_duplicate": "同一驱动器被分配给多个方向:{detail}",
"tab_press": "压轴零位",
"press_hint": "解算给出的是接触点在台面原点平面之上的几何高度(mm);驱动器收到的是它自己的坐标,从它自己的机械零位算起。两者只差一个实机测量值。没有标定之前,执行动作会保持压轴不动,而不会把几何高度当成控制器目标直接下发。",
"press_zero": "零点(压头触到台面原点平面时的读数)",
"press_rising": "坐标增大时压头上升",
"press_calibrated": "已在本机测量并确认",
"press_set": "已标定:压轴目标 = 零点 ± 接触高度。",
"press_unset": "未标定:执行动作会保持压轴不动。",
"close": "关闭", "close": "关闭",
"chip_hint": "点击打开设备设置 — {name}" "chip_hint": "点击打开设备设置 — {name}"
}, },
@@ -56,7 +63,7 @@
"confirm_title": "确认运动", "confirm_title": "确认运动",
"confirm_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。", "confirm_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。",
"confirm_return": "将同时移回零点:\n\n{steps}\n\n请确认机床上没有干涉。", "confirm_return": "将同时移回零点:\n\n{steps}\n\n请确认机床上没有干涉。",
"hold_note": "不动作:{roles}(压轴由 P7 的接触检测与压深另外管)", "hold_note": "不动作:{roles}(这些轴保持现状,不参与本次动作)",
"cancelled": "已取消,未发出任何运动", "cancelled": "已取消,未发出任何运动",
"confirm_check": "移动前确认", "confirm_check": "移动前确认",
"confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。", "confirm_hint": "取消勾选后,执行动作与回初始位将直接发出(停止不受影响)。",
@@ -359,7 +366,11 @@
"no_map": "请先载入传感器导出包,让「选择测点」出现曲面。", "no_map": "请先载入传感器导出包,让「选择测点」出现曲面。",
"no_solver": "姿态求解还没有接上。", "no_solver": "姿态求解还没有接上。",
"align_select": "请先在左边表里点中要去的测点,再点「对齐法向」。", "align_select": "请先在左边表里点中要去的测点,再点「对齐法向」。",
"align_busy": "这一轮还在采集:等它结束再对准其它测点,中途移动平台会让这次记录作废。",
"pose_refused": "这个点的姿态不能执行:{reason}", "pose_refused": "这个点的姿态不能执行:{reason}",
"point_moved": "这个点记录的坐标与当前映射不符(相差 {mm} mm)。请重新在「选择测点」里取这个点并确认,不要用旧记录移动平台。",
"normal_moved": "这个点记录的法向与当前映射不符(相差 {deg}°)。请重新在「选择测点」里取这个点并确认。",
"point_unmapped": "这个点没有可用的来源坐标(既没有 UV 映射,也不是传感器坐标)。请重新在「选择测点」里取这个点。",
"no_finger": "手指调试页面还没有接上。", "no_finger": "手指调试页面还没有接上。",
"no_finger_points": "手指调试里还没有选点。", "no_finger_points": "手指调试里还没有选点。",
"branch_baseline": "空载", "branch_baseline": "空载",
@@ -401,9 +412,20 @@
"row_sensor_normal": "曲面法向", "row_sensor_normal": "曲面法向",
"row_platform_point": "平台坐标 (mm)", "row_platform_point": "平台坐标 (mm)",
"row_pose": "位姿 X / Y / yaw / pitch", "row_pose": "位姿 X / Y / yaw / pitch",
"row_branch": "所选姿态与理由",
"row_table_move": "工作台移动 X / Y", "row_table_move": "工作台移动 X / Y",
"row_press": "压轴目标",
"row_residual": "对齐残差", "row_residual": "对齐残差",
"row_frames": "坐标映射", "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_xy": "刀具 X / Y",
"tool_hint": "刀具在工作台坐标系中的位置。每个测点的工作台移动都相对这个位置计算。", "tool_hint": "刀具在工作台坐标系中的位置。每个测点的工作台移动都相对这个位置计算。",
"sensor_xy": "传感器 X / Y", "sensor_xy": "传感器 X / Y",
@@ -662,6 +684,7 @@
"col_range_status": "范围/状态" "col_range_status": "范围/状态"
}, },
"review": { "review": {
"checking": "正在后台读取并校验记录,请稍候…",
"load_selection": "载入历史分析…", "load_selection": "载入历史分析…",
"confirm_stable": "此时间段已检查稳定", "confirm_stable": "此时间段已检查稳定",
"apply_window": "确认选中时间段并重算", "apply_window": "确认选中时间段并重算",
@@ -681,6 +704,8 @@
"no_sites": "会话没有测点", "no_sites": "会话没有测点",
"candidate": "候选窗口", "candidate": "候选窗口",
"insufficient_samples": "有效样本不足", "insufficient_samples": "有效样本不足",
"no_frame_source": "本次循环没有录像帧来源:参考窗口已记录,但没有可用于计算的原始法向信号",
"time_basis_not_comparable": "时间基准与保持窗口不可比(录像不是本机实时采集),未参与计算",
"replay": "视频调试", "replay": "视频调试",
"unverified": "稳定性未复核", "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(不是独立验证误差)", "fit": "加载分支初步拟合:灵敏度 {sensitivity_raw_per_n:.6g} raw/N;增益 {gain_n_per_raw:.6g} N/raw;偏置 {offset_n:.6g} N;拟合 RMSE {rmse_n:.4g} N(不是独立验证误差)",
+7 -1
View File
@@ -24,14 +24,20 @@
"max_travel_mm": 100.0, "max_travel_mm": 100.0,
"max_descent_mm": 10.0, "max_descent_mm": 10.0,
"max_press_depth_mm": 1.0, "max_press_depth_mm": 1.0,
"max_yaw_deg": 180.0,
"max_pitch_deg": 90.0,
"follow_error_mm": 0.5, "follow_error_mm": 0.5,
"contact_search_mm": 3.0 "contact_search_mm": 3.0
}, },
"press_axis": {
"zero_mm": 0.0,
"rising": true,
"calibrated": false
},
"sensor_setup": { "sensor_setup": {
"sensor_type": "box", "sensor_type": "box",
"config_ref": "", "config_ref": "",
"geometry_id": "", "geometry_id": "",
"press_axis": 2,
"probe_tip_offset_mm": [0.0, 0.0, 0.0], "probe_tip_offset_mm": [0.0, 0.0, 0.0],
"probe_tip_set": false, "probe_tip_set": false,
"sensor_to_platform": [], "sensor_to_platform": [],
@@ -96,6 +96,10 @@ finger_F001_<时间>_<标识>/
参考窗口采用起点包含、终点不包含的时间范围。换档没有恒定力标签,视频和算法输出仍保存。`trial_finished` 表示请求正常结束;最终是否 complete 以排空后的 manifest 为准。保存失败不能当作成功;重试不覆盖原文件。 参考窗口采用起点包含、终点不包含的时间范围。换档没有恒定力标签,视频和算法输出仍保存。`trial_finished` 表示请求正常结束;最终是否 complete 以排空后的 manifest 为准。保存失败不能当作成功;重试不覆盖原文件。
### 多点会话格式
多点标定按测点组织(`sites/<site_id>/repeat-00N/`),复核输入见[多点标定会话格式](multipoint-data-format.zh.md)。
## 软件数据流及后续 ## 软件数据流及后续
| 输入 → 处理 → 输出 | 所有者 | | 输入 → 处理 → 输出 | 所有者 |
+12 -6
View File
@@ -53,7 +53,7 @@ With the corrected envelope, solved by closed form
| Envelope | Worst residual | Within 0.5 deg | In range | | 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 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 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 move, a better-conditioned contact — is a separate bench question, not a
kinematic one. kinematic one.
One thing the corrected solve did surface: **51 of 1032 vertices end inverted**, **Correction (motion-correction-plan.zh.md task 1).** This note used to claim that
whichever closed-form branch is chosen, because the tool direction is **51 of 1032 vertices end inverted** whichever branch is chosen, because the tool
antiparallel to the sensor's own up axis. That is the tool-polarity question, it direction was antiparallel to the sensor's own up axis. That count came from
needs the bench, and it is *not* visible in the residual. See `uprightness` comparing a platform axis through `to_sensor(np.eye(3)[1])`, which is
`inverse-kinematics.md` → *Still open*. 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 ## What was wrong before, and why it is worth recording
+1 -1
View File
@@ -67,7 +67,7 @@ Three buttons, and where each one lives is deliberate:
| Button | Where | What it does | | 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. | | **停止** 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. | | **回初始位** 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. |
+79 -40
View File
@@ -17,7 +17,7 @@ the contact point ends up, and Z is reported, never solved.
|---|---|---| |---|---|---|
| Tool axis | `z` | `motion/frames.py` | | Tool axis | `z` | `motion/frames.py` |
| Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` | | 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` | | Stage order | `R_pitch @ R_yaw` | `stage_rotation` |
| Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` | | Mount map | sensor `x→x, y→z, z→-y` | `AXIS_MAP` |
| Aim direction | `(0, 0, +1)` in the platform frame | `approach_sign = +1` | | 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`. 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 `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 turn the sensor over. Nothing caught it, because the residual is measured against
(`arccos` maps a face turned fully over to `0.0000°`, the same as perfectly the aim vector itself: `acos(turned · tool_direction)` reports `0.0000°` for a face
aligned) and because every test asserted only that the residual was *small*. The 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. failure was visible **only** as the model flipping upside down.
Measured over the export, counting vertices whose face ends up pointing away from 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`) | | `approach_sign` | Face ends inverted | Nominal pose (`yaw = pitch = 0`) |
|---|---|---| |---|---|---|
| `-1` (was) | **981 / 1032** | pitch **180°** needed; lands on `(0, 0, -1)` | | `-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 An earlier revision of this document reported 22 vertices not `upright` under `+1`.
aligning them necessarily rolls the body about that normal and the sign is That count came from `uprightness` comparing a **platform Y** direction — a
decided by rounding. They are edge-on, not turned over — the most negative horizontal axis — so a pose that merely rolled the body about the vertical was
`uprightness` among them is a hair past zero, not `-1`. 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 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 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)` 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. 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 All 1032 vertices are **in range**, and all 1032 report `upright` under the
are the rim of the dome, where the normal is 60–90° off the tool axis — see tool-axis reference task 1 introduced. Reachability is the yaw limit, not the rim:
*the face is aimed at +Z* above and *still open* below. see *the face is aimed at +Z* above and *the two exact poses of one normal* below.
### The two pitch bindings are one family ### The two pitch bindings are one family
@@ -154,43 +158,78 @@ everything the machine needs to know:
| Field | Meaning | | Field | Meaning |
|---|---| |---|---|
| `residual_deg` | angle between the rotated normal and the tool direction | | `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 | | `in_range` | whether the pose is inside the stage travel |
| `reason` | why not, when it is not | | `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 `upright` used to be measured against the platform's Y axis, which reports a body
one that aligns perfectly and does not, and the residual alone cannot tell them rolled about the vertical as turned over; it now compares the tool axis with the
apart. Likewise a pose needing more travel than the stage has: `in_range` says so, same axis after the stages turn. A pose that aligns perfectly and turns the sensor
and the solver reports the **best pose the limits do allow** with its real over is a different thing from one that aligns perfectly and does not, and the
residual, rather than an angle the machine cannot reach or a residual it cannot residual alone cannot tell them apart — but the up test can only do that if it uses
achieve. 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**. ### From contact height to a press axis command
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 A geometry height is not a controller coordinate. `motion/pose_move.py::PressAxis`
read as perfect, and it is why this document previously distrusted the solver's holds the measured relation (`zero_mm` is the reading where the tool meets the
"100% within 0.5°". 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 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 missing was an aim the machine could trust plus `upright`, which now travels with
be lost. The lesson is recorded rather than deleted: a scalar that cannot express every pose so the distinction cannot be lost. The lesson is recorded rather than
the failure mode it is used to detect will not detect it. 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 Every solvable normal has two exact poses, and they are a **180° roll about the
are the rim of the dome, where the surface normal is 60–90° from the tool axis. vertical**, not an upright/inverted pair: `R_branch2 = R_z(180°) @ R_branch1`
Aligning an edge-on face necessarily rolls the body about that normal, and which agrees with the measured branches to `2.2e-16`. Both leave the body up (the same
side of zero it lands on is settled by rounding — the most negative uprightness is tool-axis component), both reach the same contact height, and both put the contact
`-0.997` for a vertex whose normal is `90.4°` from the tool axis, i.e. a face being back under the tool — the table XY differs, because the roll moves the point
presented side-on rather than turned over. sideways and the table compensates.
They are not a solver defect: the closed form is exact for all of them So the choice is a machine convention, and the solver states it instead of hiding
(`residual < 1.3e-06°`). Whether they matter is a coverage question — a contact it: among the branches with the best residual (within `1e-6°`), it prefers one that
made on the rim is the least well-conditioned one on the patch anyway — and it is is in range, then upright, then the smaller total travel `|yaw| + |pitch|`, and
worth a look at the bench rather than a change in the maths. `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 The polarity question that used to live here is **settled**: see *the face is aimed
at +Z* above. 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)] analytical = [stack.solve(p, n, method="analytical") for p, n in zip(points, normals)]
residuals = np.array([pose.residual_deg for pose in analytical]) residuals = np.array([pose.residual_deg for pose in analytical])
print(residuals.max()) # ~1.2e-06 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 print(all(pose.in_range for pose in analytical)) # True
``` ```
@@ -0,0 +1,138 @@
# Autocal 运动与多点标定修正计划
状态:任务 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)为准。
## 已知事实与待确认项
用户确认 yaw 台承载传感器,左侧主要表现为 pitch 方向不符合预期。当前链 `R_pitch @ R_yaw` 与这一叠放关系相符,不能据此交换矩阵顺序。执行层没有按测点左右反转 pitch,反号来自求解器选取不同的逆解分支。
截图显示值经舍入;以下为按三位小数法向重新归一化后的离线结果,不是实机测量:
| 测点 | sensor XYZ(mm) | sensor 法向 | 当前选解 yaw/pitch | 另一等价解 yaw/pitch |
|---|---|---|---|---|
| 右 | 4.872, 5.444, 16.176 | 0.595, 0.673, 0.439 | +36.42° / −47.69° | −143.58° / +47.69° |
| 左 | −2.698, 5.186, 18.228 | −0.343, 0.699, 0.627 | −61.32° / +45.64° | +118.68° / −45.64° |
两组分支都能在现有模型中对准法向。只反转左点 pitch 会留下约 91° 误差。修正朝上判断也不保证默认选解变成负 pitch:必须另行确认平台允许的姿态分支。
| 已确认缺陷 | 位置与后果 |
|---|---|
| 朝上参考错误 | `motion/kinematics.py::uprightness` 用平台 Y 判断朝上,错误标记候选姿态 |
| 接触高度错误 | `PlatformStack.solve` 计算了旋转后的 `landed`,却把旋转前 Z 传给执行入口 |
| 姿态缓存过期 | `ui/multipoint_panel.py::_align` 对已有可达姿态不重新求解,安装或工具位置变化后可能执行旧目标 |
| 多点分析断开 | `session_review.py` 拒绝 `autocal-multipoint-1.0`,新采集不能进入既有复核界面 |
截图右点的 Y 为 +16.176,而当前零安装角、零 Y 偏移下应为 −16.176。先核对截图对应版本、当时设置与表格是否更新,不从这一异常直接推出轴翻转。
## 任务顺序与分工
建议一名熟悉 Autocal 的工程师负责软件,一名平台负责人参加坐标确认和实机验收。任务 1→2→3 是运动修正链;任务 4 在原始数据含义确认后可独立开展。当前不启动代理,也不包含自动力闭环或硬件驱动改写。
| 完成 | 任务 | 依赖 | 估计人时 |
|---|---|---|---:|
| [ ] | 0:固定坐标和实机约定 | 无 | 2–3 |
| [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 的测量结果调整。
## 0:先固定坐标约定
记录传感器、yaw、pitch、平台 XY 和压头 Z 的零位与正方向,实际旋转中心、安装偏移、工具位置和当前驱动器映射。区分台体旋转轴和传感器自身坐标;不要用“看起来向左”代替正方向定义。实测显示模型缺少旋转中心偏移时,再补对应几何量。
交付一张坐标图、参数快照和左右点记录。每点保存 UV、XYZ、法向、两个候选角、选解理由、最终五轴目标、控制器反馈与照片。核对右点 Y 异常。实机动作须由平台负责人先明确设备、无接触间隙、角度/行程边界和停机条件;本计划本身不授权动作。
## 1:修正朝上判断和选解
修改 `motion/kinematics.py`,补充 `test_kinematics.py` 与 `test_ik_analytical.py`。
- [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`。
在界面显示所选分支及理由,无法满足约定时拒绝执行。不要仅让原测试保持通过;新回归须在审查基准上失败。
## 2:统一接触高度与执行目标
修改 `PlatformStack.solve` 和两个 UI 执行入口,补充 `test_kinematics.py`、`test_pose_move.py`。
- [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` 称为正负方向不可区分的说法。不要把单纯改变多轴下发顺序当作修正机械旋转链。
## 3:执行前重新求解测点
修改 `ui/multipoint_panel.py` 和必要的 `SiteSpec` 状态,新增对应 UI 回归测试。
- [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 私有模块。
## 4:接通多点数据复核
修改 `session_review.py` 的读取入口及需要的复核 UI;复用既有保持窗口统计、诊断和拟合能力,不另建一套分析器。
- [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 标定系数,也不引入新采集格式;确有字段歧义时先更新数据契约并说明旧记录的解释限制。
## 5:验收与后续复查
检查点 A:任务 1–3 完成,先审查差异、离线几何证明和 mock 下发记录,再进入设备联调。检查点 B:任务 4–5 完成,从采集目录重开到图表的完整链通过后交接。
工程师提交以下证据供后续复查:
| 证据 | 要求 |
|---|---|
| 代码差异 | 基准/完成提交、修改清单、坐标与分支决策理由;未涉及的历史缺陷单独说明 |
| 回归 | 新测试在旧代码上的失败结果、修后结果、实际收集和跳过数量 |
| 运动链 | 两个截图点与至少三个边界点:输入→候选→选解→几何接触点→五轴目标→单位换算→mock 下发;不得通过实机运行测试自动化 |
| 实机 | 有授权的低速无接触核对,比较正负 pitch、非零 yaw 和零位;按已确定阈值记录位置、姿态与反馈,不用离线残差替代测量 |
| 数据 | 脱敏多点会话、录像/索引、重开截图、有效性理由和拟合结果;原始数据哈希 |
复查将重点检查独立预期值、分支选择、Z 零位关系、旧姿态、失败时是否仍下发,以及新旧数据是否都可复核。未提供设备证据时只能验收软件,不能宣布实机错向已修复。完成后提供提交或差异和证据目录;复查需由用户再次发起,不安排自动监控。
从 app 根运行首轮相关测试;每次使用新的 `--basetemp` 目录:
```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_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
```
新增 UI 回归加入上述命令;若修改共用录制入口,追加对应 shared record 测试。文档门禁使用工作区真实解释器,按 AUTHORING 执行,不通过扩大 baseline 获得通过。
@@ -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/<site_id>/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 系数,也不引入第二种采集格式。
+9 -9
View File
@@ -237,15 +237,15 @@ measured time per point.
guard violation. guard violation.
**The single-point dry run is built**, in the finger debug screen: 执行动作 sends the **The single-point dry run is built**, in the finger debug screen: 执行动作 sends the
selected point's *solved pose* to the platform — yaw, then pitch, then table X/Y — and selected point's *solved pose* to the platform — the stages together with the table
does **not** command the press axis, so it cannot reach the sensor. The order is the X/Y, and the press axis only once its zero is calibrated (设备设置 → 压轴); until then
model's own (`R_pitch · R_yaw`), so every intermediate pose is one the operator it is held, so the move cannot reach the sensor by accident. The order follows the
watched in the preview. Before it moves it checks the mapping, the target against the model's own composition (`R_pitch · R_yaw`), so every intermediate pose is one the
P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary calibration — operator watched in the preview. Before it moves it checks the mapping, the target
all in `motion/service.py`, so a later caller cannot walk around them — and the drives against the P0 limits (millimetres for x/y/z, degrees for yaw/pitch) and the rotary
are commanded one at a time, each waiting for the previous to arrive, with a stall or calibration — all in `motion/service.py`, so a later caller cannot walk around them.
an alarm aborting the rest. 停止 next to it is the same command as the strip's 停止 next to it is the same command as the strip's 全部停止; 回初始位 in the rail sends
全部停止; 回初始位 in the rail sends every axis to zero with the press axis first. every axis to zero with the press axis first.
--- ---
@@ -20,8 +20,10 @@ from .pose_move import (
MovePlan, MovePlan,
MoveStep, MoveStep,
PoseMover, PoseMover,
PressAxis,
plan_pose_move, plan_pose_move,
plan_return_to_initial, plan_return_to_initial,
press_targets,
) )
from .service import ( from .service import (
AXIS_ROLES, AXIS_ROLES,
@@ -76,6 +78,7 @@ __all__ = [
"MoveStep", "MoveStep",
"NativeTransport", "NativeTransport",
"PoseMover", "PoseMover",
"PressAxis",
"Snapshot", "Snapshot",
"ZmcTransport", "ZmcTransport",
"clamp_bas_task", "clamp_bas_task",
@@ -84,6 +87,7 @@ __all__ = [
"normalize_axis_roles", "normalize_axis_roles",
"plan_pose_move", "plan_pose_move",
"plan_return_to_initial", "plan_return_to_initial",
"press_targets",
"safe_float", "safe_float",
"units_per_degree", "units_per_degree",
"user_units_to_deg", "user_units_to_deg",
@@ -72,6 +72,14 @@ _UPRIGHT_TIE = 1e-6
#: has to absorb the sweep's own quantisation, not excuse a real misalignment. #: has to absorb the sweep's own quantisation, not excuse a real misalignment.
_UPRIGHT_TOL_DEG = 0.05 _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: 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 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 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 yaw_deg: float
@@ -109,10 +121,21 @@ class Branch:
upright: bool upright: bool
in_range: 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: def describe(self) -> str:
return ( return (
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} " f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
f"residual {self.residual_deg:.6f} " f"residual {self.residual_deg:.6f} "
f"travel {self.travel_deg:.3f} "
f"{'upright' if self.upright else 'inverted'}" f"{'upright' if self.upright else 'inverted'}"
f"{'' if self.in_range else ' out-of-range'}" 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.""" """One solved platform pose, or the reason there is not one."""
ok: bool ok: bool
#: Platform-frame coordinates of the contact point, mm — where the point *is*, #: Platform-frame XY of the contact point **as mounted**, mm: the sensor's
#: with the sensor's mounting pose applied. #: 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) target_xy_mm: tuple[float, float] = (0.0, 0.0)
#: Platform-frame Z of the contact point, mm. Carries the mounting plate and #: Platform-frame coordinates of the contact point as mounted, mm: the same
#: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs #: point as ``target_xy_mm`` with its Z, so the mounting geometry reads as one
#: are computed and then discarded, and nothing downstream can tell. #: 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 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 #: 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, #: 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 #: 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 residual_deg: float = 0.0
#: Whether the yaw could be determined at all (indeterminate at the pole). #: Whether the yaw could be determined at all (indeterminate at the pole).
yaw_determined: bool = True yaw_determined: bool = True
#: Whether the pose leaves the sensing face pointing along the tool axis. A #: Whether the pose leaves the *body* up — the axis the mount sends to the
#: pose can align the normal perfectly and still be turned over; the residual #: platform's vertical — still pointing up. A pose can align the normal
#: alone cannot say which, so it is reported rather than inferred. #: 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 upright: bool = True
#: False when the chosen pose is outside the stage travel — reported, never #: False when the chosen pose is outside the stage travel — reported, never
#: silently clamped. #: silently clamped.
in_range: bool = True in_range: bool = True
reason: str = "" 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 @property
def reached_tolerance(self) -> bool: def reached_tolerance(self) -> bool:
@@ -191,11 +243,15 @@ class PlatformStack:
#: ``+tool_axis``, and the tool feeds down onto it. #: ``+tool_axis``, and the tool feeds down onto it.
#: #:
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**. #: 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 #: Nothing reported that as a fault, because the residual was measured against
#: face turned fully over still scored 0.0000 deg), so the mistake showed up #: the same inverted aim: a pose that turned the sensor over aligned with ``-Z``
#: only as the model flipping upside down: **981 of 1032 vertices** ended with #: *perfectly*, scored 0.0000 deg, and passed every test that asked only for a
#: the face pointing down. With ``+1`` that is **51**, and those are edge-on #: small residual. The mistake showed up as the model flipping upside down:
#: rather than inverted (worst cosine -0.013). #: **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 approach_sign: int = 1
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight #: 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 #: above the table origin, which is where a dialled-in machine has it. Every
@@ -386,6 +442,12 @@ class PlatformStack:
residual = chosen.residual_deg residual = chosen.residual_deg
upright = chosen.upright upright = chosen.upright
in_range = True 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: elif branches:
# Every exact pose needs more travel than the stage has. Report the # Every exact pose needs more travel than the stage has. Report the
# best pose the limits *do* allow, with the residual it really # 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) yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
in_range = False in_range = False
branch_reason = "in_range"
branch_travel = abs(yaw_deg) + abs(pitch_deg)
other_travel = 0.0
else: else:
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool) yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
in_range = False in_range = False
branch_reason = ""
branch_travel = abs(yaw_deg) + abs(pitch_deg)
other_travel = 0.0
elif method == "search": elif method == "search":
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool) yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0 upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
in_range = True 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 else: # pragma: no cover - guarded by the signature
raise ValueError(f"unknown method {method!r}") raise ValueError(f"unknown method {method!r}")
@@ -413,17 +484,29 @@ class PlatformStack:
elif residual > self.tolerance_deg: elif residual > self.tolerance_deg:
reason = "no pose meets the tolerance" reason = "no pose meets the tolerance"
# The stages turn the contact about the table origin; the table then slides # Three points, and they are three different places:
# 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. # * ``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 landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
tool_x, tool_y = (float(value) for value in self.tool_xy_mm) 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( return Pose(
ok=True, ok=True,
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")), target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
contact_z_mm=self._axis(point, "z"), mounted_point_mm=(self._axis(point, "x"), self._axis(point, "y"), self._axis(point, "z")),
table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")), 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, yaw_deg=yaw_deg,
pitch_deg=pitch_deg, pitch_deg=pitch_deg,
tool_z_mm=float(tool_z_mm), tool_z_mm=float(tool_z_mm),
@@ -432,6 +515,9 @@ class PlatformStack:
upright=upright, upright=upright,
in_range=in_range, in_range=in_range,
reason=reason, reason=reason,
branch_reason=branch_reason,
branch_travel_deg=branch_travel,
other_travel_deg=other_travel,
) )
# ------------------------------------------------------- closed-form solve # ------------------------------------------------------- closed-form solve
@@ -582,34 +668,6 @@ class PlatformStack:
sine_term = sign_first * u2 * w1 - u1 * w2 sine_term = sign_first * u2 * w1 - u1 * w2
return math.degrees(math.atan2(sine_term, cosine_term)) 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]: 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. """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) turned = self._cell(unit, yaw_deg, pitch_deg)
tool = self.tool_direction tool = self.tool_direction
cosine = ( x, y, z = turned
turned[0] * float(tool[0]) tx, ty, tz = (float(value) for value in tool)
+ turned[1] * float(tool[1]) cosine = x * tx + y * ty + z * tz
+ turned[2] * float(tool[2]) # acos loses angular precision near a dot product of 1, which can make
) # equivalent exact branches appear different. atan2 stays well conditioned.
return float(math.degrees(math.acos(max(-1.0, min(1.0, cosine))))) 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: 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``. """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: def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
"""How much the sensing face still points up, in -1..1. """How much the sensing face still points up, in -1..1.
Measured, not derived: the sensor's own up axis (mesh ``+Y``, which Measured, not derived: the body's own up axis — the mesh axis the mount
``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage sends to the platform frame's vertical, i.e. mesh ``+Y`` under the default
rotation and compared with that same axis *unrotated*. ``+1`` is the ``AXIS_MAP`` — is put through the composed stage rotation and dotted with
nominal pose — no rotation, face straight up — ``-1`` is fully turned over. 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 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 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 points. An earlier version dotted the turned axis against the tool
direction, which quietly moved with ``approach_sign`` and so reported the direction, which quietly moved with ``approach_sign`` and so reported the
polarity as an orientation. 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) reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
turned = turned / max(float(np.linalg.norm(turned)), 1e-12) turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
@@ -996,10 +1067,71 @@ class PlatformStack:
return True return True
if lean < -_UPRIGHT_TIE: if lean < -_UPRIGHT_TIE:
return False return False
# Indeterminate: the two poses are the same alignment to any tolerance the # Edge-on: the body's up is perpendicular to the vertical to within the
# machine has, so take the one needing the smaller move. # 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 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( def _prefer_smallest(
self, self,
unit: np.ndarray, unit: np.ndarray,
@@ -26,6 +26,7 @@ translation is what the rotations are resolved against.
from __future__ import annotations from __future__ import annotations
import math
import time import time
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field 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") 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) @dataclass(frozen=True)
class MoveStep: class MoveStep:
"""One axis, one target, in that role's own unit.""" """One axis, one target, in that role's own unit."""
@@ -326,6 +326,20 @@ class Campaign:
raise ValueError("the solved pose is outside the stage travel; it is stored but not commanded") raise ValueError("the solved pose is outside the stage travel; it is stored but not commanded")
return {key: float(pose[key]) for key in ("x", "y", "yaw", "pitch", "z")} return {key: float(pose[key]) for key in ("x", "y", "yaw", "pitch", "z")}
def store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Update the current site pose without rewriting past repeat records."""
if self.is_active and site_id != self.pending_alignment_site:
raise RuntimeError("cannot change a pose during an active trial")
site = self.sites[self._index(site_id)]
record = None if self._document is None else next(
(row for row in self._document["sites"] if row["site_id"] == site_id), None)
if site.pose == pose and (record is None or record["pose"] == pose):
return
site.pose = dict(pose)
if record is not None:
record["pose"] = site.pose
self._save()
def set_force_rotation(self, values: list[float]) -> None: def set_force_rotation(self, values: list[float]) -> None:
"""Store the force-sensor-to-tool rotation. There is no default matrix.""" """Store the force-sensor-to-tool rotation. There is no default matrix."""
self._rotation = parse_rotation(list(values)) self._rotation = parse_rotation(list(values))
@@ -623,6 +637,13 @@ class Campaign:
elapsed = (time.perf_counter_ns() - self._hold_started_ns) / 1e9 elapsed = (time.perf_counter_ns() - self._hold_started_ns) / 1e9
return max(0.0, self._hold_s - elapsed) return max(0.0, self._hold_s - elapsed)
@property
def pending_alignment_site(self) -> str:
"""The next point awaiting alignment between recorded trials."""
if self.phase != "liftoff" or self._single_site:
return ""
return self.sites[self._site_index].site_id
def progress(self) -> dict: def progress(self) -> dict:
site = self.sites[self._site_index] if self.sites and self._site_index < len(self.sites) else None site = self.sites[self._site_index] if self.sites and self._site_index < len(self.sites) else None
step = self._steps[self._step_index] if self._step_index < len(self._steps) else None step = self._steps[self._step_index] if self._step_index < len(self._steps) else None
+40 -1
View File
@@ -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]: def _default_sensor_setup() -> dict[str, Any]:
"""Workflow P3/P4: what is under test, what touches it, and the transform. """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(), "sensor_setup": _default_sensor_setup(),
#: P5: grid generation and run parameters. #: P5: grid generation and run parameters.
"sequence_profile": _default_sequence_profile(), "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 --------------------------------------------- # --- finger debug screen ---------------------------------------------
#: The export bundle the debug screen loads. Empty means "ask", and the #: The export bundle the debug screen loads. Empty means "ask", and the
#: default below points at the revision the bench confirmed. #: default below points at the revision the bench confirmed.
@@ -242,7 +269,12 @@ def default_preferences() -> dict[str, Any]:
#: a cosmetic problem: the move is refused with "the limit is not set" while the file #: 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 #: 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. #: 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",
)
def load_preferences() -> dict[str, Any]: def load_preferences() -> dict[str, Any]:
@@ -322,6 +354,13 @@ def load_preferences() -> dict[str, Any]:
# Yaw travel is ±180. The same-sign pitch solution sits near ±150° of yaw, # Yaw travel is ±180. The same-sign pitch solution sits near ±150° of yaw,
# and a stored ±90 drops it, leaving the opposite pitch sign on one side. # and a stored ±90 drops it, leaving the opposite pitch sign on one side.
prefs["finger_yaw_limit_deg"] = float(defaults["finger_yaw_limit_deg"]) prefs["finger_yaw_limit_deg"] = float(defaults["finger_yaw_limit_deg"])
# The solver's envelope and the move guard's are one bench fact written twice: a
# measured point has two exact solutions, the second past 118° of yaw, and a file
# saved on the old ±90 would have the solver call a pose reachable while the guard
# refused the 118° the move needs. Normalise both spellings, as the pitch envelope
# above is normalised, so "cannot reach" can only mean the stage really cannot.
if isinstance(limits, dict):
limits["max_yaw_deg"] = float(defaults["safety_limits"]["max_yaw_deg"])
# A hand-edited task number must not reach the controller as typed. # A hand-edited task number must not reach the controller as typed.
prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task")) prefs["bas_task"] = clamp_bas_task(prefs.get("bas_task"))
prefs = _migrate_axis_roles(prefs) prefs = _migrate_axis_roles(prefs)
@@ -48,7 +48,7 @@ def read_plan(path: Path, definition: dict) -> dict:
def overview_rows(review: dict, plan: dict | None = None) -> list[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 = plan_points(plan, review["uv_definition"]) if plan is not None else []
planned_uv = {tuple(p["uv_xy_px"]) for p in planned} 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 = [] rows = []
for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]: 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"])) site = actual.get(tuple(point["uv_xy_px"]))
@@ -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: def review_session(directory: Path, *, selections: dict | None = None) -> dict:
"""Join frame identity to host timestamps and inspect only complete windows. """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() root = Path(directory).resolve()
manifest = json.loads((root / "session.json").read_text(encoding="utf-8")) manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
if manifest.get("format_version") == "autocal-multipoint-1.0": if manifest.get("format_version") == MULTIPOINT_FORMAT:
raise ValueError("multi-point sessions are raw acquisition folders; this review only opens finger sessions") return review_multipoint(root, manifest=manifest, selections=selections)
if manifest.get("format_version") != "autocal-session-1.0": if manifest.get("format_version") != "autocal-session-1.0":
raise ValueError("select a calibration session root, not an individual trial") raise ValueError("select a calibration session root, not an individual trial")
windows = [] 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, if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None,
"stability_verified": verified, "stability_verified": verified,
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]), "reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
"frame_source": trial["directory"],
"status": "candidate" if raw.size >= 2 else "insufficient_samples", "status": "candidate" if raw.size >= 2 else "insufficient_samples",
}) })
if set(selections) != used: if set(selections) != used:
@@ -235,6 +520,61 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
} }
def reselect_review(result: dict, selections: dict) -> dict:
"""Adjust loaded hold traces; saving still verifies all acquired files afresh."""
from copy import deepcopy
updated = dict(result)
updated["sites"] = [{**site, "windows": [dict(window) for window in site["windows"]]}
for site in result["sites"]]
used = set()
for site in updated["sites"]:
for window in site["windows"]:
key = f"{window['trial_id']}:{window['step_index']}"
choice = selections.get(key)
start, end = window["hold_start_host_mono_ns"], window["hold_end_host_mono_ns"]
verified = False
if choice is not None:
used.add(key)
start, end = int(choice["t_start_host_mono_ns"]), int(choice["t_end_host_mono_ns"])
verified = choice["stability_verified"] is True
if not window["hold_start_host_mono_ns"] <= start < end <= window["hold_end_host_mono_ns"]:
raise ValueError(f"selection outside recorded hold: {key}")
trace = [(stamp, value) for stamp, _, value in window["trace"] if start <= stamp < end]
raw = np.array([value for _, value in trace], dtype=float)
if verified and raw.size < 2:
raise ValueError(f"not enough valid samples to confirm: {key}")
status = window["status"]
if status in {"candidate", "insufficient_samples"}:
status = "candidate" if raw.size >= 2 else "insufficient_samples"
window.update(
t_start_host_mono_ns=start, t_end_host_mono_ns=end,
duration_s=(end - start) / 1e9, samples=int(raw.size),
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 - start) / 1e9 for stamp, _ in trace]), raw, 1)[0])
if raw.size >= 2 and len({stamp for stamp, _ in trace}) >= 2 else None,
stability_verified=verified, status=status,
)
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
and w["status"] == "candidate"]
site.update(fit=_fit(training),
confirmed_fit=_fit([w for w in training if w["stability_verified"]]),
quality=_quality(site["windows"]))
if updated.get("source_format") == MULTIPOINT_FORMAT:
site.update(_multipoint_site(site, site["windows"]))
site["blockers"] = sorted({
*[b for b in site["blockers"] if not b.startswith("unverified_window:") and b != "no_fit"],
*[f"unverified_window:{w['step_index']}" for w in site["windows"] if not w["stability_verified"]],
*(["no_fit"] if site["fit"] is None else []),
})
if used != set(selections):
raise ValueError("selection refers to an unavailable hold window")
updated["selections"] = deepcopy(selections)
return updated
def load_review(directory: Path, review_path: Path) -> dict: def load_review(directory: Path, review_path: Path) -> dict:
"""Recompute saved selections only when the acquired inputs still match.""" """Recompute saved selections only when the acquired inputs still match."""
saved = json.loads(review_path.read_text(encoding="utf-8")) saved = json.loads(review_path.read_text(encoding="utf-8"))
@@ -283,7 +623,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"]] 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] frame_id = selected[0][1]
trial_id = window["trial_id"] 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) 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, item = {"site_id": site["site_id"], "trial_id": trial_id,
"frame_id": frame_id, "role": "reviewed_zero_load_inspection", "frame_id": frame_id, "role": "reviewed_zero_load_inspection",
@@ -26,9 +26,11 @@ from __future__ import annotations
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox,
QFormLayout, QFormLayout,
QGroupBox, QGroupBox,
QLabel, QLabel,
@@ -43,11 +45,13 @@ from .bus_panel import BusPanel
from .connection_panel import ConnectionPanel from .connection_panel import ConnectionPanel
#: Tab identity -> locale key, in the order they are shown. The order is the work: #: 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], ...] = ( TABS: tuple[tuple[str, str], ...] = (
("connection", "setup.tab_connection"), ("connection", "setup.tab_connection"),
("bus", "setup.tab_bus"), ("bus", "setup.tab_bus"),
("axes", "setup.tab_axes"), ("axes", "setup.tab_axes"),
("press", "setup.tab_press"),
) )
#: Role -> locale key for the mapping rows. #: Role -> locale key for the mapping rows.
@@ -165,6 +169,116 @@ class AxisRolesPage(QWidget):
self._refresh_problem(announce=False) 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): class DeviceSetupDialog(QDialog):
"""Connection and bus initialisation, the once-per-start controls.""" """Connection and bus initialisation, the once-per-start controls."""
@@ -173,6 +287,7 @@ class DeviceSetupDialog(QDialog):
spec: ConnectionSpec, spec: ConnectionSpec,
bas_path: str = "", bas_path: str = "",
roles: dict[str, int] | None = None, roles: dict[str, int] | None = None,
press: object = None,
parent: QWidget | None = None, parent: QWidget | None = None,
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
@@ -195,6 +310,7 @@ class DeviceSetupDialog(QDialog):
self.connection_panel = ConnectionPanel(spec, self.tabs) self.connection_panel = ConnectionPanel(spec, self.tabs)
self.bus_panel = BusPanel(bas_path, self.tabs) self.bus_panel = BusPanel(bas_path, self.tabs)
self.roles_page = AxisRolesPage(roles, self.tabs) self.roles_page = AxisRolesPage(roles, self.tabs)
self.press_page = PressAxisPage(press, self.tabs)
for key, locale_key in TABS: for key, locale_key in TABS:
self.tabs.addTab(self.panel(key), "") self.tabs.addTab(self.panel(key), "")
column.addWidget(self.tabs, 1) column.addWidget(self.tabs, 1)
@@ -215,10 +331,12 @@ class DeviceSetupDialog(QDialog):
# ----------------------------------------------------------------- content # ----------------------------------------------------------------- content
def panel(self, key: str) -> QWidget: 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": if key == "connection":
return self.connection_panel 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: def open_for(self, tab: str | None = None) -> None:
"""Show the dialog, raising it if it is already up, and pick a tab. """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.connection_panel.retranslate()
self.bus_panel.retranslate() self.bus_panel.retranslate()
self.roles_page.retranslate() self.roles_page.retranslate()
self.press_page.retranslate()
@@ -25,6 +25,12 @@ from orisys_app_shared.i18n import t
from ..multipoint import Campaign, next_point_id from ..multipoint import Campaign, next_point_id
from ..sensor.uv_flat import site_at 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): class _WrappingLabel(QLabel):
"""Keep wrapped text inside its own row instead of painting over the next control.""" """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: def _hold(self) -> float:
return float(self.host.hold.value()) return float(self.host.hold.value())
def _solve_uv(self, u: float, v: float): def _solver(self):
"""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
finger = self._finger finger = self._finger
stack = None if finger is None else getattr(finger, "stack", None) stack = None if finger is None else getattr(finger, "stack", None)
if stack is None: if stack is None:
raise ValueError(t("multipoint.no_solver")) raise ValueError(t("multipoint.no_solver"))
solved = stack.solve( return stack
np.asarray(point, dtype=np.float64),
np.asarray(normal, dtype=np.float64), @staticmethod
) def _pose_of(solved, stack) -> dict:
length = float(np.linalg.norm(normal)) """The record of one solve: the targets, and what the solver said of them."""
unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple( return {
float(value) for value in normal
)
pose = {
"x": float(solved.table_xy_mm[0]), "x": float(solved.table_xy_mm[0]),
"y": float(solved.table_xy_mm[1]), "y": float(solved.table_xy_mm[1]),
"yaw": float(solved.yaw_deg), "yaw": float(solved.yaw_deg),
@@ -310,13 +304,42 @@ class MultipointPanel(QWidget):
"normal_aligned_to": f"tool_axis:{stack.tool_axis}", "normal_aligned_to": f"tool_axis:{stack.tool_axis}",
"reason": str(solved.reason or ""), "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 ( return (
(float(point[0]), float(point[1]), float(point[2])), (float(point[0]), float(point[1]), float(point[2])),
unit, unit,
pose, self._pose_of(solved, stack),
(float(site.u_px), float(site.v_px)), (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): def _solve_current(self):
return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value())) return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value()))
@@ -328,6 +351,69 @@ class MultipointPanel(QWidget):
site.uv_px = uv site.uv_px = uv
site.position_frame = "sensor" site.position_frame = "sensor"
def _store_solved_pose(self, site_id: str, pose: dict) -> None:
"""Record a fresh solve of a point without retaking the point itself."""
self.campaign.store_solved_pose(site_id, pose)
def _require_same_point(self, site, point, normal) -> None:
"""Refuse a point the current map no longer puts where the record says.
A re-solve is allowed to answer differently; it is not allowed to move the
point quietly. The recorded position is what a session's rows are
attributed to, so a changed map, bundle or mounting has to reach the
operator as "take this point again", never as a silently retargeted move.
"""
if site.position_frame != "sensor" or site.normal is None:
return
stored = np.asarray((site.x, site.y, site.z), dtype=np.float64)
moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored))
if moved > _POINT_MATCH_MM:
raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}"))
dot = float(np.dot(
np.asarray(normal, dtype=np.float64),
np.asarray(site.normal, dtype=np.float64),
))
turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0))))
if turned > _NORMAL_MATCH:
raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}"))
def _resolve_site(self, site) -> dict:
"""The pose to align now, solved against the geometry in force now.
``site.pose`` records what the solver said when the point was taken. The
mounting offsets, the mounting angle, the tool position and the travel
limits are all editable afterwards, so a stored pose can name a target
this machine no longer reaches — or reach the same point a different way.
The closed form is cheap and stateless, so this asks it again instead of
keeping a cache and a rule for when to distrust it.
"""
if site.uv_px is not None:
point, normal, pose, _uv = self._solve_uv(*site.uv_px)
self._require_same_point(site, point, normal)
self._store_solved_pose(site.site_id, pose)
return pose
if site.normal is None or site.position_frame != "sensor":
raise ValueError(t("multipoint.point_unmapped"))
pose = self._solve_point((site.x, site.y, site.z), site.normal)
self._store_solved_pose(site.site_id, pose)
return pose
def _pose_for_display(self, site):
"""What the status line shows: solved now, or the record when that fails.
The line is read before a move, so it has to answer for the geometry in
force now; a stored pose the current mounting no longer reaches would
otherwise read as reachable right up to the refusal.
"""
if site is None:
return None
if self.campaign.is_active:
return site.pose
try:
return self._resolve_site(site)
except (OSError, RuntimeError, ValueError):
return site.pose
def _max_force(self) -> float: def _max_force(self) -> float:
text = self.max_force.text().strip() text = self.max_force.text().strip()
if not text: if not text:
@@ -417,18 +503,26 @@ class MultipointPanel(QWidget):
"""The right-hand confirm button records the current multi-point step.""" """The right-hand confirm button records the current multi-point step."""
self._confirm() 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: def _align(self) -> None:
if not self._selected: if not self._selected:
self.message.setText(t("multipoint.align_select")) self.message.setText(t("multipoint.align_select"))
return 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(): def go():
site = next(item for item in self.campaign.sites if item.site_id == self._selected) site = next(item for item in self.campaign.sites if item.site_id == self._selected)
pose = site.pose # Solved, not looked up: the stored pose may predate any of the
if pose is None or not pose.get("in_range"): # mounting, tool or travel settings the operator has changed since.
if site.uv_px is None: pose = self._resolve_site(site)
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)
if not pose.get("ok") or not pose.get("in_range"): if not pose.get("ok") or not pose.get("in_range"):
raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or "")) raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or ""))
self.alignRequested.emit(self.campaign.command_targets(self._selected)) self.alignRequested.emit(self.campaign.command_targets(self._selected))
@@ -522,7 +616,7 @@ class MultipointPanel(QWidget):
break break
elif self.campaign.phase != "liftoff": elif self.campaign.phase != "liftoff":
self._offered_liftoff = "" self._offered_liftoff = ""
self.align_button.setEnabled(self.campaign.phase != "hold" and bool(self._selected)) self.align_button.setEnabled(self._can_align())
branch = { branch = {
"baseline": t("multipoint.branch_baseline"), "baseline": t("multipoint.branch_baseline"),
"load": t("multipoint.branch_load"), "load": t("multipoint.branch_load"),
@@ -546,7 +640,7 @@ class MultipointPanel(QWidget):
(site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)), (site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)),
None, None,
) )
pose = None if shown is None else shown.pose pose = self._pose_for_display(shown)
if pose: if pose:
text = t("multipoint.pose").format( text = t("multipoint.pose").format(
x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"], x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"],
@@ -8,7 +8,7 @@ from bisect import bisect_left, bisect_right
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
from PySide6.QtCore import Qt, QPointF from PySide6.QtCore import Qt, QPointF, Slot
from PySide6.QtGui import QColor, QPainter from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
@@ -24,6 +24,7 @@ from ..record_diagnostics import trial_diagnostics
from ..sensor.uv_outline import recorded_outline from ..sensor.uv_outline import recorded_outline
from .session_review_dialog import SessionReviewDialog from .session_review_dialog import SessionReviewDialog
from .chart_helpers import add_value_axes, clear_chart from .chart_helpers import add_value_axes, clear_chart
from .review_job import ReviewJob
class RecordOverviewPage(QWidget): class RecordOverviewPage(QWidget):
@@ -32,6 +33,7 @@ class RecordOverviewPage(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.overview = None self.overview = None
self._job = None
self.directory = Path.cwd() self.directory = Path.cwd()
self.content_layout = QVBoxLayout(self) self.content_layout = QVBoxLayout(self)
self.open_button = QPushButton(self) self.open_button = QPushButton(self)
@@ -66,8 +68,31 @@ class RecordOverviewPage(QWidget):
self.load_session(Path(path)) self.load_session(Path(path))
def load_session(self, directory: Path): def load_session(self, directory: Path):
if self._job is not None:
return
self._loading_directory = directory
def read():
review = review_session(directory)
diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial)
for trial in review["trials"]}
return review, diagnostics
self.open_button.setEnabled(False)
self.message.setText(t("review.checking"))
self.message.show()
self._job = ReviewJob(read, self._loaded)
self._job.start()
@Slot(object, object)
def _loaded(self, value, error):
self._job = None
self.open_button.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
self.message.show()
return
directory = self._loading_directory
try: try:
overview = RecordOverviewDialog(directory, self) overview = RecordOverviewDialog(directory, self, review=value[0], diagnostics=value[1])
except (OSError, ValueError, KeyError, TypeError) as exc: except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc)) self.message.setText(t("calibration.error").format(detail=exc))
self.message.show() self.message.show()
@@ -95,11 +120,13 @@ class RecordOverviewDialog(QDialog):
if self.isWindow(): if self.isWindow():
super().reject() super().reject()
def __init__(self, directory: Path, parent=None): def __init__(self, directory: Path, parent=None, *, review=None, diagnostics=None):
super().__init__(parent) super().__init__(parent)
self.directory = directory self.directory = directory
self.review = review_session(directory) self._job = None
self.diagnostics = {trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]} self.review = review if review is not None else review_session(directory)
self.diagnostics = diagnostics if diagnostics is not None else {
trial["trial_id"]: trial_diagnostics(directory, trial) for trial in self.review["trials"]}
self.outline, self.outline_state = recorded_outline(self.review) self.outline, self.outline_state = recorded_outline(self.review)
self._uv_bounds = None self._uv_bounds = None
self._uv_label = None self._uv_label = None
@@ -520,25 +547,39 @@ class RecordOverviewDialog(QDialog):
self._refresh() self._refresh()
def _save(self): def _save(self):
try: review, plan = self.review, self.plan
path = save_overview(self.review, self.plan) self._start_job("save", lambda: save_overview(review, plan))
@Slot(object, object)
def _job_finished(self, value, error):
self._job = None
self.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
self.message.show()
elif self._job_kind == "save":
self._dirty = False self._dirty = False
self._show_response() self._show_response()
self.message.setText(t("overview.saved")) self.message.setText(t("overview.saved"))
self.message.show() self.message.show()
self.message.setToolTip(str(path)) self.message.setToolTip(str(value))
except (OSError, ValueError, KeyError, TypeError) as exc: else:
self.message.setText(t("calibration.error").format(detail=exc)) self.review, self.plan = value
self.message.show() self._dirty = False
self._refresh()
def _start_job(self, kind, action):
if self._job is not None:
return
self._job_kind = kind
self.setEnabled(False)
self.message.setText(t("review.checking"))
self.message.show()
self._job = ReviewJob(action, self._job_finished)
self._job.start()
def _load(self): def _load(self):
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)") path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
if path: if path:
try: directory = self.directory
review, plan = load_overview(self.directory, Path(path)) self._start_job("load", lambda: load_overview(directory, Path(path)))
self.review, self.plan = review, plan
self._dirty = False
self._refresh()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(t("calibration.error").format(detail=exc))
self.message.show()
@@ -0,0 +1,27 @@
"""Run recording reads and integrity checks without blocking Qt's event loop."""
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
class _Result(QObject):
ready = Signal(object, object)
class ReviewJob(QRunnable):
def __init__(self, action, receiver):
super().__init__()
self.action = action
self.result = _Result()
self.result.ready.connect(receiver)
def start(self):
QThreadPool.globalInstance().start(self)
def run(self):
try:
value = self.action()
except Exception as exc:
# Worker failures must return to Qt so controls are always restored.
self.result.ready.emit(None, str(exc))
else:
self.result.ready.emit(value, None)
@@ -45,6 +45,7 @@ from PySide6.QtWidgets import (
) )
from ..motion.kinematics import PlatformStack from ..motion.kinematics import PlatformStack
from ..motion.pose_move import PressAxis, press_targets
from ..sensor.export_bundle import SensorBundle from ..sensor.export_bundle import SensorBundle
from ..sensor.frames import describe as describe_frames from ..sensor.frames import describe as describe_frames
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
@@ -372,7 +373,9 @@ class FingerPanel(QGroupBox):
"sensor_normal", "sensor_normal",
"platform_point", "platform_point",
"pose", "pose",
"branch",
"table_move", "table_move",
"press",
"residual", "residual",
"frames", "frames",
) )
@@ -751,13 +754,19 @@ class FingerPanel(QGroupBox):
finally: finally:
self.confirm_check.blockSignals(False) self.confirm_check.blockSignals(False)
_press = PressAxis()
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None: def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
"""The selected point's per-role targets, and the roles held back. """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 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 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 preview drew as the cyan path's end.
the other four axes in the same move.
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 pose = self._last_pose
if pose is None: if pose is None:
@@ -770,7 +779,41 @@ class FingerPanel(QGroupBox):
"pitch": float(pose.pitch_deg), "pitch": float(pose.pitch_deg),
"z": float(pose.contact_z_mm), "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: def _sync_sim_button(self) -> None:
self.sim_button.setText( self.sim_button.setText(
@@ -1212,7 +1255,9 @@ class FingerPanel(QGroupBox):
"sensor_normal", "sensor_normal",
"platform_point", "platform_point",
"pose", "pose",
"branch",
"table_move", "table_move",
"press",
"residual", "residual",
): ):
self._readout_labels[key].setText("—") self._readout_labels[key].setText("—")
@@ -1240,6 +1285,8 @@ class FingerPanel(QGroupBox):
) )
if pose is not None: if pose is not None:
self._readout_labels["pose"].setText(pose.describe()) 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( self._readout_labels["residual"].setText(
t( t(
"finger.residual_value", "finger.residual_value",
@@ -1326,7 +1373,9 @@ class FingerPanel(QGroupBox):
"sensor_normal": "finger.row_sensor_normal", "sensor_normal": "finger.row_sensor_normal",
"platform_point": "finger.row_platform_point", "platform_point": "finger.row_platform_point",
"pose": "finger.row_pose", "pose": "finger.row_pose",
"branch": "finger.row_branch",
"table_move": "finger.row_table_move", "table_move": "finger.row_table_move",
"press": "finger.row_press",
"residual": "finger.row_residual", "residual": "finger.row_residual",
"frames": "finger.row_frames", "frames": "finger.row_frames",
} }
@@ -6,14 +6,15 @@ from pathlib import Path
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries
from PySide6.QtCore import Qt, QPointF from PySide6.QtCore import Qt, QPointF, Slot
from PySide6.QtGui import QColor, QPainter from PySide6.QtGui import QColor, QPainter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QComboBox, QDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QDoubleSpinBox, QCheckBox, QFileDialog, QTabWidget, QWidget,
) )
from ..session_review import review_session, save_review, load_review from ..session_review import review_session, save_review, load_review, reselect_review
from .review_job import ReviewJob
from .chart_helpers import add_value_axes, clear_chart from .chart_helpers import add_value_axes, clear_chart
@@ -30,6 +31,8 @@ class SessionReviewDialog(QDialog):
"#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}") "#holdInspection QPushButton {min-height: 22px; padding: 3px 10px;}")
self.review = review if review is not None else review_session(directory) self.review = review if review is not None else review_session(directory)
self.directory = directory self.directory = directory
self._job = None
self._job_kind = ""
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
self.notice = QLabel(t("review.notice"), self) self.notice = QLabel(t("review.notice"), self)
self.notice.setWordWrap(True) self.notice.setWordWrap(True)
@@ -227,9 +230,7 @@ class SessionReviewDialog(QDialog):
} }
row = self.table.currentRow() row = self.table.currentRow()
try: try:
updated = review_session(self.directory, selections=selections) updated = reselect_review(self.review, selections)
if updated["input_sha256"] != self.review["input_sha256"]:
raise ValueError("recorded inputs changed since this review was opened")
self.review = updated self.review = updated
self._show_site() self._show_site()
self.table.selectRow(row) self.table.selectRow(row)
@@ -242,11 +243,8 @@ class SessionReviewDialog(QDialog):
path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"), path, _ = QFileDialog.getOpenFileName(self, t("review.load_selection"),
str(self.directory / "derived"), "JSON (*.json)") str(self.directory / "derived"), "JSON (*.json)")
if path: if path:
try: directory = self.directory
self.review = load_review(self.directory, Path(path)) self._start_job("load", lambda: load_review(directory, Path(path)))
self._show_site()
except (OSError, ValueError, KeyError, TypeError) as exc:
self.message.setText(str(exc))
def _show_uv(self, selected: int) -> None: def _show_uv(self, selected: int) -> None:
clear_chart(self.uv_chart) clear_chart(self.uv_chart)
@@ -280,8 +278,28 @@ class SessionReviewDialog(QDialog):
return "—" if value is None else f"{value:.6g}" return "—" if value is None else f"{value:.6g}"
def _save(self) -> None: def _save(self) -> None:
try: review = self.review
path = save_review(self.review) self._start_job("save", lambda: save_review(review))
self.message.setText(t("review.saved").format(path=path))
except (OSError, ValueError) as exc: def _start_job(self, kind, action):
self.message.setText(t("calibration.error").format(detail=exc)) if self._job is not None:
return
self._job_kind = kind
self._job = ReviewJob(action, self._job_finished)
self.message.setText(t("review.checking"))
for button in (self.save_button, self.load_button, self.apply_button):
button.setEnabled(False)
self._job.start()
@Slot(object, object)
def _job_finished(self, value, error):
self._job = None
for button in (self.save_button, self.load_button, self.apply_button):
button.setEnabled(True)
if error is not None:
self.message.setText(t("calibration.error").format(detail=error))
elif self._job_kind == "load":
self.review = value
self._show_site()
else:
self.message.setText(t("review.saved").format(path=value))
+46 -7
View File
@@ -39,7 +39,13 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..motion.kinematics import PlatformStack 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.service import clamp_bas_task
from ..motion.units import DEFAULT_UNITS from ..motion.units import DEFAULT_UNITS
from ..preferences import load_preferences, save_preferences from ..preferences import load_preferences, save_preferences
@@ -219,6 +225,7 @@ class AutoCalMainWindow(QMainWindow):
self.spec, self.spec,
str(self.prefs.get("bas_path") or ""), str(self.prefs.get("bas_path") or ""),
self.prefs.get("axis_roles"), self.prefs.get("axis_roles"),
self._press_axis,
self, self,
) )
self.connection_panel = self.device_setup.connection_panel 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 # 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. # page has to reach the service rather than only the preference file.
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed) 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( self.axis_readout = AxisReadoutPanel(
MotionService().axis_count, self._theme, self.rail_content 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), tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
parent=body, parent=body,
) )
self.finger_panel.set_press(self._press_axis)
self.finger_source_panel = FingerSourcePanel( self.finger_source_panel = FingerSourcePanel(
video_path=str(self.prefs.get("finger_video_path") or ""), video_path=str(self.prefs.get("finger_video_path") or ""),
config_ref=str(self.prefs.get("finger_config_ref") or ""), config_ref=str(self.prefs.get("finger_config_ref") or ""),
@@ -404,6 +415,24 @@ class AutoCalMainWindow(QMainWindow):
if problem: if problem:
self.finger_panel.set_move_status(f"✗ {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 # ---------------------------------------------------------- finger debug
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE): 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: def _on_execute_pose(self) -> None:
"""Move the platform to the solved pose of the finger screen's selection. """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 Yaw, pitch and table X/Y start together. The press axis joins them only
the solved contact height. The numbers in the confirmation are the ones when the bench has measured how its coordinate relates to a contact
the preview drew. 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() solve = self.finger_panel.solved_pose()
if solve is None: if solve is None:
@@ -1089,12 +1120,18 @@ class AutoCalMainWindow(QMainWindow):
self._start_move(plan, t("move.execute")) self._start_move(plan, t("move.execute"))
def _on_align_multipoint(self, targets: dict) -> None: 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( plan = plan_pose_move(
self.poller.service, self.poller.service,
targets, targets,
title=t("multipoint.align"), title=t("multipoint.align"),
hold=(), hold=held,
together=True, together=True,
) )
self._start_move( self._start_move(
@@ -1248,7 +1285,9 @@ class AutoCalMainWindow(QMainWindow):
def _apply_theme(self) -> None: def _apply_theme(self) -> None:
app = QApplication.instance() app = QApplication.instance()
if app is not None: 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.brand_bar.set_theme(self._theme)
self.status_strip.set_theme(self._theme) self.status_strip.set_theme(self._theme)
self.run_bar.set_theme(self._theme) self.run_bar.set_theme(self._theme)
+12
View File
@@ -0,0 +1,12 @@
"""Bounded event-loop waits for recording review tests."""
import time
from PySide6.QtTest import QTest
def wait_review(widget):
deadline = time.monotonic() + 10
while widget._job is not None and time.monotonic() < deadline:
QTest.qWait(10)
assert widget._job is None, "background review did not finish"
+65 -4
View File
@@ -180,20 +180,21 @@ def test_the_axis_mapping_page_holds_the_mapping_and_refuses_duplicates(make_win
window = make_window() window = make_window()
try: try:
dialog = window.device_setup dialog = window.device_setup
assert dialog.tabs.count() == 3 assert dialog.tabs.count() == 4
page = dialog.roles_page page = dialog.roles_page
assert page.roles() == DEFAULT_AXIS_ROLES assert page.roles() == DEFAULT_AXIS_ROLES
assert page.problem() == "" assert page.problem() == ""
assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES assert window.poller.service.axis_roles == DEFAULT_AXIS_ROLES
# Two roles on one drive: reported on the page *and* by the guard. # Two roles on one drive: reported on the page *and* by the guard. Yaw is
page.combos["pitch"].setCurrentIndex(page.combos["pitch"].findData(3)) # 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 page.problem() != ""
assert window.poller.service.roles_problem() != "" assert window.poller.service.roles_problem() != ""
assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None assert window.poller.service.check_axes_move({"yaw": 1.0}) is not None
# Correcting it clears both. # 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 page.problem() == ""
assert window.poller.service.roles_problem() == "" assert window.poller.service.roles_problem() == ""
finally: 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 assert window.connection_panel.open_button.isEnabled() is True
finally: finally:
window.close() 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
+80 -3
View File
@@ -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) panel.set_sensor_setup((0.0, 0.0, 4.0), 0.0)
thick = panel.stack.solve(point, normal) 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) assert thick.target_xy_mm == pytest.approx(thin.target_xy_mm)
# Picking through the panel uses the same fields, so the two must agree. # Picking through the panel uses the same fields, so the two must agree.
panel.clear_points() panel.clear_points()
@@ -1179,7 +1183,8 @@ def test_the_sensor_z_offset_reaches_the_solve(panel):
thick_row = panel.handle_pick(x, y) thick_row = panel.handle_pick(x, y)
assert thin_row is not None and thick_row is not None 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["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["X"] == pytest.approx(thin_row["X"])
assert thick_row["Y"] == pytest.approx(thin_row["Y"]) 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[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.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 # A yaw about the table's z turns the contact about the origin, so it moves the
# point and leaves its height alone. # 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 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): def test_the_preview_refuses_to_draw_without_a_context(qt_app):
from orisys_autocal.ui.pose_preview import PosePreview 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 assert np.allclose(moved[index], residual_colour(preview._residuals[index])) # noqa: SLF001
finally: finally:
preview.deleteLater() 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
+7 -9
View File
@@ -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): def test_no_vertex_ends_turned_over(bundle):
"""Turning the face over is the failure mode; measure it and bound it. """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 — ``uprightness`` used to compare the platform's Y axis, so 47 of these vertices read
the rim of the dome. Aligning an edge-on face has to roll the body about that as turned over even though they only rolled about the vertical. Against the
normal, and the sign is decided by rounding, so they cannot be pushed to zero post-mount nominal up (the tool axis) no vertex ends inverted, and this now fails
and should not be asserted away. if the reference regresses rather than reporting a small count as acceptable.
""" """
stack = PlatformStack() stack = PlatformStack()
normals = bundle.vertex_normals() 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) pose = stack.solve(np.zeros(3), normal)
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5: if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
flipped += 1 flipped += 1
assert flipped <= len(normals) * 0.03, ( assert flipped == 0, f"{flipped} of {len(normals)} vertices ended turned over"
f"{flipped} of {len(normals)} vertices ended turned over"
)
def test_a_pose_reports_whether_it_is_upright(): def test_a_pose_reports_whether_it_is_upright():
+230 -2
View File
@@ -19,6 +19,7 @@ import numpy as np
import pytest import pytest
from orisys_autocal.motion.kinematics import ( from orisys_autocal.motion.kinematics import (
ALIGNED_TOL_DEG, ALIGNED_TOL_DEG,
Branch,
PlatformStack, PlatformStack,
Pose, Pose,
residual_for, residual_for,
@@ -363,6 +364,42 @@ def test_the_yaw_limit_is_respected():
assert abs(pose.yaw_deg) <= 90.0 + 1e-6 assert abs(pose.yaw_deg) <= 90.0 + 1e-6
def test_the_full_yaw_travel_keeps_the_second_solution_of_a_measured_point():
"""±180 is what keeps "cannot reach" from being reported for an aligned pose.
``docs/motion-correction-plan.zh.md`` measures a point at (-2.698, 5.186, 18.228)
with normal (-0.343, 0.699, 0.627). It has two exact solutions — -61.319°/+45.636°
and +118.681°/-45.636° — and the second sits outside the ±90 note this bench was
once configured with. At ±180 both branches are reachable, so a change in which
branch is preferred cannot strand the point on "cannot reach".
"""
point = np.array([-2.698, 5.186, 18.228])
normal = np.array([-0.343, 0.699, 0.627])
wide = PlatformStack(yaw_limit_deg=180.0, pitch_limit_deg=90.0)
narrow = PlatformStack(yaw_limit_deg=90.0, pitch_limit_deg=90.0)
unit = wide.setup_normal(normal)
unit = unit / float(np.linalg.norm(unit))
wide_branches = wide.alignment_branches(unit, wide.tool_direction)
narrow_branches = narrow.alignment_branches(unit, narrow.tool_direction)
assert {round(branch.yaw_deg, 3) for branch in wide_branches} == {-61.319, 118.681}
assert all(branch.in_range for branch in wide_branches)
assert sum(branch.in_range for branch in narrow_branches) == 1, (
"±90 was expected to drop one of the two exact solutions"
)
assert wide.solve(point, normal).in_range
def test_a_yaw_limit_past_the_trunnion_travel_is_capped():
"""The stage turns ±180. A stored 360 must not widen the envelope."""
stack = PlatformStack(yaw_limit_deg=360.0, pitch_limit_deg=90.0)
assert stack._yaw_limit_effective() == 180.0 # the cap lives here, not in the caller
for normal in NORMALS:
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
assert abs(pose.yaw_deg) <= 180.0 + 1e-6
def test_the_pitch_limit_is_respected(): def test_the_pitch_limit_is_respected():
stack = PlatformStack(pitch_limit_deg=45.0) stack = PlatformStack(pitch_limit_deg=45.0)
for normal in NORMALS: for normal in NORMALS:
@@ -476,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]) 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[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
assert final[1] == pytest.approx(stack.tool_xy_mm[1], 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(): def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
@@ -490,7 +531,12 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
moved = shifted.solve(point, normal) 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[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.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) turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
yawed = turned.solve(point, normal) yawed = turned.solve(point, normal)
@@ -516,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)) lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
raised = lifted.solve(point, normal) 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) assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
# And it is the same number as the plate parameter it replaced. # And it is the same number as the plate parameter it replaced.
assert lifted.setup_point(point)[2] == pytest.approx( assert lifted.setup_point(point)[2] == pytest.approx(
@@ -569,3 +619,181 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance():
assert loose.reachable assert loose.reachable
far = Pose(ok=True, residual_deg=30.0) far = Pose(ok=True, residual_deg=30.0)
assert not far.reached_tolerance and not far.reachable 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([])
@@ -122,6 +122,41 @@ def test_a_stored_block_without_a_new_limit_still_gets_it():
assert "press_axis" not in prefs["sensor_setup"] assert "press_axis" not in prefs["sensor_setup"]
def test_a_stored_quarter_turn_yaw_limit_is_normalised_to_the_bench_travel():
"""A stored ±90 makes a solvable pose read as unreachable.
The yaw stage turns ±180, and a measured point's second exact solution sits past
118° of yaw: at ±90 the solver is left with one branch and the move guard refuses
the other, so a pose the preview aligned reports "cannot reach". Both spellings of
the limit — the solver's ``finger_yaw_limit_deg`` and the guard's
``safety_limits.max_yaw_deg`` — are the same bench fact, so both are normalised.
"""
from orisys_app_shared.paths import preference_path
from orisys_autocal.preferences import load_preferences
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"safety_limits": {"max_yaw_deg": 90.0, "max_pitch_deg": 360.0},
"finger_yaw_limit_deg": 90.0,
"finger_pitch_limit_deg": 360.0,
}
),
encoding="utf-8",
)
prefs = load_preferences()
assert prefs["safety_limits"]["max_yaw_deg"] == 180.0
assert prefs["finger_yaw_limit_deg"] == 180.0
# The pitch envelope keeps the correction it already had, so the two do not drift.
assert prefs["safety_limits"]["max_pitch_deg"] == 90.0
assert prefs["finger_pitch_limit_deg"] == 90.0
def _walk(node, prefix: str = ""): def _walk(node, prefix: str = ""):
for key, value in node.items(): for key, value in node.items():
path = f"{prefix}{key}" path = f"{prefix}{key}"
+10 -2
View File
@@ -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 all(trial["status"] == "complete" for trial in session["trials"])
assert directory.name.startswith("finger_SN1_") assert directory.name.startswith("finger_SN1_")
from orisys_autocal.session_review import review_session from orisys_autocal.session_review import review_session
with pytest.raises(ValueError, match="finger sessions"): review = review_session(directory)
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" first = directory / "sites" / "A" / "repeat-001" / "reference.csv"
rows = list(csv.DictReader(first.open(encoding="utf-8"))) rows = list(csv.DictReader(first.open(encoding="utf-8")))
+217
View File
@@ -0,0 +1,217 @@
"""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):
import json
window = make_window()
panel = window.calibration_panel.multipoint
_add_site(panel, window.finger_stack, site_id="P1")
_add_site(panel, window.finger_stack, site_id="P2")
emitted = _watch(panel, window)
panel.campaign.start(tmp_path, sensor_model="finger", sensor_sn="SN1",
coordinate_frame="sensor", max_force_n=20.0, simulate=True)
while panel.campaign.phase == "load":
target = panel.campaign.progress()["target_n"]
panel.campaign.confirm_reference(target)
panel.campaign.complete_hold(
now_ns=panel.campaign._hold_started_ns + int(panel.campaign._hold_s * 1e9))
assert panel.campaign.phase == "liftoff"
panel._selected = "P2"
panel._refresh()
assert panel.align_button.isEnabled()
previous = json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text())
window.finger_panel.sensor_z_spin.setValue(12.0)
panel._align()
assert len(emitted) == 1
manifest = json.loads((panel.campaign.directory / "session.json").read_text())
assert manifest["sites"][1]["pose"]["z"] == emitted[0]["z"]
panel.campaign.confirm_liftoff()
repeat = json.loads((panel.campaign.directory / "sites/P2/repeat-001/session.json").read_text())
assert repeat["pose"] == manifest["sites"][1]["pose"]
assert json.loads((panel.campaign.directory / "sites/P1/repeat-001/session.json").read_text()) == previous
panel._selected = "P1"
panel._align()
assert len(emitted) == 1
panel.campaign.stop("test finished")
+78
View File
@@ -32,8 +32,10 @@ from orisys_autocal.motion.models import AxisState, AxisStatusBits, BusStatus
from orisys_autocal.motion.pose_move import ( from orisys_autocal.motion.pose_move import (
MOVE_ORDER, MOVE_ORDER,
PoseMover, PoseMover,
PressAxis,
SETTLE_DEG, SETTLE_DEG,
SETTLE_MM, SETTLE_MM,
press_targets,
) )
from orisys_autocal.motion.transport import DemoTransport from orisys_autocal.motion.transport import DemoTransport
from orisys_autocal.preferences import default_preferences from orisys_autocal.preferences import default_preferences
@@ -238,6 +240,23 @@ def test_the_guard_names_an_unknown_direction():
assert refusal is not None and "roll" in refusal.message assert refusal is not None and "roll" in refusal.message
def test_the_guard_allows_the_yaw_the_solver_calls_reachable():
"""One bench fact, two spellings: the guard must not refuse what the solver solved.
A measured point's second exact solution sits past 118° of yaw — inside the ±180
the trunnion turns, outside the ±90 note this bench was once configured with. The
solver's envelope and the guard's limit therefore have to be the same number.
"""
service = make_service()
assert service.limit_for("yaw") == 180.0
assert service.limit_for("pitch") == 90.0
assert service.check_axes_move({"yaw": 118.681}) is None
past = service.check_axes_move({"yaw": 180.0 + 0.1})
assert past is not None, "a yaw past the trunnion's travel was allowed"
# ------------------------------------------------------------------------ mover # ------------------------------------------------------------------------ mover
@@ -448,3 +467,62 @@ def test_the_units_round_trip_through_the_service():
from orisys_autocal.motion import user_units_to_deg from orisys_autocal.motion import user_units_to_deg
assert user_units_to_deg(axis, units) == pytest.approx(value) 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)
@@ -15,10 +15,23 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
APP_ROOT = Path(__file__).resolve().parents[1] APP_ROOT = Path(__file__).resolve().parents[1]
INSTALLED = APP_ROOT / "preference.json" INSTALLED = APP_ROOT / "preference.json"
@pytest.mark.parametrize("stored", [{"calibrated": True}, {"zero_mm": 12.5, "calibrated": True}])
def test_loading_incomplete_press_calibration_does_not_invent_measurements(stored):
import json
from orisys_autocal import preferences
from orisys_autocal.motion.pose_move import PressAxis
Path(preferences.path()).write_text(json.dumps({"press_axis": stored}), encoding="utf-8")
loaded = preferences.load_preferences()
assert PressAxis.from_settings(loaded["press_axis"]).target_for(20) is None
def test_the_preference_path_is_redirected_under_the_scratch_root(): def test_the_preference_path_is_redirected_under_the_scratch_root():
from orisys_autocal.preferences import path from orisys_autocal.preferences import path
@@ -5,6 +5,8 @@ import json
from copy import deepcopy from copy import deepcopy
import pytest import pytest
from review_test_helpers import wait_review
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
from PySide6.QtGui import QAction from PySide6.QtGui import QAction
@@ -155,11 +157,13 @@ def test_overview_gui_plan_click_trial_filter_and_saved_roundtrip(qt_app, overvi
qt_app.processEvents() qt_app.processEvents()
assert dialog.grab().save(str(tmp_path / "overview-response.png")) assert dialog.grab().save(str(tmp_path / "overview-response.png"))
dialog.findChild(QPushButton, "overview_save").click() dialog.findChild(QPushButton, "overview_save").click()
wait_review(dialog)
saved = next(root.glob("derived/*/review.json")) saved = next(root.glob("derived/*/review.json"))
dialog.findChild(QAction, "overview_clear_plan").trigger() dialog.findChild(QAction, "overview_clear_plan").trigger()
assert dialog.table.rowCount() == 3 assert dialog.table.rowCount() == 3
monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), "")) monkeypatch.setattr(record_overview_dialog.QFileDialog, "getOpenFileName", lambda *args: (str(saved), ""))
dialog.findChild(QAction, "overview_load").trigger() dialog.findChild(QAction, "overview_load").trigger()
wait_review(dialog)
assert dialog.table.rowCount() == 4 assert dialog.table.rowCount() == 4
dialog.close() dialog.close()
@@ -174,6 +178,7 @@ def test_calibration_review_action_opens_records_tab(make_window, overview_sessi
window.calibration_panel.review_button.click() window.calibration_panel.review_button.click()
assert window.workspace.current_key() == "records" assert window.workspace.current_key() == "records"
page = window.workspace.page("records") page = window.workspace.page("records")
wait_review(page)
assert len(page.overview.rows) == 3 assert len(page.overview.rows) == 3
assert not page.overview.isWindow() assert not page.overview.isWindow()
window.close() window.close()
@@ -192,10 +197,13 @@ def test_records_tab_browse_error_and_reload(make_window, overview_session, monk
assert page.overview is None and page.path_label.text() assert page.overview is None and page.path_label.text()
monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root)) monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args: str(root))
page.open_button.click() page.open_button.click()
wait_review(page)
first = page.overview first = page.overview
page.load_session(root / "not-a-session") page.load_session(root / "not-a-session")
wait_review(page)
assert page.overview is first and page.message.text() assert page.overview is first and page.message.text()
page.open_button.click() page.open_button.click()
wait_review(page)
assert page.overview is not first and not page.message.text() assert page.overview is not first and not page.message.text()
window.show() window.show()
qt_app.processEvents() qt_app.processEvents()
+86
View File
@@ -0,0 +1,86 @@
"""Recording integrity work must leave the UI thread free and report failures."""
import threading
from PySide6.QtCore import QObject, QTimer, Slot, QCoreApplication, QEvent, QThreadPool
from PySide6.QtTest import QTest
from orisys_autocal.ui.review_job import ReviewJob
def test_review_job_runs_off_thread_and_keeps_qt_responsive(qt_app):
started, release = threading.Event(), threading.Event()
main_thread = threading.get_ident()
results, ticks = [], []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error, threading.get_ident()))
receiver = Receiver()
def action():
started.set()
assert release.wait(3)
return threading.get_ident()
job = ReviewJob(action, receiver.receive)
job.start()
try:
assert started.wait(2)
QTimer.singleShot(0, lambda: ticks.append(True))
QTest.qWait(20)
assert ticks == [True] and results == []
finally:
release.set()
for _ in range(100):
if results:
break
QTest.qWait(10)
assert len(results) == 1
assert results[0][0] != main_thread
assert results[0][1] is None and results[0][2] == main_thread
def test_unexpected_review_job_failure_returns_to_receiver(qt_app):
results = []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error))
receiver = Receiver()
def action():
raise RuntimeError("decoder failed")
job = ReviewJob(action, receiver.receive)
job.start()
for _ in range(100):
if results:
break
QTest.qWait(10)
assert results == [(None, "decoder failed")]
def test_review_job_can_finish_after_its_receiver_is_destroyed(qt_app):
started, release, finished = threading.Event(), threading.Event(), threading.Event()
results = []
class Receiver(QObject):
@Slot(object, object)
def receive(self, value, error):
results.append((value, error))
receiver = Receiver()
def action():
started.set()
assert release.wait(3)
finished.set()
return "done"
job = ReviewJob(action, receiver.receive)
job.start()
try:
assert started.wait(2)
receiver.deleteLater()
QCoreApplication.sendPostedEvents(receiver, QEvent.Type.DeferredDelete)
finally:
release.set()
assert QThreadPool.globalInstance().waitForDone(3000)
qt_app.processEvents()
assert finished.is_set() and results == []
@@ -6,6 +6,8 @@ import csv
import json import json
import pytest import pytest
from review_test_helpers import wait_review
from orisys_autocal.calibration_session import write_json from orisys_autocal.calibration_session import write_json
from orisys_autocal.session_review import review_session, save_review from orisys_autocal.session_review import review_session, save_review
@@ -120,6 +122,7 @@ def test_review_dialog_shows_saved_windows_charts_and_new_export(qt_app, session
dialog.uv_chart.series()[0].clicked.emit(dialog.uv_chart.series()[0].at(0)) dialog.uv_chart.series()[0].clicked.emit(dialog.uv_chart.series()[0].at(0))
assert dialog.sites.currentIndex() == 0 assert dialog.sites.currentIndex() == 0
dialog.save_button.click() dialog.save_button.click()
wait_review(dialog)
assert len(list(session.glob("derived/*/review.json"))) == 1 assert len(list(session.glob("derived/*/review.json"))) == 1
dialog.show() dialog.show()
qt_app.processEvents() qt_app.processEvents()
@@ -177,11 +180,13 @@ def test_operator_confirms_windows_in_dialog_and_reloads_them(qt_app, session, m
assert dialog.review["sites"][0]["windows"][row]["stability_verified"] assert dialog.review["sites"][0]["windows"][row]["stability_verified"]
assert dialog.review["sites"][0]["confirmed_fit"] is not None assert dialog.review["sites"][0]["confirmed_fit"] is not None
dialog.save_button.click() dialog.save_button.click()
wait_review(dialog)
saved = next(session.glob("derived/*/review.json")) saved = next(session.glob("derived/*/review.json"))
fresh = session_review_dialog.SessionReviewDialog(session) fresh = session_review_dialog.SessionReviewDialog(session)
monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName", monkeypatch.setattr(session_review_dialog.QFileDialog, "getOpenFileName",
lambda *args: (str(saved), "")) lambda *args: (str(saved), ""))
fresh.load_button.click() fresh.load_button.click()
wait_review(fresh)
assert fresh.review["sites"] == dialog.review["sites"] assert fresh.review["sites"] == dialog.review["sites"]
dialog.close() dialog.close()
fresh.close() fresh.close()
@@ -0,0 +1,352 @@
"""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 review_test_helpers import wait_review
from orisys_autocal.session_review import review_session, save_review, reselect_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 test_window_adjustment_uses_loaded_traces_and_matches_fresh_review(tmp_path, monkeypatch):
import orisys_autocal.session_review as module
root = _session(tmp_path / "recording")
original = review_session(root)
w = original["sites"][0]["windows"][0]
selections = {f"{w['trial_id']}:{w['step_index']}": {
"t_start_host_mono_ns": w["hold_start_host_mono_ns"] + 10_000_000,
"t_end_host_mono_ns": w["hold_end_host_mono_ns"], "stability_verified": True}}
expected = review_session(root, selections=selections)
monkeypatch.setattr(module, "_rows", lambda *args: pytest.fail("window edit read a file"))
monkeypatch.setattr(module, "_sha256", lambda *args: pytest.fail("window edit hashed a file"))
adjusted = reselect_review(original, selections)
assert adjusted == expected
assert original["selections"] == {} and not w["stability_verified"]
assert adjusted["sites"][0]["windows"][0]["trace"] is w["trace"]
def test_saving_adjusted_review_still_rejects_modified_video(tmp_path):
root = _session(tmp_path / "recording")
adjusted = reselect_review(review_session(root), {})
video = next(root.glob("sites/*/*/capture/raw_0001.mp4"))
video.write_bytes(b"different recorded video")
with pytest.raises(ValueError, match="recorded inputs changed"):
save_review(adjusted)
assert not (root / "derived").exists()
def test_background_save_reports_changed_video_and_recovers_controls(qt_app, tmp_path, monkeypatch):
import threading
from PySide6.QtWidgets import QLabel
from orisys_autocal.ui.session_review_dialog import SessionReviewDialog
root = _session(tmp_path / "recording")
dialog = SessionReviewDialog(root)
main_thread = threading.get_ident()
updates = []
set_text = QLabel.setText
def checked_set_text(label, text):
if label is dialog.message:
updates.append(threading.get_ident())
set_text(label, text)
monkeypatch.setattr(QLabel, "setText", checked_set_text)
next(root.glob("sites/*/*/capture/raw_0001.mp4")).write_bytes(b"changed")
dialog.save_button.click()
assert not dialog.save_button.isEnabled() and not dialog.apply_button.isEnabled()
wait_review(dialog)
assert "recorded inputs changed" in dialog.message.text()
assert dialog.save_button.isEnabled() and dialog.apply_button.isEnabled()
assert len(updates) >= 2 and set(updates) == {main_thread}
assert not (root / "derived").exists()
dialog.close()
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}})
+54 -6
View File
@@ -100,8 +100,26 @@ def main() -> int:
) )
# ---- locale completeness --------------------------------------------- # ---- locale completeness ---------------------------------------------
from orisys_autocal import preferences as prefs_mod
from orisys_autocal.preferences import load_preferences 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() prefs = load_preferences()
for language in ("zh_CN", "en_US"): for language in ("zh_CN", "en_US"):
set_locale(language) set_locale(language)
@@ -162,9 +180,12 @@ def main() -> int:
print(f" {width}x{height}: the device setup fits") print(f" {width}x{height}: the device setup fits")
# ---- the permanent shell ---------------------------------------- # ---- 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( checker.that(
window.workspace.page_keys() window.workspace.page_keys()
== ["axes", "sequence", "finger", "sensor", "surface", "points"], == ["axes", "sequence", "finger", "sensor", "records"],
f"unexpected tab set: {window.workspace.page_keys()}", f"unexpected tab set: {window.workspace.page_keys()}",
) )
checker.that( checker.that(
@@ -295,16 +316,25 @@ def main() -> int:
window.axis_readout.return_button.parent() is window.axis_readout, window.axis_readout.return_button.parent() is window.axis_readout,
"the return button is not in the always-visible rail", "the return button is not in the always-visible rail",
) )
# The mapping page is the third tab of the device setup, and the service # The mapping page is the third tab of the device setup, the press-axis
# already holds the mapping it shows. # relation the fourth, and the service already holds the mapping shown.
checker.that( checker.that(
window.device_setup.tabs.count() == 3, window.device_setup.tabs.count() == 4,
f"the device setup has {window.device_setup.tabs.count()} tabs, expected 3", f"the device setup has {window.device_setup.tabs.count()} tabs, expected 4",
) )
checker.that( checker.that(
window.device_setup.tabs.tabText(2) == t("setup.tab_axes"), window.device_setup.tabs.tabText(2) == t("setup.tab_axes"),
f"the third setup tab is {window.device_setup.tabs.tabText(2)!r}", 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 # With no controller, a move must be refused with a reason and the execute
# button must say so rather than appearing to work. # button must say so rather than appearing to work.
disconnected = window.poller.service.check_axes_move({"x": 1.0}) 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", "the finger screen solved no pose from its point list",
) )
if solve is not None: 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 targets, held = solve
checker.that( checker.that(
"z" in targets and not held, "z" in targets and not held,
@@ -668,7 +716,7 @@ def main() -> int:
try: try:
window.poller.shutdown() 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 pass
window.close() window.close()
app.processEvents() app.processEvents()