优化传感器对准压头
This commit is contained in:
@@ -59,7 +59,7 @@
|
|||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||||
"return_initial": "Return to zero",
|
"return_initial": "Return to zero",
|
||||||
"return_hint": "Yaw, pitch and press axis Z return to 0. The table parks at its initial position: X at -1.0 mm, Y 15.0 mm forward.",
|
"return_hint": "Yaw, pitch and press axis Z return to 0. The table parks at its initial position: X at 0 mm, Y at 0 mm.",
|
||||||
"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}",
|
||||||
@@ -416,6 +416,10 @@
|
|||||||
"row_table_move": "Table move X / Y",
|
"row_table_move": "Table move X / Y",
|
||||||
"row_press": "Press-axis target",
|
"row_press": "Press-axis target",
|
||||||
"row_residual": "Alignment residual",
|
"row_residual": "Alignment residual",
|
||||||
|
"row_match": "Point match",
|
||||||
|
"match_none": "Pick a point on the flat image first",
|
||||||
|
"match_offline": "Platform not connected",
|
||||||
|
"match_value": "Selected X {sx} Y {sy} mm, yaw {syaw}°, pitch {spitch}°. Actual X {ax} Y {ay} mm, yaw {ayaw}°, pitch {apitch}°. Difference X {dx} Y {dy} mm, yaw {dyaw}°, pitch {dpitch}°",
|
||||||
"row_frames": "Frame mapping",
|
"row_frames": "Frame mapping",
|
||||||
"branch_none": "—",
|
"branch_none": "—",
|
||||||
"branch_value": "{reason} (this pose {travel}° total, the other exact pose {other}°)",
|
"branch_value": "{reason} (this pose {travel}° total, the other exact pose {other}°)",
|
||||||
@@ -424,6 +428,7 @@
|
|||||||
"branch_in_range": "inside travel",
|
"branch_in_range": "inside travel",
|
||||||
"branch_upright": "face still up",
|
"branch_upright": "face still up",
|
||||||
"branch_smallest": "smallest turn",
|
"branch_smallest": "smallest turn",
|
||||||
|
"branch_crown": "already facing up; translate only",
|
||||||
"press_unset": "Zero not measured: this move holds the press axis (enter it in Device setup → Press zero)",
|
"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)",
|
"press_target": "{target} mm (geometric contact height {height} mm)",
|
||||||
"tool_xy": "Tool X / Y",
|
"tool_xy": "Tool X / Y",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
"stop": "停止",
|
"stop": "停止",
|
||||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||||
"return_initial": "回初始位",
|
"return_initial": "回初始位",
|
||||||
"return_hint": "回转、俯仰、压轴 Z 回到 0。工作台停在初始位置:X 为 -1.0 mm,Y 为向前 15.0 mm。",
|
"return_hint": "回转、俯仰、压轴 Z 回到 0。工作台停在初始位置:X 为 0 mm,Y 为 0 mm。",
|
||||||
"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请确认机床上没有干涉。",
|
||||||
@@ -416,6 +416,10 @@
|
|||||||
"row_table_move": "工作台移动 X / Y",
|
"row_table_move": "工作台移动 X / Y",
|
||||||
"row_press": "压轴目标",
|
"row_press": "压轴目标",
|
||||||
"row_residual": "对齐残差",
|
"row_residual": "对齐残差",
|
||||||
|
"row_match": "点位匹配",
|
||||||
|
"match_none": "先在展开图上选一个点",
|
||||||
|
"match_offline": "未连接平台",
|
||||||
|
"match_value": "选择 X {sx} Y {sy} mm,回转 {syaw}°,俯仰 {spitch}°。实际 X {ax} Y {ay} mm,回转 {ayaw}°,俯仰 {apitch}°。差 X {dx} Y {dy} mm,回转 {dyaw}°,俯仰 {dpitch}°",
|
||||||
"row_frames": "坐标映射",
|
"row_frames": "坐标映射",
|
||||||
"branch_none": "—",
|
"branch_none": "—",
|
||||||
"branch_value": "{reason}(本解合计 {travel}°,另一精确解 {other}°)",
|
"branch_value": "{reason}(本解合计 {travel}°,另一精确解 {other}°)",
|
||||||
@@ -424,6 +428,7 @@
|
|||||||
"branch_in_range": "行程内",
|
"branch_in_range": "行程内",
|
||||||
"branch_upright": "传感面仍朝上",
|
"branch_upright": "传感面仍朝上",
|
||||||
"branch_smallest": "转动量最小",
|
"branch_smallest": "转动量最小",
|
||||||
|
"branch_crown": "已朝上,只平移",
|
||||||
"press_unset": "未标定零位:本动作保持压轴不动(在 设备设置 → 压轴零位 填入实测零位)",
|
"press_unset": "未标定零位:本动作保持压轴不动(在 设备设置 → 压轴零位 填入实测零位)",
|
||||||
"press_target": "{target} mm(几何接触高度 {height} mm)",
|
"press_target": "{target} mm(几何接触高度 {height} mm)",
|
||||||
"tool_xy": "刀具 X / Y",
|
"tool_xy": "刀具 X / Y",
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ the contact point ends up, and Z is reported, never solved.
|
|||||||
| Item | Value | Where |
|
| Item | Value | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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°**, positive is clockwise from above | `YAW_AXIS`, `YAW_COMMAND_SIGN` |
|
||||||
| Pitch axis | `y`, travel **90°** | `PITCH_AXIS`, `finger_pitch_limit_deg` |
|
| Pitch axis | `x`, travel **90°**, positive nods the dome toward `−Y` | `PITCH_AXIS`, `PITCH_COMMAND_SIGN` |
|
||||||
| 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` |
|
||||||
|
|
||||||
### The face is aimed at +Z, and this was wrong
|
### The face is aimed at +Z, and this was wrong
|
||||||
@@ -135,20 +135,21 @@ All 1032 vertices are **in range**, and all 1032 report `upright` under the
|
|||||||
tool-axis reference task 1 introduced. Reachability is the yaw limit, not the rim:
|
tool-axis reference task 1 introduced. Reachability is the yaw limit, not the rim:
|
||||||
see *the face is aimed at +Z* above and *the two exact poses of one normal* below.
|
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 pitch axis is the left-right axis
|
||||||
|
|
||||||
`PITCH_AXIS` is `y`, while the trunnion drawing shows the pitch arc about `x`.
|
The right-hand identity
|
||||||
This turns out not to matter:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
R_y(p) · R_z(t) ≡ R_x(p) · R_z(t − 90°)
|
||||||
```
|
```
|
||||||
|
|
||||||
agreeing to machine precision in alignment for every yaw and pitch tried. So the
|
still says the two bindings can reach the same alignments. It does not say the
|
||||||
axis binding is a **naming and zero-offset choice**: the two reach exactly the
|
drive can use either one. Measured from the commissioned zero on 2026-10-15:
|
||||||
same set of alignments, and the 90° difference is absorbed when the yaw stage is
|
pitch is about platform `x`. An executed pose matched the preview only after
|
||||||
zeroed at commissioning. `PITCH_AXIS` is documentation, not a correctness
|
the pitch sign was reversed, so positive pitch is the right-hand sense about
|
||||||
question — which is why the tests pin the identity rather than a value.
|
`+X` and nods the dome toward the operator. Axis 2 at `+10°` turned the tip clockwise from above, so positive yaw is
|
||||||
|
the opposite of the right-hand sense about `+Z`. The solver, the preview and
|
||||||
|
the number sent to the drive are that commanded angle.
|
||||||
|
|
||||||
## What the solver reports
|
## What the solver reports
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ import numpy as np
|
|||||||
from ..sensor.frames import (
|
from ..sensor.frames import (
|
||||||
AXIS_MAP,
|
AXIS_MAP,
|
||||||
PITCH_AXIS,
|
PITCH_AXIS,
|
||||||
|
PITCH_COMMAND_SIGN,
|
||||||
TOOL_AXIS,
|
TOOL_AXIS,
|
||||||
YAW_AXIS,
|
YAW_AXIS,
|
||||||
|
YAW_COMMAND_SIGN,
|
||||||
|
machine_table_xy,
|
||||||
mount_rotation,
|
mount_rotation,
|
||||||
to_platform,
|
to_platform,
|
||||||
to_sensor,
|
to_sensor,
|
||||||
@@ -43,6 +46,13 @@ from ..sensor.frames import (
|
|||||||
#: A normal this close to the tool axis counts as already aligned.
|
#: A normal this close to the tool axis counts as already aligned.
|
||||||
ALIGNED_TOL_DEG = 0.01
|
ALIGNED_TOL_DEG = 0.01
|
||||||
|
|
||||||
|
#: A normal already within this many degrees of the tool axis is not yawed.
|
||||||
|
#: Pitch nods about the left-right axis, so a leftover lean along that axis can
|
||||||
|
#: only be removed by yawing a quarter-turn first. On the crown that leftover is
|
||||||
|
#: about 1.6°, and the quarter-turn turns a fore-aft table move into a sideways
|
||||||
|
#: one. Before the drive axes were measured, the same point was yaw 0 and a Y move.
|
||||||
|
CROWN_RESIDUAL_DEG = 3.0
|
||||||
|
|
||||||
#: Sweep step, degrees, for each stage of the alignment search. The sweep only
|
#: Sweep step, degrees, for each stage of the alignment search. The sweep only
|
||||||
#: has to land in the right basin — the polish that follows refines it to 1e-4 —
|
#: has to land in the right basin — the polish that follows refines it to 1e-4 —
|
||||||
#: so this trades a little precision for a sweep the eye can be told about.
|
#: so this trades a little precision for a sweep the eye can be told about.
|
||||||
@@ -214,17 +224,24 @@ class Pose:
|
|||||||
|
|
||||||
False means the geometry cannot bring the tool axis onto the normal, no
|
False means the geometry cannot bring the tool axis onto the normal, no
|
||||||
matter the angles — a statement about the machine, not about the search.
|
matter the angles — a statement about the machine, not about the search.
|
||||||
|
A crown point kept upright on purpose is reachable: the quarter-turn that
|
||||||
|
would erase the last couple of degrees is declined, not impossible.
|
||||||
"""
|
"""
|
||||||
return self.ok and self.residual_deg <= 0.5
|
if not self.ok:
|
||||||
|
return False
|
||||||
|
if self.branch_reason == "crown":
|
||||||
|
return True
|
||||||
|
return self.residual_deg <= 0.5
|
||||||
|
|
||||||
def describe(self) -> str:
|
def describe(self) -> str:
|
||||||
if not self.ok:
|
if not self.ok:
|
||||||
return f"no pose: {self.reason}"
|
return f"no pose: {self.reason}"
|
||||||
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
||||||
|
move_x, move_y = machine_table_xy(self.table_xy_mm)
|
||||||
return (
|
return (
|
||||||
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
f"X {self.target_xy_mm[0]:+.3f} Y {self.target_xy_mm[1]:+.3f} "
|
||||||
f"Z {self.contact_z_mm:+.3f} move {self.table_xy_mm[0]:+.3f} "
|
f"Z {self.contact_z_mm:+.3f} move {move_x:+.3f} "
|
||||||
f"{self.table_xy_mm[1]:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
f"{move_y:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||||
f"residual {self.residual_deg:.4f} deg"
|
f"residual {self.residual_deg:.4f} deg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -236,6 +253,12 @@ class PlatformStack:
|
|||||||
axis_map: dict[str, str] = field(default_factory=lambda: dict(AXIS_MAP))
|
axis_map: dict[str, str] = field(default_factory=lambda: dict(AXIS_MAP))
|
||||||
yaw_axis: str = YAW_AXIS
|
yaw_axis: str = YAW_AXIS
|
||||||
pitch_axis: str = PITCH_AXIS
|
pitch_axis: str = PITCH_AXIS
|
||||||
|
#: Commanded degrees times this is the right-hand angle about ``yaw_axis``.
|
||||||
|
#: ``-1`` matches the drive: positive yaw is clockwise from above.
|
||||||
|
yaw_sign: float = YAW_COMMAND_SIGN
|
||||||
|
#: Commanded degrees times this is the right-hand angle about ``pitch_axis``.
|
||||||
|
#: ``+1`` matches the drive: positive pitch nods the dome toward platform −Y.
|
||||||
|
pitch_sign: float = PITCH_COMMAND_SIGN
|
||||||
tool_axis: str = TOOL_AXIS
|
tool_axis: str = TOOL_AXIS
|
||||||
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
#: Which way along ``tool_axis`` the sensing face is aimed, relative to the
|
||||||
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
#: normal the mesh stores. It is ``+1``: the sensor sits at the bottom of its
|
||||||
@@ -294,6 +317,9 @@ class PlatformStack:
|
|||||||
for axis in (self.yaw_axis, self.pitch_axis, self.tool_axis):
|
for axis in (self.yaw_axis, self.pitch_axis, self.tool_axis):
|
||||||
if axis not in ("x", "y", "z"):
|
if axis not in ("x", "y", "z"):
|
||||||
raise ValueError(f"unknown axis {axis!r}")
|
raise ValueError(f"unknown axis {axis!r}")
|
||||||
|
for name, sign in (("yaw_sign", self.yaw_sign), ("pitch_sign", self.pitch_sign)):
|
||||||
|
if float(sign) not in (1.0, -1.0):
|
||||||
|
raise ValueError(f"{name} must be +1 or -1, got {sign}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ frames
|
# ------------------------------------------------------------------ frames
|
||||||
|
|
||||||
@@ -381,16 +407,31 @@ class PlatformStack:
|
|||||||
length = float(np.linalg.norm(normal))
|
length = float(np.linalg.norm(normal))
|
||||||
return normal if length < 1e-12 else normal / length
|
return normal if length < 1e-12 else normal / length
|
||||||
|
|
||||||
|
def _command_sign(self, axis: str) -> float:
|
||||||
|
"""``+1`` or ``-1``: commanded degrees to the right-hand angle about ``axis``."""
|
||||||
|
if axis == self.yaw_axis:
|
||||||
|
return float(self.yaw_sign)
|
||||||
|
if axis == self.pitch_axis:
|
||||||
|
return float(self.pitch_sign)
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
def yaw_rotation(self, degrees: float) -> np.ndarray:
|
||||||
|
"""The rotation a yaw command of ``degrees`` produces on the drive."""
|
||||||
|
return rotation_about(self.yaw_axis, self.yaw_sign * float(degrees))
|
||||||
|
|
||||||
|
def pitch_rotation(self, degrees: float) -> np.ndarray:
|
||||||
|
"""The rotation a pitch command of ``degrees`` produces on the drive."""
|
||||||
|
return rotation_about(self.pitch_axis, self.pitch_sign * float(degrees))
|
||||||
|
|
||||||
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
def platform_rotation(self, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
"""``R_pitch · R_yaw``: the stage rotation written in the table frame.
|
||||||
|
|
||||||
:meth:`stage_rotation` is the *same* rotation in the sensor frame — the two
|
Both angles are the numbers sent to the drives. :meth:`stage_rotation` is
|
||||||
are conjugates by the mount. Anything that reasons about where a point of
|
the same rotation in the sensor frame — the two are conjugates by the
|
||||||
the table ends up (the contact, the table's own frame) needs this one.
|
mount. Anything that reasons about where a point of the table ends up
|
||||||
|
(the contact, the table's own frame) needs this one.
|
||||||
"""
|
"""
|
||||||
return rotation_about(self.pitch_axis, pitch_deg) @ rotation_about(
|
return self.pitch_rotation(pitch_deg) @ self.yaw_rotation(yaw_deg)
|
||||||
self.yaw_axis, yaw_deg
|
|
||||||
)
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------- solve
|
# -------------------------------------------------------------------- solve
|
||||||
|
|
||||||
@@ -448,6 +489,19 @@ class PlatformStack:
|
|||||||
(branch.travel_deg for branch in usable if branch is not chosen),
|
(branch.travel_deg for branch in usable if branch is not chosen),
|
||||||
default=0.0,
|
default=0.0,
|
||||||
)
|
)
|
||||||
|
yaw_deg, pitch_deg, residual, upright, branch_reason, branch_travel, other_travel = (
|
||||||
|
self._keep_a_crown_upright(
|
||||||
|
unit,
|
||||||
|
tool,
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
)
|
||||||
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
|
||||||
@@ -481,7 +535,7 @@ class PlatformStack:
|
|||||||
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
reason = "the normal lies on the tool axis; yaw is indeterminate"
|
||||||
elif not in_range:
|
elif not in_range:
|
||||||
reason = "the pose is outside the stage travel"
|
reason = "the pose is outside the stage travel"
|
||||||
elif residual > self.tolerance_deg:
|
elif branch_reason != "crown" and residual > self.tolerance_deg:
|
||||||
reason = "no pose meets the tolerance"
|
reason = "no pose meets the tolerance"
|
||||||
|
|
||||||
# Three points, and they are three different places:
|
# Three points, and they are three different places:
|
||||||
@@ -520,6 +574,59 @@ class PlatformStack:
|
|||||||
other_travel_deg=other_travel,
|
other_travel_deg=other_travel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _keep_a_crown_upright(
|
||||||
|
self,
|
||||||
|
unit: np.ndarray,
|
||||||
|
tool: np.ndarray,
|
||||||
|
yaw_deg: float,
|
||||||
|
pitch_deg: float,
|
||||||
|
residual: float,
|
||||||
|
upright: bool,
|
||||||
|
branch_reason: str,
|
||||||
|
branch_travel: float,
|
||||||
|
other_travel: float,
|
||||||
|
) -> tuple[float, float, float, bool, str, float, float]:
|
||||||
|
"""Leave a nearly vertical normal unyawed, and move the table instead.
|
||||||
|
|
||||||
|
The exact pose of a crown point yaws about 90° so the pitch stage can nod
|
||||||
|
away a degree or two of sideways lean. That turns the point's fore-aft
|
||||||
|
offset into a sideways move. Yaw 0 and the pitch that actually helps —
|
||||||
|
none, when the lean is sideways — put the move back on Y.
|
||||||
|
"""
|
||||||
|
if abs(yaw_deg) <= 45.0:
|
||||||
|
return (
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
rest_pitch = self._pitch_for(unit, tool)
|
||||||
|
limit = self._pitch_limit_effective()
|
||||||
|
rest_pitch = max(-limit, min(limit, rest_pitch))
|
||||||
|
rest_residual = self._residual(unit, 0.0, rest_pitch)
|
||||||
|
if rest_residual > CROWN_RESIDUAL_DEG:
|
||||||
|
return (
|
||||||
|
yaw_deg,
|
||||||
|
pitch_deg,
|
||||||
|
residual,
|
||||||
|
upright,
|
||||||
|
branch_reason,
|
||||||
|
branch_travel,
|
||||||
|
other_travel,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
0.0,
|
||||||
|
rest_pitch,
|
||||||
|
rest_residual,
|
||||||
|
self.uprightness(0.0, rest_pitch) >= 0.0,
|
||||||
|
"crown",
|
||||||
|
abs(rest_pitch),
|
||||||
|
abs(yaw_deg) + abs(pitch_deg),
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------- closed-form solve
|
# ------------------------------------------------------- closed-form solve
|
||||||
|
|
||||||
def alignment_branches(
|
def alignment_branches(
|
||||||
@@ -547,7 +654,7 @@ class PlatformStack:
|
|||||||
"""
|
"""
|
||||||
unit = np.asarray(unit, dtype=np.float64)
|
unit = np.asarray(unit, dtype=np.float64)
|
||||||
tool = np.asarray(tool, dtype=np.float64)
|
tool = np.asarray(tool, dtype=np.float64)
|
||||||
pitch_stationary = self._rotation_coefficients(self.pitch_axis)[0]
|
pitch_stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
yaw_stationary = self._yaw_stationary
|
yaw_stationary = self._yaw_stationary
|
||||||
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
||||||
return []
|
return []
|
||||||
@@ -571,7 +678,7 @@ class PlatformStack:
|
|||||||
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
||||||
continue
|
continue
|
||||||
seen.add(yaw_deg)
|
seen.add(yaw_deg)
|
||||||
yawed = rotation_about(self.yaw_axis, yaw_deg) @ unit
|
yawed = self.yaw_rotation(yaw_deg) @ unit
|
||||||
pitch_deg = self._pitch_for(yawed, tool)
|
pitch_deg = self._pitch_for(yawed, tool)
|
||||||
branches.append(
|
branches.append(
|
||||||
Branch(
|
Branch(
|
||||||
@@ -604,7 +711,7 @@ class PlatformStack:
|
|||||||
to transcribe wrong.
|
to transcribe wrong.
|
||||||
"""
|
"""
|
||||||
at_zero = np.asarray(unit, dtype=np.float64)
|
at_zero = np.asarray(unit, dtype=np.float64)
|
||||||
at_ninety = rotation_about(self.yaw_axis, 90.0) @ at_zero
|
at_ninety = self.yaw_rotation(90.0) @ at_zero
|
||||||
a = float(at_zero[pinned])
|
a = float(at_zero[pinned])
|
||||||
b = float(at_ninety[pinned])
|
b = float(at_ninety[pinned])
|
||||||
|
|
||||||
@@ -641,7 +748,7 @@ class PlatformStack:
|
|||||||
best: tuple[float, float, float] | None = None
|
best: tuple[float, float, float] | None = None
|
||||||
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
step = max(SWEEP_STEP, (2.0 * self._yaw_limit_effective()) / max(samples - 1, 1))
|
||||||
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
for yaw_deg in np.arange(-self._yaw_limit_effective(), self._yaw_limit_effective() + step, step):
|
||||||
yawed = rotation_about(self.yaw_axis, float(yaw_deg)) @ unit
|
yawed = self.yaw_rotation(float(yaw_deg)) @ unit
|
||||||
pitch_deg = self._pitch_for(yawed, tool)
|
pitch_deg = self._pitch_for(yawed, tool)
|
||||||
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
pitch_deg = max(-pitch_limit, min(pitch_limit, pitch_deg))
|
||||||
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
residual = self._residual(unit, float(yaw_deg), pitch_deg)
|
||||||
@@ -657,16 +764,19 @@ class PlatformStack:
|
|||||||
two ``atan2`` arguments are the dot and cross of those two components
|
two ``atan2`` arguments are the dot and cross of those two components
|
||||||
against the tool's.
|
against the tool's.
|
||||||
"""
|
"""
|
||||||
_, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
self.pitch_axis
|
vector = np.asarray(yawed, dtype=np.float64)
|
||||||
)
|
# R(θ) = cos(θ)·I + sin(θ)·R(90) on the plane this stage turns, so the
|
||||||
u1, u2 = float(yawed[first]), float(yawed[second])
|
# angle that lands on the tool is the atan2 of those two probes. The
|
||||||
w1, w2 = float(tool[first]), float(tool[second])
|
# probes are the commanded rotation, which is what keeps a sign change
|
||||||
# turned[first] = u1·c + sign_first ·u2·s
|
# on the drive from needing its own copy of the matrix.
|
||||||
# turned[second] = sign_second·u1·s + u2·c
|
before = vector.copy()
|
||||||
cosine_term = u1 * w1 + sign_second * u2 * w2
|
after = self.pitch_rotation(90.0) @ vector
|
||||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
before[stationary] = 0.0
|
||||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
after[stationary] = 0.0
|
||||||
|
target = np.asarray(tool, dtype=np.float64).copy()
|
||||||
|
target[stationary] = 0.0
|
||||||
|
return math.degrees(math.atan2(float(after @ target), float(before @ target)))
|
||||||
|
|
||||||
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.
|
||||||
@@ -693,15 +803,27 @@ class PlatformStack:
|
|||||||
@property
|
@property
|
||||||
def _yaw_stationary(self) -> int:
|
def _yaw_stationary(self) -> int:
|
||||||
"""The component the yaw leaves alone (its own axis)."""
|
"""The component the yaw leaves alone (its own axis)."""
|
||||||
return self._rotation_coefficients(self.yaw_axis)[0]
|
return self._signed_coefficients(self.yaw_axis)[0]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _yaw_sign_first(self) -> float:
|
def _yaw_sign_first(self) -> float:
|
||||||
return self._rotation_coefficients(self.yaw_axis)[3]
|
return self._signed_coefficients(self.yaw_axis)[3]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _yaw_sign_second(self) -> float:
|
def _yaw_sign_second(self) -> float:
|
||||||
return self._rotation_coefficients(self.yaw_axis)[4]
|
return self._signed_coefficients(self.yaw_axis)[4]
|
||||||
|
|
||||||
|
def _signed_coefficients(
|
||||||
|
self, axis: str
|
||||||
|
) -> tuple[int, int, int, float, float]:
|
||||||
|
"""Right-hand coefficients with the drive's command sign folded in.
|
||||||
|
|
||||||
|
A command sign of ``-1`` flips the two sine terms, which is the same
|
||||||
|
rotation as feeding ``rotation_about`` the negated angle.
|
||||||
|
"""
|
||||||
|
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(axis)
|
||||||
|
sign = self._command_sign(axis)
|
||||||
|
return stationary, first, second, sign_first * sign, sign_second * sign
|
||||||
|
|
||||||
def _rotation_coefficients(
|
def _rotation_coefficients(
|
||||||
self, axis: str
|
self, axis: str
|
||||||
@@ -733,7 +855,7 @@ class PlatformStack:
|
|||||||
yaw = math.radians(yaw_deg)
|
yaw = math.radians(yaw_deg)
|
||||||
yaw_cos, yaw_sin = math.cos(yaw), math.sin(yaw)
|
yaw_cos, yaw_sin = math.cos(yaw), math.sin(yaw)
|
||||||
yawed = [0.0, 0.0, 0.0]
|
yawed = [0.0, 0.0, 0.0]
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.yaw_axis
|
self.yaw_axis
|
||||||
)
|
)
|
||||||
yawed[stationary] = unit[stationary]
|
yawed[stationary] = unit[stationary]
|
||||||
@@ -743,7 +865,7 @@ class PlatformStack:
|
|||||||
pitch = math.radians(pitch_deg)
|
pitch = math.radians(pitch_deg)
|
||||||
pitch_cos, pitch_sin = math.cos(pitch), math.sin(pitch)
|
pitch_cos, pitch_sin = math.cos(pitch), math.sin(pitch)
|
||||||
turned = [0.0, 0.0, 0.0]
|
turned = [0.0, 0.0, 0.0]
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.pitch_axis
|
self.pitch_axis
|
||||||
)
|
)
|
||||||
turned[stationary] = yawed[stationary]
|
turned[stationary] = yawed[stationary]
|
||||||
@@ -777,7 +899,7 @@ class PlatformStack:
|
|||||||
"""
|
"""
|
||||||
angle = np.radians(np.asarray(angle_deg, dtype=np.float64))
|
angle = np.radians(np.asarray(angle_deg, dtype=np.float64))
|
||||||
vector = np.asarray(vector, dtype=np.float64)
|
vector = np.asarray(vector, dtype=np.float64)
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(axis)
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(axis)
|
||||||
cosine, sine = np.cos(angle), np.sin(angle)
|
cosine, sine = np.cos(angle), np.sin(angle)
|
||||||
|
|
||||||
turned = np.empty((angle.size, 3), dtype=np.float64)
|
turned = np.empty((angle.size, 3), dtype=np.float64)
|
||||||
@@ -839,27 +961,20 @@ class PlatformStack:
|
|||||||
``O(n_yaw)`` instead of ``O(n_yaw * n_pitch)``, which is the whole cost of
|
``O(n_yaw)`` instead of ``O(n_yaw * n_pitch)``, which is the whole cost of
|
||||||
the exhaustive grid below.
|
the exhaustive grid below.
|
||||||
|
|
||||||
The coefficients are written out by axis name rather than through the
|
The sine coefficient is the same probe :meth:`_pitch_for` uses: a
|
||||||
``first``/``second`` rotation ordering, because it is the *cross* term
|
commanded 90° pitch. Writing it out per axis name returned the antipode
|
||||||
that carries the sign and getting that backwards returns the antipode —
|
once the pitch axis moved off ``y``.
|
||||||
an angle 180 deg away that looks entirely plausible. The signs here were
|
|
||||||
fixed against a direct numeric scan, and
|
|
||||||
``test_the_closed_form_pitch_beats_a_direct_scan`` keeps them that way.
|
|
||||||
"""
|
"""
|
||||||
tool = np.asarray(self.tool_direction, dtype=np.float64)
|
tool = np.asarray(self.tool_direction, dtype=np.float64)
|
||||||
axis = self.pitch_axis
|
|
||||||
others = [name for name in ("x", "y", "z") if name != axis]
|
|
||||||
leading, trailing = others[0], others[1]
|
|
||||||
lead, trail = ("x", "y", "z").index(leading), ("x", "y", "z").index(trailing)
|
|
||||||
|
|
||||||
yawed = self._turn(self.yaw_axis, yaws, unit)
|
yawed = self._turn(self.yaw_axis, yaws, unit)
|
||||||
cosine_coefficient = (
|
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||||
yawed[:, lead] * tool[lead] + yawed[:, trail] * tool[trail]
|
before = np.array(yawed, copy=True)
|
||||||
)
|
after = yawed @ self.pitch_rotation(90.0).T
|
||||||
sine_coefficient = (
|
before[:, stationary] = 0.0
|
||||||
yawed[:, trail] * tool[lead] - yawed[:, lead] * tool[trail]
|
after[:, stationary] = 0.0
|
||||||
)
|
target = np.array(tool, copy=True)
|
||||||
return np.degrees(np.arctan2(sine_coefficient, cosine_coefficient))
|
target[stationary] = 0.0
|
||||||
|
return np.degrees(np.arctan2(after @ target, before @ target))
|
||||||
|
|
||||||
def _grid_reference(
|
def _grid_reference(
|
||||||
self, unit: np.ndarray, yaws: np.ndarray, pitches: np.ndarray
|
self, unit: np.ndarray, yaws: np.ndarray, pitches: np.ndarray
|
||||||
@@ -884,7 +999,7 @@ class PlatformStack:
|
|||||||
yawed = self._turn(self.yaw_axis, yaws, unit)
|
yawed = self._turn(self.yaw_axis, yaws, unit)
|
||||||
pitch = np.radians(np.asarray(pitches, dtype=np.float64))
|
pitch = np.radians(np.asarray(pitches, dtype=np.float64))
|
||||||
cosine, sine = np.cos(pitch), np.sin(pitch)
|
cosine, sine = np.cos(pitch), np.sin(pitch)
|
||||||
stationary, first, second, sign_first, sign_second = self._rotation_coefficients(
|
stationary, first, second, sign_first, sign_second = self._signed_coefficients(
|
||||||
self.pitch_axis
|
self.pitch_axis
|
||||||
)
|
)
|
||||||
turned = np.empty_like(yawed)
|
turned = np.empty_like(yawed)
|
||||||
@@ -1021,12 +1136,7 @@ class PlatformStack:
|
|||||||
this way. One definition, so the two cannot disagree.
|
this way. One definition, so the two cannot disagree.
|
||||||
"""
|
"""
|
||||||
mount = mount_rotation(self.axis_map)
|
mount = mount_rotation(self.axis_map)
|
||||||
return (
|
return mount.T @ self.platform_rotation(yaw_deg, pitch_deg) @ mount
|
||||||
mount.T
|
|
||||||
@ rotation_about(self.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(self.yaw_axis, yaw_deg)
|
|
||||||
@ mount
|
|
||||||
)
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -1236,11 +1346,7 @@ def residual_for(
|
|||||||
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
||||||
length = max(float(np.linalg.norm(normal)), 1e-12)
|
length = max(float(np.linalg.norm(normal)), 1e-12)
|
||||||
unit = normal / length
|
unit = normal / length
|
||||||
rotated = (
|
rotated = stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
rotation_about(stack.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
|
||||||
@ unit
|
|
||||||
)
|
|
||||||
return float(
|
return float(
|
||||||
np.degrees(np.arccos(np.clip(float(rotated @ stack.tool_direction), -1.0, 1.0)))
|
np.degrees(np.arccos(np.clip(float(rotated @ stack.tool_direction), -1.0, 1.0)))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -248,14 +248,17 @@ def default_preferences() -> dict[str, Any]:
|
|||||||
#: lifts the sensing face and does nothing else, and two fields for one
|
#: lifts the sensing face and does nothing else, and two fields for one
|
||||||
#: number drift apart. The bench plate under the finger is 8 mm thick.
|
#: number drift apart. The bench plate under the finger is 8 mm thick.
|
||||||
"finger_sensor_offset_mm": [0.0, 0.0, 8.0],
|
"finger_sensor_offset_mm": [0.0, 0.0, 8.0],
|
||||||
|
# Tip points forward, away from the operator. 180° turned it back toward
|
||||||
|
# the operator; the preview then showed the green tip on the near side.
|
||||||
"finger_sensor_yaw_deg": 0.0,
|
"finger_sensor_yaw_deg": 0.0,
|
||||||
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
|
#: Where the tool is, in the table frame, mm. A tool-tip offset belongs
|
||||||
#: here. The bench X shift is the initial position, not this number.
|
#: here. The bench X shift is the initial position, not this number.
|
||||||
"finger_tool_xy_mm": [0.0, 3.0],
|
"finger_tool_xy_mm": [0.0, 3.0],
|
||||||
#: Where 「回初始位」 parks the table, mm. X is 1 mm toward the negative
|
#: Where 「回初始位」 parks the table, mm. After the power cycle the
|
||||||
#: direction. Y is 15.0 mm forward of the old zero.
|
#: coordinate origin sits on the previous park, so this is the origin:
|
||||||
"table_initial_x_mm": -1.0,
|
#: one millimetre back in X and 15 mm back in Y from the old target.
|
||||||
"table_initial_y_mm": 15.0,
|
"table_initial_x_mm": 0.0,
|
||||||
|
"table_initial_y_mm": 0.0,
|
||||||
#: Stage travel, degrees. Yaw turns ±180. Pitch is held to ±90 because the
|
#: Stage travel, degrees. Yaw turns ±180. Pitch is held to ±90 because the
|
||||||
#: fixture interferes outside that.
|
#: fixture interferes outside that.
|
||||||
"finger_yaw_limit_deg": 180.0,
|
"finger_yaw_limit_deg": 180.0,
|
||||||
@@ -312,6 +315,10 @@ def load_preferences() -> dict[str, Any]:
|
|||||||
if not offset[2]:
|
if not offset[2]:
|
||||||
offset[2] = float(defaults["finger_sensor_offset_mm"][2])
|
offset[2] = float(defaults["finger_sensor_offset_mm"][2])
|
||||||
prefs["finger_sensor_offset_mm"] = offset
|
prefs["finger_sensor_offset_mm"] = offset
|
||||||
|
# The mounted shell needs no extra yaw: the tip points forward, away from
|
||||||
|
# the operator. A running session still has 180° in memory and writes it
|
||||||
|
# back on close, which would turn the tip toward the operator again.
|
||||||
|
prefs["finger_sensor_yaw_deg"] = float(defaults["finger_sensor_yaw_deg"])
|
||||||
|
|
||||||
# The tool tip offset used to be a vector added to the reported coordinates
|
# The tool tip offset used to be a vector added to the reported coordinates
|
||||||
# while the drawing ignored it, so the picture and the numbers disagreed by
|
# while the drawing ignored it, so the picture and the numbers disagreed by
|
||||||
|
|||||||
@@ -200,14 +200,21 @@ def load_bundle(
|
|||||||
|
|
||||||
housing_vertices, housing_faces, part_names = _load_housing(root / "housing.npz", warnings)
|
housing_vertices, housing_faces, part_names = _load_housing(root / "housing.npz", warnings)
|
||||||
camera_k, camera_size = _load_intrinsics(root / "camera_intrinsics.npz")
|
camera_k, camera_size = _load_intrinsics(root / "camera_intrinsics.npz")
|
||||||
|
vertices_mm = vertices * mm_per_unit
|
||||||
|
housing_mm = housing_vertices * mm_per_unit
|
||||||
|
# The mesh zero sits near the tail. Move it onto the tail's mounting-face
|
||||||
|
# midpoint, which is the point aligned with the platform origin.
|
||||||
|
origin = housing_tail_origin_mm(housing_mm, vertices_mm)
|
||||||
|
vertices_mm = vertices_mm - origin
|
||||||
|
housing_mm = housing_mm - origin
|
||||||
|
|
||||||
return SensorBundle(
|
return SensorBundle(
|
||||||
path=root,
|
path=root,
|
||||||
vertices_mm=vertices * mm_per_unit,
|
vertices_mm=vertices_mm,
|
||||||
faces=faces,
|
faces=faces,
|
||||||
uv_data=uv_data,
|
uv_data=uv_data,
|
||||||
loop_to_vertex=loop_to_vertex,
|
loop_to_vertex=loop_to_vertex,
|
||||||
housing_vertices_mm=housing_vertices * mm_per_unit,
|
housing_vertices_mm=housing_mm,
|
||||||
housing_faces=housing_faces,
|
housing_faces=housing_faces,
|
||||||
housing_part_names=part_names,
|
housing_part_names=part_names,
|
||||||
camera_k=camera_k,
|
camera_k=camera_k,
|
||||||
@@ -218,6 +225,37 @@ def load_bundle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def housing_tail_origin_mm(housing_mm: np.ndarray, sensing_mm: np.ndarray) -> np.ndarray:
|
||||||
|
"""Mounting-face center of the housing end the sensing surface does not cover.
|
||||||
|
|
||||||
|
That end is the shell tail. Its base midpoint is the sensor origin: the
|
||||||
|
point placed on the platform's rotation axis.
|
||||||
|
"""
|
||||||
|
housing_mm = np.asarray(housing_mm, dtype=np.float64)
|
||||||
|
sensing_mm = np.asarray(sensing_mm, dtype=np.float64)
|
||||||
|
if housing_mm.size == 0 or sensing_mm.size == 0:
|
||||||
|
return np.zeros(3, dtype=np.float64)
|
||||||
|
depth = housing_mm[:, 2]
|
||||||
|
low = housing_mm[depth <= float(depth.min()) + 1.0]
|
||||||
|
high = housing_mm[depth >= float(depth.max()) - 1.0]
|
||||||
|
sense_depth = sensing_mm[:, 2]
|
||||||
|
low_gap = float(sense_depth.min() - low[:, 2].mean())
|
||||||
|
high_gap = float(high[:, 2].mean() - sense_depth.max())
|
||||||
|
end = low if low_gap >= high_gap else high
|
||||||
|
base_height = float(end[:, 1].min())
|
||||||
|
base = end[end[:, 1] <= base_height + 0.3]
|
||||||
|
if base.size == 0:
|
||||||
|
base = end
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
0.5 * (float(base[:, 0].min()) + float(base[:, 0].max())),
|
||||||
|
float(np.median(base[:, 1])),
|
||||||
|
float(np.median(base[:, 2])),
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def mm_per_unit_from_pose(world_to_cam: np.ndarray) -> float:
|
def mm_per_unit_from_pose(world_to_cam: np.ndarray) -> float:
|
||||||
"""The millimetres-per-mesh-unit factor, derived from the pose.
|
"""The millimetres-per-mesh-unit factor, derived from the pose.
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,47 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
#: Which platform axis each sensor axis points along, and in which sign.
|
#: Which platform axis each sensor axis points along, and in which sign.
|
||||||
#: ``"z": "-y"`` keeps the frame right-handed: sensor (x, y, z) → platform
|
#: The bench (2026-10-15) showed both table commands reversed: a point further
|
||||||
#: (x, z, -y), whose determinant is +1 (verified by a test).
|
#: along the shell (sensor +Z, toward the tip) must move the table toward
|
||||||
AXIS_MAP: dict[str, str] = {"x": "x", "y": "z", "z": "-y"}
|
#: machine −Y, and a point on sensor +X must move it toward machine +X.
|
||||||
|
#: Sensor +Z is machine +Y and sensor +Y is machine +Z. Sensor +X is machine
|
||||||
|
#: −X, which is what keeps this a rotation (det +1) rather than a mirror.
|
||||||
|
#: Sensor (x, y, z) → platform (−x, z, y).
|
||||||
|
AXIS_MAP: dict[str, str] = {"x": "-x", "y": "z", "z": "y"}
|
||||||
|
|
||||||
#: Which platform axis each stage turns about. The stages are stacked on the XY
|
#: Which platform axis each stage turns about. The stages are stacked on the XY
|
||||||
#: table, so both are expressed in the *platform* frame.
|
#: table, so both are expressed in the *platform* frame.
|
||||||
|
#:
|
||||||
|
#: The positive direction is the controller's, measured 2026-10-15 from the
|
||||||
|
#: commissioned zero (face up, tip toward platform +Y):
|
||||||
|
#:
|
||||||
|
#: * yaw (axis 2) about ``z``. ``+10°`` turned the tip clockwise viewed from
|
||||||
|
#: above, which is the opposite of the right-hand sense about ``+Z``.
|
||||||
|
#: * pitch (axis 3) about ``x``, the left-right axis. It is not a roll about
|
||||||
|
#: the tip axis. An executed pose matched the preview only with this sign
|
||||||
|
#: reversed, so positive pitch is the right-hand sense about ``+X``: the dome
|
||||||
|
#: nods toward the operator (platform ``−Y``).
|
||||||
|
#:
|
||||||
|
#: ``YAW_COMMAND_SIGN`` and ``PITCH_COMMAND_SIGN`` convert a commanded angle into
|
||||||
|
#: the right-hand angle :func:`orisys_autocal.motion.kinematics.rotation_about`
|
||||||
|
#: expects. The preview, the solver and the drive all use the commanded angle.
|
||||||
YAW_AXIS = "z"
|
YAW_AXIS = "z"
|
||||||
PITCH_AXIS = "y"
|
YAW_COMMAND_SIGN = -1.0
|
||||||
|
PITCH_AXIS = "x"
|
||||||
|
PITCH_COMMAND_SIGN = 1.0
|
||||||
|
|
||||||
|
#: Model table X to the X motor. Yaw and pitch already match the drives, and Y
|
||||||
|
#: was checked (+5 mm toward the gantry). The X slide did not: cancelling the
|
||||||
|
#: yaw swing in model +X pushed the contact further off the probe. The preview
|
||||||
|
#: keeps the model move, so the drawn contact stays on the tool; only the number
|
||||||
|
#: sent to the drive, and the readout compared with the live axis, uses this sign.
|
||||||
|
TABLE_X_COMMAND_SIGN = -1.0
|
||||||
|
|
||||||
|
#: How much of the model X slide the motor should actually travel. On the two
|
||||||
|
#: side points already run (table X about 11 mm) the probe stopped about 1 mm
|
||||||
|
#: past the contact, on both sides, in the direction of that slide. 10/11 takes
|
||||||
|
#: that millimetre off. The preview still draws the full model slide.
|
||||||
|
TABLE_X_GAIN = 10.0 / 11.0
|
||||||
|
|
||||||
#: Platform axes, in the order the pose reports them.
|
#: Platform axes, in the order the pose reports them.
|
||||||
PLATFORM_AXES: tuple[str, ...] = ("x", "y", "z")
|
PLATFORM_AXES: tuple[str, ...] = ("x", "y", "z")
|
||||||
@@ -81,8 +114,22 @@ def to_sensor(vectors_mm: np.ndarray, axis_map: dict[str, str] | None = None) ->
|
|||||||
return array @ matrix
|
return array @ matrix
|
||||||
|
|
||||||
|
|
||||||
|
def machine_table_xy(table_xy_mm: tuple[float, float]) -> tuple[float, float]:
|
||||||
|
"""The table move the drives must make, in millimetres.
|
||||||
|
|
||||||
|
``table_xy_mm`` is the solver's move in the model frame. X is multiplied by
|
||||||
|
:data:`TABLE_X_COMMAND_SIGN` because the motor's positive direction is
|
||||||
|
opposite that frame, then by :data:`TABLE_X_GAIN` because that slide runs
|
||||||
|
about a millimetre past the real contact. Y is unchanged.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
TABLE_X_COMMAND_SIGN * TABLE_X_GAIN * float(table_xy_mm[0]),
|
||||||
|
float(table_xy_mm[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def describe(axis_map: dict[str, str] | None = None) -> str:
|
def describe(axis_map: dict[str, str] | None = None) -> str:
|
||||||
"""``sensor x→platform x, y→z, z→-y`` — shown in the UI so a flip is visible."""
|
"""``sensor x→-x, y→z, z→y`` — shown in the UI so a flip is visible."""
|
||||||
mapping = dict(axis_map or AXIS_MAP)
|
mapping = dict(axis_map or AXIS_MAP)
|
||||||
return "sensor " + ", ".join(
|
return "sensor " + ", ".join(
|
||||||
f"{axis}->{mapping.get(axis, '?')}" for axis in ("x", "y", "z")
|
f"{axis}->{mapping.get(axis, '?')}" for axis in ("x", "y", "z")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
|
|||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
from ..multipoint import Campaign, next_point_id
|
from ..multipoint import Campaign, next_point_id
|
||||||
|
from ..sensor.frames import machine_table_xy
|
||||||
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
|
#: A stored point and the mapping's answer for the same UV are the same point when
|
||||||
@@ -291,9 +292,10 @@ class MultipointPanel(QWidget):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _pose_of(solved, stack) -> dict:
|
def _pose_of(solved, stack) -> dict:
|
||||||
"""The record of one solve: the targets, and what the solver said of them."""
|
"""The record of one solve: the targets, and what the solver said of them."""
|
||||||
|
move_x, move_y = machine_table_xy(solved.table_xy_mm)
|
||||||
return {
|
return {
|
||||||
"x": float(solved.table_xy_mm[0]),
|
"x": float(move_x),
|
||||||
"y": float(solved.table_xy_mm[1]),
|
"y": float(move_y),
|
||||||
"yaw": float(solved.yaw_deg),
|
"yaw": float(solved.yaw_deg),
|
||||||
"pitch": float(solved.pitch_deg),
|
"pitch": float(solved.pitch_deg),
|
||||||
"z": float(solved.contact_z_mm),
|
"z": float(solved.contact_z_mm),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ from ..motion.kinematics import PlatformStack
|
|||||||
from ..motion.pose_move import PressAxis, press_targets
|
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.frames import machine_table_xy
|
||||||
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||||
|
|
||||||
#: How many picked points the list keeps before the oldest is dropped.
|
#: How many picked points the list keeps before the oldest is dropped.
|
||||||
@@ -377,6 +378,7 @@ class FingerPanel(QGroupBox):
|
|||||||
"table_move",
|
"table_move",
|
||||||
"press",
|
"press",
|
||||||
"residual",
|
"residual",
|
||||||
|
"match",
|
||||||
"frames",
|
"frames",
|
||||||
)
|
)
|
||||||
for row, key in enumerate(rows):
|
for row, key in enumerate(rows):
|
||||||
@@ -386,7 +388,7 @@ class FingerPanel(QGroupBox):
|
|||||||
value.setObjectName("statusValue")
|
value.setObjectName("statusValue")
|
||||||
value.setWordWrap(True)
|
value.setWordWrap(True)
|
||||||
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
# These values are long ("sensor x->x, y->z, z->-y"). Allowed to drive
|
# These values are long ("sensor x->-x, y->z, z->y"). Allowed to drive
|
||||||
# their own width they claimed most of the row and squeezed the UV
|
# their own width they claimed most of the row and squeezed the UV
|
||||||
# picker beside them down to a thumbnail; wrapping keeps them readable
|
# picker beside them down to a thumbnail; wrapping keeps them readable
|
||||||
# at whatever width the row can spare.
|
# at whatever width the row can spare.
|
||||||
@@ -771,7 +773,7 @@ class FingerPanel(QGroupBox):
|
|||||||
pose = self._last_pose
|
pose = self._last_pose
|
||||||
if pose is None:
|
if pose is None:
|
||||||
return None
|
return None
|
||||||
table_x, table_y = pose.table_xy_mm
|
table_x, table_y = machine_table_xy(pose.table_xy_mm)
|
||||||
targets = {
|
targets = {
|
||||||
"x": float(table_x),
|
"x": float(table_x),
|
||||||
"y": float(table_y),
|
"y": float(table_y),
|
||||||
@@ -868,6 +870,7 @@ class FingerPanel(QGroupBox):
|
|||||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||||
pose = self._stack.solve(point, normal)
|
pose = self._stack.solve(point, normal)
|
||||||
|
move_x, move_y = machine_table_xy(pose.table_xy_mm)
|
||||||
points.append({
|
points.append({
|
||||||
"site_id": f"site-{index:03d}",
|
"site_id": f"site-{index:03d}",
|
||||||
"x": float(point[0]),
|
"x": float(point[0]),
|
||||||
@@ -879,8 +882,8 @@ class FingerPanel(QGroupBox):
|
|||||||
"u_px": record.get("u_px"),
|
"u_px": record.get("u_px"),
|
||||||
"v_px": record.get("v_px"),
|
"v_px": record.get("v_px"),
|
||||||
"pose": {
|
"pose": {
|
||||||
"x": float(pose.table_xy_mm[0]),
|
"x": float(move_x),
|
||||||
"y": float(pose.table_xy_mm[1]),
|
"y": float(move_y),
|
||||||
"yaw": float(pose.yaw_deg),
|
"yaw": float(pose.yaw_deg),
|
||||||
"pitch": float(pose.pitch_deg),
|
"pitch": float(pose.pitch_deg),
|
||||||
"z": float(pose.contact_z_mm),
|
"z": float(pose.contact_z_mm),
|
||||||
@@ -1246,6 +1249,7 @@ class FingerPanel(QGroupBox):
|
|||||||
|
|
||||||
_last_pose = None
|
_last_pose = None
|
||||||
_last_face = -1
|
_last_face = -1
|
||||||
|
_actual_pose: tuple[float, float, float, float] | None = None
|
||||||
|
|
||||||
def refresh_readouts(self, point=None, normal=None, pose=None, face: int = -1) -> None:
|
def refresh_readouts(self, point=None, normal=None, pose=None, face: int = -1) -> None:
|
||||||
self._readout_labels["frames"].setText(describe_frames(self._stack.axis_map))
|
self._readout_labels["frames"].setText(describe_frames(self._stack.axis_map))
|
||||||
@@ -1278,10 +1282,9 @@ class FingerPanel(QGroupBox):
|
|||||||
self._readout_labels["platform_point"].setText(
|
self._readout_labels["platform_point"].setText(
|
||||||
" ".join(f"{value:+.3f}" for value in platform) + " mm"
|
" ".join(f"{value:+.3f}" for value in platform) + " mm"
|
||||||
)
|
)
|
||||||
|
move_x, move_y = (0.0, 0.0) if pose is None else machine_table_xy(pose.table_xy_mm)
|
||||||
self._readout_labels["table_move"].setText(
|
self._readout_labels["table_move"].setText(
|
||||||
"—"
|
"—" if pose is None else f"{move_x:+.3f} {move_y:+.3f} mm"
|
||||||
if pose is None
|
|
||||||
else f"{pose.table_xy_mm[0]:+.3f} {pose.table_xy_mm[1]:+.3f} mm"
|
|
||||||
)
|
)
|
||||||
if pose is not None:
|
if pose is not None:
|
||||||
self._readout_labels["pose"].setText(pose.describe())
|
self._readout_labels["pose"].setText(pose.describe())
|
||||||
@@ -1298,8 +1301,55 @@ class FingerPanel(QGroupBox):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._refresh_match()
|
||||||
del face
|
del face
|
||||||
|
|
||||||
|
def set_actual_pose(
|
||||||
|
self, pose: tuple[float, float, float, float] | None
|
||||||
|
) -> None:
|
||||||
|
"""Live platform pose, ``(x_mm, y_mm, yaw_deg, pitch_deg)``, or none."""
|
||||||
|
self._actual_pose = pose
|
||||||
|
self._refresh_match()
|
||||||
|
|
||||||
|
def _refresh_match(self) -> None:
|
||||||
|
"""Selected pose against the live axes. The tail origin is on the platform axis."""
|
||||||
|
label = self._readout_labels.get("match")
|
||||||
|
if label is None:
|
||||||
|
return
|
||||||
|
pose = self._last_pose
|
||||||
|
actual = self._actual_pose
|
||||||
|
if pose is None:
|
||||||
|
label.setText(t("finger.match_none"))
|
||||||
|
return
|
||||||
|
if actual is None:
|
||||||
|
label.setText(t("finger.match_offline"))
|
||||||
|
return
|
||||||
|
move_x, move_y = machine_table_xy(pose.table_xy_mm)
|
||||||
|
selected = (
|
||||||
|
float(move_x),
|
||||||
|
float(move_y),
|
||||||
|
float(pose.yaw_deg),
|
||||||
|
float(pose.pitch_deg),
|
||||||
|
)
|
||||||
|
delta = tuple(actual[index] - selected[index] for index in range(4))
|
||||||
|
label.setText(
|
||||||
|
t(
|
||||||
|
"finger.match_value",
|
||||||
|
sx=f"{selected[0]:+.3f}",
|
||||||
|
sy=f"{selected[1]:+.3f}",
|
||||||
|
syaw=f"{selected[2]:+.3f}",
|
||||||
|
spitch=f"{selected[3]:+.3f}",
|
||||||
|
ax=f"{actual[0]:+.3f}",
|
||||||
|
ay=f"{actual[1]:+.3f}",
|
||||||
|
ayaw=f"{actual[2]:+.3f}",
|
||||||
|
apitch=f"{actual[3]:+.3f}",
|
||||||
|
dx=f"{delta[0]:+.3f}",
|
||||||
|
dy=f"{delta[1]:+.3f}",
|
||||||
|
dyaw=f"{delta[2]:+.3f}",
|
||||||
|
dpitch=f"{delta[3]:+.3f}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _refresh_path(self) -> None:
|
def _refresh_path(self) -> None:
|
||||||
"""Report what the planned motion does, and where it actually arrives.
|
"""Report what the planned motion does, and where it actually arrives.
|
||||||
|
|
||||||
@@ -1377,6 +1427,7 @@ class FingerPanel(QGroupBox):
|
|||||||
"table_move": "finger.row_table_move",
|
"table_move": "finger.row_table_move",
|
||||||
"press": "finger.row_press",
|
"press": "finger.row_press",
|
||||||
"residual": "finger.row_residual",
|
"residual": "finger.row_residual",
|
||||||
|
"match": "finger.row_match",
|
||||||
"frames": "finger.row_frames",
|
"frames": "finger.row_frames",
|
||||||
}
|
}
|
||||||
grid = self._readout_box.layout()
|
grid = self._readout_box.layout()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ the ctypes handle single-threaded.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -47,7 +48,7 @@ from ..motion.pose_move import (
|
|||||||
press_targets,
|
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, user_units_to_deg, user_units_to_mm
|
||||||
from ..preferences import load_preferences, save_preferences
|
from ..preferences import load_preferences, save_preferences
|
||||||
from ..sensor import (
|
from ..sensor import (
|
||||||
BundleError,
|
BundleError,
|
||||||
@@ -869,6 +870,36 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
"""Signal handler for the poller's periodic state read."""
|
"""Signal handler for the poller's periodic state read."""
|
||||||
self.update_snapshot(snapshot)
|
self.update_snapshot(snapshot)
|
||||||
|
|
||||||
|
def _report_point_match(self, snapshot: Snapshot) -> None:
|
||||||
|
"""Selected pose versus the live axes. Offsets are actual minus selected."""
|
||||||
|
if not snapshot.connected:
|
||||||
|
self.finger_panel.set_actual_pose(None)
|
||||||
|
return
|
||||||
|
roles = self.prefs.get("axis_roles") or {}
|
||||||
|
|
||||||
|
def reading(role: str, rotary: bool) -> float | None:
|
||||||
|
axis = roles.get(role)
|
||||||
|
if axis is None:
|
||||||
|
return None
|
||||||
|
state = snapshot.axis(int(axis))
|
||||||
|
if state is None or not state.read_ok:
|
||||||
|
return None
|
||||||
|
if rotary:
|
||||||
|
return user_units_to_deg(int(axis), state.dpos)
|
||||||
|
units = state.units or float(self.prefs.get("units_per_mm") or DEFAULT_UNITS)
|
||||||
|
return user_units_to_mm(state.dpos, units=units)
|
||||||
|
|
||||||
|
values = (
|
||||||
|
reading("x", False),
|
||||||
|
reading("y", False),
|
||||||
|
reading("yaw", True),
|
||||||
|
reading("pitch", True),
|
||||||
|
)
|
||||||
|
if any(value is None or not math.isfinite(value) for value in values):
|
||||||
|
self.finger_panel.set_actual_pose(None)
|
||||||
|
return
|
||||||
|
self.finger_panel.set_actual_pose(tuple(float(value) for value in values)) # type: ignore[arg-type]
|
||||||
|
|
||||||
def update_snapshot(self, snapshot: Snapshot) -> None:
|
def update_snapshot(self, snapshot: Snapshot) -> None:
|
||||||
self._last_snapshot = snapshot
|
self._last_snapshot = snapshot
|
||||||
self.connection_panel.set_connected(
|
self.connection_panel.set_connected(
|
||||||
@@ -894,6 +925,7 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
self.finger_panel.set_move_available(
|
self.finger_panel.set_move_available(
|
||||||
self._move_available(), moving=self.mover.running
|
self._move_available(), moving=self.mover.running
|
||||||
)
|
)
|
||||||
|
self._report_point_match(snapshot)
|
||||||
# The strip is outside the tabs, so it is fed on every poll regardless
|
# The strip is outside the tabs, so it is fed on every poll regardless
|
||||||
# of which tab is showing.
|
# of which tab is showing.
|
||||||
self.status_strip.set_snapshot(snapshot)
|
self.status_strip.set_snapshot(snapshot)
|
||||||
@@ -1103,7 +1135,8 @@ class AutoCalMainWindow(QMainWindow):
|
|||||||
when the bench has measured how its coordinate relates to a contact
|
when the bench has measured how its coordinate relates to a contact
|
||||||
height; otherwise the panel hands back that role as held, so the move
|
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
|
carries the roles it can justify and says which one it left alone. The
|
||||||
numbers in the confirmation are the ones the preview drew.
|
confirmation shows those drive targets. Table X carries the motor sign;
|
||||||
|
the preview keeps the model slide, which is what puts the contact on the tool.
|
||||||
"""
|
"""
|
||||||
solve = self.finger_panel.solved_pose()
|
solve = self.finger_panel.solved_pose()
|
||||||
if solve is None:
|
if solve is None:
|
||||||
|
|||||||
@@ -169,19 +169,14 @@ def test_the_initial_view_is_the_commissioned_orientation(qt_app, bundle):
|
|||||||
f"sensor +Y must point up the screen, but moved {tip[1] - origin[1]:+.1f} px"
|
f"sensor +Y must point up the screen, but moved {tip[1] - origin[1]:+.1f} px"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sensor +Z is the sensing face's own axis and must face the viewer. It is
|
# The commissioned camera sits in front of the shell and a little below
|
||||||
# the platform's -Y on this mount. ``forward`` points from the eye into the
|
# it, looking back toward platform −Y. That is the ad049d12 preview.
|
||||||
# scene, so a surface normal aimed *at* the camera has a positive dot with
|
|
||||||
# it — the opposite of what the depth term in a projection does, and the
|
|
||||||
# sign this assertion got wrong first time.
|
|
||||||
eye, _distance = preview._camera() # noqa: SLF001
|
eye, _distance = preview._camera() # noqa: SLF001
|
||||||
|
centre = preview._view_target() # noqa: SLF001
|
||||||
_centre, forward, _right, _up = preview._view_basis(eye) # noqa: SLF001
|
_centre, forward, _right, _up = preview._view_basis(eye) # noqa: SLF001
|
||||||
lens_axis = to_sensor(np.array([0.0, -1.0, 0.0]), stack.axis_map)
|
assert eye[1] > centre[1]
|
||||||
lens_axis = lens_axis / np.linalg.norm(lens_axis)
|
assert eye[2] < centre[2]
|
||||||
assert float(lens_axis @ forward) > 0.2, (
|
assert forward[1] < -0.5, f"the view must look toward −Y, but forward is {forward}"
|
||||||
f"sensor +Z must face the viewer, but points away: "
|
|
||||||
f"{float(lens_axis @ forward):+.3f}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
preview.deleteLater()
|
preview.deleteLater()
|
||||||
|
|
||||||
@@ -1253,8 +1248,8 @@ def test_the_tool_position_sets_the_move_and_not_the_reading(panel):
|
|||||||
def test_the_readouts_name_the_frame_mapping(panel):
|
def test_the_readouts_name_the_frame_mapping(panel):
|
||||||
"""The one line the bench will want to check when x and y are interchanged."""
|
"""The one line the bench will want to check when x and y are interchanged."""
|
||||||
text = panel._readout_labels["frames"].text() # noqa: SLF001
|
text = panel._readout_labels["frames"].text() # noqa: SLF001
|
||||||
assert "x" in text and "z" in text
|
assert "x->-x" in text
|
||||||
assert "-y" in text or "−y" in text
|
assert "z->y" in text
|
||||||
|
|
||||||
|
|
||||||
def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
|
def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
|
||||||
@@ -1271,7 +1266,7 @@ def test_the_readout_names_the_chosen_pose_and_the_key_that_chose_it(panel):
|
|||||||
|
|
||||||
pose = panel._last_pose # noqa: SLF001
|
pose = panel._last_pose # noqa: SLF001
|
||||||
text = panel._readout_labels["branch"].text() # noqa: SLF001
|
text = panel._readout_labels["branch"].text() # noqa: SLF001
|
||||||
assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest"}
|
assert pose.branch_reason in {"only", "alignment", "in_range", "upright", "smallest", "crown"}
|
||||||
assert t(f"finger.branch_{pose.branch_reason}") in text
|
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.
|
# And the alternative's cost is on screen too, not just the chosen one's.
|
||||||
assert f"{pose.other_travel_deg:.1f}" in text
|
assert f"{pose.other_travel_deg:.1f}" in text
|
||||||
@@ -1294,7 +1289,9 @@ def test_the_press_readout_says_why_it_is_held_and_then_names_the_target(panel):
|
|||||||
targets, held = panel.solved_pose()
|
targets, held = panel.solved_pose()
|
||||||
assert held == ("z",)
|
assert held == ("z",)
|
||||||
assert "z" not in targets
|
assert "z" not in targets
|
||||||
assert targets["x"] == pytest.approx(pose.table_xy_mm[0])
|
from orisys_autocal.sensor.frames import TABLE_X_GAIN
|
||||||
|
|
||||||
|
assert targets["x"] == pytest.approx(-TABLE_X_GAIN * pose.table_xy_mm[0])
|
||||||
|
|
||||||
# With the bench's zero entered, the same contact height becomes a coordinate.
|
# 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))
|
panel.set_press(PressAxis(zero_mm=2.0, rising=True, calibrated=True))
|
||||||
|
|||||||
@@ -39,12 +39,8 @@ def _random_units(count: int, seed: int) -> list[np.ndarray]:
|
|||||||
|
|
||||||
|
|
||||||
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
"""Re-apply a returned pose with the library's own rotations."""
|
"""Re-apply a returned pose with the same rotation the drives execute."""
|
||||||
return (
|
return stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
rotation_about(stack.pitch_axis, pitch_deg)
|
|
||||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
|
||||||
@ unit
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ exactness
|
# ------------------------------------------------------------------ exactness
|
||||||
@@ -108,11 +104,10 @@ def test_the_two_methods_agree_inside_the_envelope():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_two_pitch_bindings_are_the_same_family():
|
def test_the_two_pitch_bindings_are_the_same_family():
|
||||||
"""Confirmed at the bench: a 90 degree yaw takes one binding to the other.
|
"""``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` reach the same alignments.
|
||||||
|
|
||||||
``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` agree to machine precision. So
|
This is a property of the right-hand matrices. The drive does not get to
|
||||||
which axis is called pitch is a naming and zero-offset choice, not a
|
pick either binding: pitch is about ``x`` with the measured command sign.
|
||||||
correctness question — the two reach exactly the same set of alignments.
|
|
||||||
"""
|
"""
|
||||||
unit = np.array([0.3, 0.5, -0.81])
|
unit = np.array([0.3, 0.5, -0.81])
|
||||||
unit = unit / np.linalg.norm(unit)
|
unit = unit / np.linalg.norm(unit)
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ def _brute_force_best(
|
|||||||
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
||||||
best = 1e9
|
best = 1e9
|
||||||
for yaw in np.arange(-yaw_limit, yaw_limit + step, step):
|
for yaw in np.arange(-yaw_limit, yaw_limit + step, step):
|
||||||
turned = rotation_about(stack.yaw_axis, float(yaw)) @ unit_platform
|
turned = stack.yaw_rotation(float(yaw)) @ unit_platform
|
||||||
for pitch in np.arange(-pitch_limit, pitch_limit + step, step):
|
for pitch in np.arange(-pitch_limit, pitch_limit + step, step):
|
||||||
rotated = rotation_about(stack.pitch_axis, float(pitch)) @ turned
|
rotated = stack.pitch_rotation(float(pitch)) @ turned
|
||||||
residual = float(np.degrees(np.arccos(np.clip(rotated @ tool, -1.0, 1.0))))
|
residual = float(np.degrees(np.arccos(np.clip(rotated @ tool, -1.0, 1.0))))
|
||||||
best = min(best, residual)
|
best = min(best, residual)
|
||||||
return best
|
return best
|
||||||
@@ -77,13 +77,78 @@ def test_the_axis_map_is_a_rotation():
|
|||||||
assert np.allclose(matrix @ matrix.T, np.eye(3))
|
assert np.allclose(matrix @ matrix.T, np.eye(3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_crown_point_moves_along_y_and_does_not_yaw():
|
||||||
|
"""The top face used to be a Y move. A 1.6° sideways lean must not yaw 90°."""
|
||||||
|
stack = PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0), pitch_limit_deg=90.0)
|
||||||
|
point = np.array([0.150, 8.355, 5.483])
|
||||||
|
normal = np.array([0.028, 1.0, 0.0])
|
||||||
|
normal = normal / np.linalg.norm(normal)
|
||||||
|
pose = stack.solve(point, normal)
|
||||||
|
assert pose.yaw_deg == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert pose.pitch_deg == pytest.approx(0.0, abs=1e-6)
|
||||||
|
assert pose.table_xy_mm[1] == pytest.approx(-5.483, abs=0.02)
|
||||||
|
assert abs(pose.table_xy_mm[0]) < 0.5
|
||||||
|
assert pose.branch_reason == "crown"
|
||||||
|
assert pose.reachable
|
||||||
|
assert pose.reason == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sideways_normal_still_yaws():
|
||||||
|
"""A lean the pitch stage cannot nod away still takes the exact yaw."""
|
||||||
|
stack = PlatformStack()
|
||||||
|
normal = np.array([0.7, 0.7, 0.1])
|
||||||
|
normal = normal / np.linalg.norm(normal)
|
||||||
|
pose = stack.solve(np.zeros(3), normal)
|
||||||
|
assert pose.residual_deg < 1e-4
|
||||||
|
assert abs(pose.yaw_deg) > 20.0
|
||||||
|
assert pose.branch_reason != "crown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive_stage_commands_match_the_drives():
|
||||||
|
"""Bench 2026-10-15, one axis at a time from the commissioned zero.
|
||||||
|
|
||||||
|
Yaw +10° turned the tip clockwise viewed from above. Positive pitch nods the
|
||||||
|
dome toward the operator (platform −Y). The preview uses this same rotation,
|
||||||
|
so a commanded angle draws the motion the drive will make.
|
||||||
|
"""
|
||||||
|
stack = PlatformStack()
|
||||||
|
assert stack.yaw_axis == "z" and stack.pitch_axis == "x"
|
||||||
|
assert np.allclose(stack.platform_rotation(0.0, 0.0), np.eye(3))
|
||||||
|
|
||||||
|
tip = stack.platform_rotation(10.0, 0.0) @ np.array([0.0, 1.0, 0.0])
|
||||||
|
# Clockwise from above: the tip, pointing at +Y, swings toward +X.
|
||||||
|
assert tip[0] > 0.0
|
||||||
|
assert tip[1] > 0.0
|
||||||
|
assert abs(tip[2]) < 1e-12
|
||||||
|
|
||||||
|
dome = stack.platform_rotation(0.0, 10.0) @ np.array([0.0, 0.0, 1.0])
|
||||||
|
# A nod, not a roll: the face leans toward −Y and does not pick up +X.
|
||||||
|
assert dome[1] < 0.0
|
||||||
|
assert abs(dome[0]) < 1e-12
|
||||||
|
assert dome[2] > 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_the_sensor_y_maps_to_the_platform_z():
|
def test_the_sensor_y_maps_to_the_platform_z():
|
||||||
"""The bench's frame relation, stated once so it can be corrected once."""
|
"""The bench's frame relation, stated once so it can be corrected once."""
|
||||||
assert np.allclose(to_platform(np.array([0.0, 1.0, 0.0])), [0.0, 0.0, 1.0])
|
assert np.allclose(to_platform(np.array([0.0, 1.0, 0.0])), [0.0, 0.0, 1.0])
|
||||||
|
|
||||||
|
|
||||||
def test_the_sensor_x_maps_to_the_platform_x():
|
def test_the_sensor_x_maps_to_the_platform_minus_x():
|
||||||
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [1.0, 0.0, 0.0])
|
"""Both table axes were reversed on the bench. −X keeps the map a rotation."""
|
||||||
|
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [-1.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sensor_z_maps_to_the_platform_y():
|
||||||
|
"""Shell length and machine Y point the same way, so the tip is +Y."""
|
||||||
|
assert np.allclose(to_platform(np.array([0.0, 0.0, 1.0])), [0.0, 1.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tip_side_point_commands_negative_table_y():
|
||||||
|
"""Bringing a point toward the tip under the tool moves the table to −Y."""
|
||||||
|
pose = PlatformStack().solve(np.array([0.0, 8.0, 6.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
assert pose.table_xy_mm[1] == pytest.approx(-6.0)
|
||||||
|
side = PlatformStack().solve(np.array([2.0, 8.0, 0.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
assert side.table_xy_mm[0] == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
def test_the_mount_round_trips():
|
def test_the_mount_round_trips():
|
||||||
@@ -105,7 +170,7 @@ def test_an_incomplete_map_is_refused():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_mapping_is_described_for_the_ui():
|
def test_the_mapping_is_described_for_the_ui():
|
||||||
assert describe() == "sensor x->x, y->z, z->-y"
|
assert describe() == "sensor x->-x, y->z, z->y"
|
||||||
|
|
||||||
|
|
||||||
def test_swapping_the_mapping_changes_the_answer():
|
def test_swapping_the_mapping_changes_the_answer():
|
||||||
@@ -155,9 +220,7 @@ def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
|
|||||||
unit = unit / np.linalg.norm(unit)
|
unit = unit / np.linalg.norm(unit)
|
||||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
||||||
|
|
||||||
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
|
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ unit
|
||||||
stack.yaw_axis, pose.yaw_deg
|
|
||||||
) @ unit
|
|
||||||
landed = landed / np.linalg.norm(landed)
|
landed = landed / np.linalg.norm(landed)
|
||||||
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
|
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
|
||||||
# And it gets there without turning: the commissioned pose is the identity.
|
# And it gets there without turning: the commissioned pose is the identity.
|
||||||
@@ -241,7 +304,7 @@ def test_the_closed_form_pitch_beats_a_direct_scan():
|
|||||||
tool = stack.tool_direction
|
tool = stack.tool_direction
|
||||||
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
|
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
|
||||||
stationary, first, second, sign_first, sign_second = (
|
stationary, first, second, sign_first, sign_second = (
|
||||||
stack._rotation_coefficients(stack.pitch_axis) # noqa: SLF001
|
stack._signed_coefficients(stack.pitch_axis) # noqa: SLF001
|
||||||
)
|
)
|
||||||
dot = (
|
dot = (
|
||||||
yawed[stationary] * tool[stationary]
|
yawed[stationary] * tool[stationary]
|
||||||
@@ -262,28 +325,11 @@ def _independent_residual(
|
|||||||
"""The residual worked out from raw matrices built here, not by the solver.
|
"""The residual worked out from raw matrices built here, not by the solver.
|
||||||
|
|
||||||
``residual_for`` shares ``rotation_about`` with the solver, so it cannot catch
|
``residual_for`` shares ``rotation_about`` with the solver, so it cannot catch
|
||||||
an error in that shared helper. This builds the two 3x3 matrices from the
|
an error in that shared helper. The matrices live in ``_independent_rotation``.
|
||||||
rotation formulas directly, so the two paths agree only if both are right.
|
|
||||||
"""
|
"""
|
||||||
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
||||||
unit = normal / np.linalg.norm(normal)
|
unit = normal / np.linalg.norm(normal)
|
||||||
|
turned = _independent_rotation(yaw_deg, pitch_deg) @ unit
|
||||||
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)],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
turned = about_pitch @ about_yaw @ unit
|
|
||||||
return float(
|
return float(
|
||||||
np.degrees(np.arccos(np.clip(float(turned @ stack.tool_direction), -1.0, 1.0)))
|
np.degrees(np.arccos(np.clip(float(turned @ stack.tool_direction), -1.0, 1.0)))
|
||||||
)
|
)
|
||||||
@@ -368,10 +414,10 @@ 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.
|
"""±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)
|
``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°
|
with normal (-0.343, 0.699, 0.627). Under the drive's rotation it has two exact
|
||||||
and +118.681°/-45.636° — and the second sits outside the ±90 note this bench was
|
solutions, -28.681° and +151.319° of yaw, and the second sits outside the ±90
|
||||||
once configured with. At ±180 both branches are reachable, so a change in which
|
note this bench was once configured with. At ±180 both branches are reachable,
|
||||||
branch is preferred cannot strand the point on "cannot reach".
|
so a change in which branch is preferred cannot strand the point on "cannot reach".
|
||||||
"""
|
"""
|
||||||
point = np.array([-2.698, 5.186, 18.228])
|
point = np.array([-2.698, 5.186, 18.228])
|
||||||
normal = np.array([-0.343, 0.699, 0.627])
|
normal = np.array([-0.343, 0.699, 0.627])
|
||||||
@@ -383,7 +429,7 @@ def test_the_full_yaw_travel_keeps_the_second_solution_of_a_measured_point():
|
|||||||
wide_branches = wide.alignment_branches(unit, wide.tool_direction)
|
wide_branches = wide.alignment_branches(unit, wide.tool_direction)
|
||||||
narrow_branches = narrow.alignment_branches(unit, narrow.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 {round(branch.yaw_deg, 3) for branch in wide_branches} == {-28.681, 151.319}
|
||||||
assert all(branch.in_range for branch in wide_branches)
|
assert all(branch.in_range for branch in wide_branches)
|
||||||
assert sum(branch.in_range for branch in narrow_branches) == 1, (
|
assert sum(branch.in_range for branch in narrow_branches) == 1, (
|
||||||
"±90 was expected to drop one of the two exact solutions"
|
"±90 was expected to drop one of the two exact solutions"
|
||||||
@@ -493,6 +539,20 @@ def test_the_default_tool_is_above_the_table_origin():
|
|||||||
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
|
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_drive_x_is_opposite_the_model_slide_and_a_little_shorter():
|
||||||
|
"""The X motor is opposite the model, and the slide is 10/11 of it.
|
||||||
|
|
||||||
|
The preview keeps the full model slide, so the drawn contact stays on the tool.
|
||||||
|
"""
|
||||||
|
from orisys_autocal.sensor.frames import TABLE_X_GAIN, machine_table_xy
|
||||||
|
|
||||||
|
pose = PlatformStack().solve(np.array([3.0, 8.0, 1.0]), np.array([0.0, 1.0, 0.0]))
|
||||||
|
drive_x, drive_y = machine_table_xy(pose.table_xy_mm)
|
||||||
|
assert drive_x == pytest.approx(-TABLE_X_GAIN * pose.table_xy_mm[0])
|
||||||
|
assert drive_y == pytest.approx(pose.table_xy_mm[1])
|
||||||
|
assert f"{drive_x:+.3f}" in pose.describe()
|
||||||
|
|
||||||
|
|
||||||
def test_the_table_move_lands_the_contact_on_the_tool():
|
def test_the_table_move_lands_the_contact_on_the_tool():
|
||||||
"""The one invariant the whole machine exists to satisfy."""
|
"""The one invariant the whole machine exists to satisfy."""
|
||||||
stack = PlatformStack(
|
stack = PlatformStack(
|
||||||
@@ -531,12 +591,11 @@ 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)
|
||||||
# An in-plane offset leaves the *mounting* height alone, but not the height the
|
# An in-plane offset leaves the mounting height alone. The stages then present
|
||||||
# stages present: the same angles now lift a contact that sits further from the
|
# a different height, because the same angles tilt a contact that sits somewhere
|
||||||
# trunnion axis. This line used to compare contact_z_mm on both sides, which
|
# else relative to the pitch axis.
|
||||||
# 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.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||||||
assert moved.contact_z_mm - base.contact_z_mm > 0.1
|
assert abs(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)
|
||||||
@@ -546,9 +605,19 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
|
|||||||
)
|
)
|
||||||
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
|
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||||
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
||||||
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
|
# Mounting yaw is right-handed about Z. Positive stage yaw is the opposite
|
||||||
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
|
# sense, so the same-pitch branch's command rises by the mounting angle.
|
||||||
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
|
# The shorter branch may be the other one, a half-turn away.
|
||||||
|
unit = turned.setup_normal(normal)
|
||||||
|
unit = unit / float(np.linalg.norm(unit))
|
||||||
|
same_pitch = [
|
||||||
|
branch
|
||||||
|
for branch in turned.alignment_branches(unit, turned.tool_direction)
|
||||||
|
if branch.pitch_deg == pytest.approx(base.pitch_deg)
|
||||||
|
]
|
||||||
|
assert len(same_pitch) == 1
|
||||||
|
shift = (same_pitch[0].yaw_deg - base.yaw_deg + 180.0) % 360.0 - 180.0
|
||||||
|
assert shift == pytest.approx(35.0, abs=1e-9)
|
||||||
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
|
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
@@ -574,12 +643,22 @@ def test_the_setup_z_is_the_mounting_plate():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_bench_mount_points_the_tip_forward():
|
||||||
|
"""Yaw 180 put the tip on the near side. Zero yaw points it away."""
|
||||||
|
stack = PlatformStack(sensor_yaw_deg=0.0)
|
||||||
|
tip = stack.setup_point(np.array([0.0, 0.0, 1.0]))
|
||||||
|
cable = stack.setup_point(np.array([0.0, 0.0, -1.0]))
|
||||||
|
assert tip[:2] == pytest.approx((0.0, 1.0))
|
||||||
|
assert cable[:2] == pytest.approx((0.0, -1.0))
|
||||||
|
assert stack.setup_point(np.array([0.0, 1.0, 0.0]))[2] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
|
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
|
||||||
stack = PlatformStack(sensor_yaw_deg=90.0)
|
stack = PlatformStack(sensor_yaw_deg=90.0)
|
||||||
normal = np.array([1.0, 0.0, 0.0])
|
normal = np.array([1.0, 0.0, 0.0])
|
||||||
# Sensor +x is platform +x; a 90 degree mounting yaw sends it to platform +y.
|
# Sensor +x is platform −x; a +90° mounting yaw about Z sends it to platform −y.
|
||||||
assert np.allclose(stack.setup_normal(normal), [0.0, 1.0, 0.0], atol=1e-12)
|
assert np.allclose(stack.setup_normal(normal), [0.0, -1.0, 0.0], atol=1e-12)
|
||||||
assert stack.normal_to_platform(normal) == pytest.approx([1.0, 0.0, 0.0])
|
assert stack.normal_to_platform(normal) == pytest.approx([-1.0, 0.0, 0.0])
|
||||||
|
|
||||||
|
|
||||||
def test_a_non_finite_sensor_yaw_is_refused():
|
def test_a_non_finite_sensor_yaw_is_refused():
|
||||||
@@ -625,20 +704,23 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance():
|
|||||||
|
|
||||||
|
|
||||||
def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||||
"""``R_pitch @ R_yaw`` from the formulas, not through ``rotation_about``."""
|
"""``R_pitch @ R_yaw`` for the drive directions, not through ``rotation_about``.
|
||||||
|
|
||||||
|
Positive yaw is clockwise from above. Positive pitch nods ``+Z`` toward ``−Y``.
|
||||||
|
"""
|
||||||
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
||||||
about_yaw = np.array(
|
about_yaw = np.array(
|
||||||
[
|
[
|
||||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
[np.cos(yaw), np.sin(yaw), 0.0],
|
||||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
[-np.sin(yaw), np.cos(yaw), 0.0],
|
||||||
[0.0, 0.0, 1.0],
|
[0.0, 0.0, 1.0],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
about_pitch = np.array(
|
about_pitch = np.array(
|
||||||
[
|
[
|
||||||
[np.cos(pitch), 0.0, np.sin(pitch)],
|
[1.0, 0.0, 0.0],
|
||||||
[0.0, 1.0, 0.0],
|
[0.0, np.cos(pitch), -np.sin(pitch)],
|
||||||
[-np.sin(pitch), 0.0, np.cos(pitch)],
|
[0.0, np.sin(pitch), np.cos(pitch)],
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return about_pitch @ about_yaw
|
return about_pitch @ about_yaw
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
from orisys_autocal.sensor.export_bundle import (
|
from orisys_autocal.sensor.export_bundle import (
|
||||||
BundleError,
|
BundleError,
|
||||||
|
housing_tail_origin_mm,
|
||||||
load_bundle,
|
load_bundle,
|
||||||
mm_per_unit_from_pose,
|
mm_per_unit_from_pose,
|
||||||
)
|
)
|
||||||
@@ -116,6 +117,29 @@ def test_the_surface_area_is_physically_plausible(bundle):
|
|||||||
assert bundle.surface_area_mm2 == pytest.approx(355.77, abs=1.0)
|
assert bundle.surface_area_mm2 == pytest.approx(355.77, abs=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_origin_is_the_unsensed_housing_tail():
|
||||||
|
"""The tail is the housing end the sensing patch does not cover."""
|
||||||
|
housing = np.array(
|
||||||
|
[
|
||||||
|
[-2.0, 0.0, 0.0],
|
||||||
|
[2.0, 0.0, 0.0],
|
||||||
|
[0.0, 4.0, 0.2],
|
||||||
|
[-1.0, 0.1, 20.0],
|
||||||
|
[1.0, 0.1, 20.2],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sensing = np.array([[0.0, 2.0, 5.0], [0.0, 2.0, 19.8]])
|
||||||
|
origin = housing_tail_origin_mm(housing, sensing)
|
||||||
|
assert origin[0] == pytest.approx(0.0)
|
||||||
|
assert origin[1] == pytest.approx(0.0)
|
||||||
|
assert origin[2] == pytest.approx(0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_loaded_bundle_puts_the_tail_on_the_origin(bundle):
|
||||||
|
origin = housing_tail_origin_mm(bundle.housing_vertices_mm, bundle.vertices_mm)
|
||||||
|
assert origin == pytest.approx(np.zeros(3), abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
def test_the_stored_normals_are_coherent(bundle):
|
def test_the_stored_normals_are_coherent(bundle):
|
||||||
"""Mixed winding would make 'the normal' meaningless."""
|
"""Mixed winding would make 'the normal' meaningless."""
|
||||||
normals = bundle.face_normals
|
normals = bundle.face_normals
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ STRINGS = {
|
|||||||
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||||
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
|
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
|
||||||
["对轴残差", "0.03° 可对轴", "#12b76a"],
|
["对轴残差", "0.03° 可对轴", "#12b76a"],
|
||||||
["坐标系映射", "x→x, y→z, z→-y", None],
|
["坐标系映射", "x→-x, y→z, z→y", None],
|
||||||
],
|
],
|
||||||
"finger_tool_label": "刀具 X / Y",
|
"finger_tool_label": "刀具 X / Y",
|
||||||
"finger_tool_values": ["0.000", "0.000"],
|
"finger_tool_values": ["0.000", "0.000"],
|
||||||
@@ -349,7 +349,7 @@ STRINGS = {
|
|||||||
["Pose X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
["Pose X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||||
["Table move X / Y", "+2.841 -0.940 mm", None],
|
["Table move X / Y", "+2.841 -0.940 mm", None],
|
||||||
["Residual", "0.03° can align", "#12b76a"],
|
["Residual", "0.03° can align", "#12b76a"],
|
||||||
["Frame map", "x→x, y→z, z→-y", None],
|
["Frame map", "x→-x, y→z, z→y", None],
|
||||||
],
|
],
|
||||||
"finger_tool_label": "Tool X / Y",
|
"finger_tool_label": "Tool X / Y",
|
||||||
"finger_tool_values": ["0.000", "0.000"],
|
"finger_tool_values": ["0.000", "0.000"],
|
||||||
|
|||||||
Reference in New Issue
Block a user