优化传感器对准压头

This commit is contained in:
2026-10-15 17:13:04 +08:00
parent ad049d12e4
commit 2a154307fd
15 changed files with 565 additions and 172 deletions
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+12 -11
View File
@@ -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)))
) )
+11 -4
View File
@@ -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()
+35 -2
View File
@@ -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:
+12 -15
View File
@@ -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))
+5 -10
View File
@@ -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)
+132 -50
View File
@@ -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
+24
View File
@@ -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
+2 -2
View File
@@ -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"],