优化传感器对准压头
This commit is contained in:
@@ -59,7 +59,7 @@
|
||||
"stop": "Stop",
|
||||
"stop_hint": "Stop every axis now (the same command as 全部停止 in the strip).",
|
||||
"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_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}",
|
||||
@@ -416,6 +416,10 @@
|
||||
"row_table_move": "Table move X / Y",
|
||||
"row_press": "Press-axis target",
|
||||
"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",
|
||||
"branch_none": "—",
|
||||
"branch_value": "{reason} (this pose {travel}° total, the other exact pose {other}°)",
|
||||
@@ -424,6 +428,7 @@
|
||||
"branch_in_range": "inside travel",
|
||||
"branch_upright": "face still up",
|
||||
"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_target": "{target} mm (geometric contact height {height} mm)",
|
||||
"tool_xy": "Tool X / Y",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"stop": "停止",
|
||||
"stop_hint": "立即停止所有轴(与顶栏「全部停止」相同)。",
|
||||
"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_execute": "将同时移动:\n\n{steps}\n\n请确认仿真与机床一致。",
|
||||
"confirm_return": "将同时移回零点:\n\n{steps}\n\n请确认机床上没有干涉。",
|
||||
@@ -416,6 +416,10 @@
|
||||
"row_table_move": "工作台移动 X / Y",
|
||||
"row_press": "压轴目标",
|
||||
"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": "坐标映射",
|
||||
"branch_none": "—",
|
||||
"branch_value": "{reason}(本解合计 {travel}°,另一精确解 {other}°)",
|
||||
@@ -424,6 +428,7 @@
|
||||
"branch_in_range": "行程内",
|
||||
"branch_upright": "传感面仍朝上",
|
||||
"branch_smallest": "转动量最小",
|
||||
"branch_crown": "已朝上,只平移",
|
||||
"press_unset": "未标定零位:本动作保持压轴不动(在 设备设置 → 压轴零位 填入实测零位)",
|
||||
"press_target": "{target} mm(几何接触高度 {height} mm)",
|
||||
"tool_xy": "刀具 X / Y",
|
||||
|
||||
@@ -16,10 +16,10 @@ the contact point ends up, and Z is reported, never solved.
|
||||
| Item | Value | Where |
|
||||
|---|---|---|
|
||||
| Tool axis | `z` | `motion/frames.py` |
|
||||
| Yaw axis | `z`, travel **±180°** | `YAW_AXIS`, `finger_yaw_limit_deg` |
|
||||
| Pitch axis | `y`, travel **90°** | `PITCH_AXIS`, `finger_pitch_limit_deg` |
|
||||
| Yaw axis | `z`, travel **±180°**, positive is clockwise from above | `YAW_AXIS`, `YAW_COMMAND_SIGN` |
|
||||
| Pitch axis | `x`, travel **90°**, positive nods the dome toward `−Y` | `PITCH_AXIS`, `PITCH_COMMAND_SIGN` |
|
||||
| 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` |
|
||||
|
||||
### 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:
|
||||
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`.
|
||||
This turns out not to matter:
|
||||
The right-hand identity
|
||||
|
||||
```
|
||||
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
|
||||
axis binding is a **naming and zero-offset choice**: the two reach exactly the
|
||||
same set of alignments, and the 90° difference is absorbed when the yaw stage is
|
||||
zeroed at commissioning. `PITCH_AXIS` is documentation, not a correctness
|
||||
question — which is why the tests pin the identity rather than a value.
|
||||
still says the two bindings can reach the same alignments. It does not say the
|
||||
drive can use either one. Measured from the commissioned zero on 2026-10-15:
|
||||
pitch is about platform `x`. An executed pose matched the preview only after
|
||||
the pitch sign was reversed, so positive pitch is the right-hand sense about
|
||||
`+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
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ import numpy as np
|
||||
from ..sensor.frames import (
|
||||
AXIS_MAP,
|
||||
PITCH_AXIS,
|
||||
PITCH_COMMAND_SIGN,
|
||||
TOOL_AXIS,
|
||||
YAW_AXIS,
|
||||
YAW_COMMAND_SIGN,
|
||||
machine_table_xy,
|
||||
mount_rotation,
|
||||
to_platform,
|
||||
to_sensor,
|
||||
@@ -43,6 +46,13 @@ from ..sensor.frames import (
|
||||
#: A normal this close to the tool axis counts as already aligned.
|
||||
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
|
||||
#: 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.
|
||||
@@ -214,17 +224,24 @@ class Pose:
|
||||
|
||||
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.
|
||||
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:
|
||||
if not self.ok:
|
||||
return f"no pose: {self.reason}"
|
||||
yaw = f"{self.yaw_deg:+.3f}" if self.yaw_determined else "indeterminate"
|
||||
move_x, move_y = machine_table_xy(self.table_xy_mm)
|
||||
return (
|
||||
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"{self.table_xy_mm[1]:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
f"Z {self.contact_z_mm:+.3f} move {move_x:+.3f} "
|
||||
f"{move_y:+.3f} yaw {yaw} pitch {self.pitch_deg:+.3f} "
|
||||
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))
|
||||
yaw_axis: str = YAW_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
|
||||
#: 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
|
||||
@@ -294,6 +317,9 @@ class PlatformStack:
|
||||
for axis in (self.yaw_axis, self.pitch_axis, self.tool_axis):
|
||||
if axis not in ("x", "y", "z"):
|
||||
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
|
||||
|
||||
@@ -381,16 +407,31 @@ class PlatformStack:
|
||||
length = float(np.linalg.norm(normal))
|
||||
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:
|
||||
"""``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
|
||||
are conjugates by the mount. Anything that reasons about where a point of
|
||||
the table ends up (the contact, the table's own frame) needs this one.
|
||||
Both angles are the numbers sent to the drives. :meth:`stage_rotation` is
|
||||
the same rotation in the sensor frame — the two are conjugates by the
|
||||
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(
|
||||
self.yaw_axis, yaw_deg
|
||||
)
|
||||
return self.pitch_rotation(pitch_deg) @ self.yaw_rotation(yaw_deg)
|
||||
|
||||
# -------------------------------------------------------------------- solve
|
||||
|
||||
@@ -448,6 +489,19 @@ class PlatformStack:
|
||||
(branch.travel_deg for branch in usable if branch is not chosen),
|
||||
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:
|
||||
# Every exact pose needs more travel than the stage has. Report the
|
||||
# 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"
|
||||
elif not in_range:
|
||||
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"
|
||||
|
||||
# Three points, and they are three different places:
|
||||
@@ -520,6 +574,59 @@ class PlatformStack:
|
||||
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
|
||||
|
||||
def alignment_branches(
|
||||
@@ -547,7 +654,7 @@ class PlatformStack:
|
||||
"""
|
||||
unit = np.asarray(unit, 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
|
||||
if pitch_stationary == yaw_stationary: # pragma: no cover - axes perpendicular
|
||||
return []
|
||||
@@ -571,7 +678,7 @@ class PlatformStack:
|
||||
if any(abs(yaw_deg - prior) < 1e-6 for prior in seen):
|
||||
continue
|
||||
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)
|
||||
branches.append(
|
||||
Branch(
|
||||
@@ -604,7 +711,7 @@ class PlatformStack:
|
||||
to transcribe wrong.
|
||||
"""
|
||||
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])
|
||||
b = float(at_ninety[pinned])
|
||||
|
||||
@@ -641,7 +748,7 @@ class PlatformStack:
|
||||
best: tuple[float, float, float] | None = None
|
||||
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):
|
||||
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 = max(-pitch_limit, min(pitch_limit, 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
|
||||
against the tool's.
|
||||
"""
|
||||
_, first, second, sign_first, sign_second = self._rotation_coefficients(
|
||||
self.pitch_axis
|
||||
)
|
||||
u1, u2 = float(yawed[first]), float(yawed[second])
|
||||
w1, w2 = float(tool[first]), float(tool[second])
|
||||
# turned[first] = u1·c + sign_first ·u2·s
|
||||
# turned[second] = sign_second·u1·s + u2·c
|
||||
cosine_term = u1 * w1 + sign_second * u2 * w2
|
||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||
vector = np.asarray(yawed, dtype=np.float64)
|
||||
# R(θ) = cos(θ)·I + sin(θ)·R(90) on the plane this stage turns, so the
|
||||
# angle that lands on the tool is the atan2 of those two probes. The
|
||||
# probes are the commanded rotation, which is what keeps a sign change
|
||||
# on the drive from needing its own copy of the matrix.
|
||||
before = vector.copy()
|
||||
after = self.pitch_rotation(90.0) @ vector
|
||||
before[stationary] = 0.0
|
||||
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]:
|
||||
"""Whether any pose can align this normal, and why not if none can.
|
||||
@@ -693,15 +803,27 @@ class PlatformStack:
|
||||
@property
|
||||
def _yaw_stationary(self) -> int:
|
||||
"""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
|
||||
def _yaw_sign_first(self) -> float:
|
||||
return self._rotation_coefficients(self.yaw_axis)[3]
|
||||
return self._signed_coefficients(self.yaw_axis)[3]
|
||||
|
||||
@property
|
||||
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(
|
||||
self, axis: str
|
||||
@@ -733,7 +855,7 @@ class PlatformStack:
|
||||
yaw = math.radians(yaw_deg)
|
||||
yaw_cos, yaw_sin = math.cos(yaw), math.sin(yaw)
|
||||
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
|
||||
)
|
||||
yawed[stationary] = unit[stationary]
|
||||
@@ -743,7 +865,7 @@ class PlatformStack:
|
||||
pitch = math.radians(pitch_deg)
|
||||
pitch_cos, pitch_sin = math.cos(pitch), math.sin(pitch)
|
||||
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
|
||||
)
|
||||
turned[stationary] = yawed[stationary]
|
||||
@@ -777,7 +899,7 @@ class PlatformStack:
|
||||
"""
|
||||
angle = np.radians(np.asarray(angle_deg, 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)
|
||||
|
||||
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
|
||||
the exhaustive grid below.
|
||||
|
||||
The coefficients are written out by axis name rather than through the
|
||||
``first``/``second`` rotation ordering, because it is the *cross* term
|
||||
that carries the sign and getting that backwards returns the antipode —
|
||||
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.
|
||||
The sine coefficient is the same probe :meth:`_pitch_for` uses: a
|
||||
commanded 90° pitch. Writing it out per axis name returned the antipode
|
||||
once the pitch axis moved off ``y``.
|
||||
"""
|
||||
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)
|
||||
cosine_coefficient = (
|
||||
yawed[:, lead] * tool[lead] + yawed[:, trail] * tool[trail]
|
||||
)
|
||||
sine_coefficient = (
|
||||
yawed[:, trail] * tool[lead] - yawed[:, lead] * tool[trail]
|
||||
)
|
||||
return np.degrees(np.arctan2(sine_coefficient, cosine_coefficient))
|
||||
stationary = self._signed_coefficients(self.pitch_axis)[0]
|
||||
before = np.array(yawed, copy=True)
|
||||
after = yawed @ self.pitch_rotation(90.0).T
|
||||
before[:, stationary] = 0.0
|
||||
after[:, stationary] = 0.0
|
||||
target = np.array(tool, copy=True)
|
||||
target[stationary] = 0.0
|
||||
return np.degrees(np.arctan2(after @ target, before @ target))
|
||||
|
||||
def _grid_reference(
|
||||
self, unit: np.ndarray, yaws: np.ndarray, pitches: np.ndarray
|
||||
@@ -884,7 +999,7 @@ class PlatformStack:
|
||||
yawed = self._turn(self.yaw_axis, yaws, unit)
|
||||
pitch = np.radians(np.asarray(pitches, dtype=np.float64))
|
||||
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
|
||||
)
|
||||
turned = np.empty_like(yawed)
|
||||
@@ -1021,12 +1136,7 @@ class PlatformStack:
|
||||
this way. One definition, so the two cannot disagree.
|
||||
"""
|
||||
mount = mount_rotation(self.axis_map)
|
||||
return (
|
||||
mount.T
|
||||
@ rotation_about(self.pitch_axis, pitch_deg)
|
||||
@ rotation_about(self.yaw_axis, yaw_deg)
|
||||
@ mount
|
||||
)
|
||||
return mount.T @ self.platform_rotation(yaw_deg, pitch_deg) @ mount
|
||||
|
||||
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
||||
"""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))
|
||||
length = max(float(np.linalg.norm(normal)), 1e-12)
|
||||
unit = normal / length
|
||||
rotated = (
|
||||
rotation_about(stack.pitch_axis, pitch_deg)
|
||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
||||
@ unit
|
||||
)
|
||||
rotated = stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||
return float(
|
||||
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
|
||||
#: number drift apart. The bench plate under the finger is 8 mm thick.
|
||||
"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,
|
||||
#: 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.
|
||||
"finger_tool_xy_mm": [0.0, 3.0],
|
||||
#: Where 「回初始位」 parks the table, mm. X is 1 mm toward the negative
|
||||
#: direction. Y is 15.0 mm forward of the old zero.
|
||||
"table_initial_x_mm": -1.0,
|
||||
"table_initial_y_mm": 15.0,
|
||||
#: Where 「回初始位」 parks the table, mm. After the power cycle the
|
||||
#: coordinate origin sits on the previous park, so this is the origin:
|
||||
#: one millimetre back in X and 15 mm back in Y from the old target.
|
||||
"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
|
||||
#: fixture interferes outside that.
|
||||
"finger_yaw_limit_deg": 180.0,
|
||||
@@ -312,6 +315,10 @@ def load_preferences() -> dict[str, Any]:
|
||||
if not offset[2]:
|
||||
offset[2] = float(defaults["finger_sensor_offset_mm"][2])
|
||||
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
|
||||
# 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)
|
||||
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(
|
||||
path=root,
|
||||
vertices_mm=vertices * mm_per_unit,
|
||||
vertices_mm=vertices_mm,
|
||||
faces=faces,
|
||||
uv_data=uv_data,
|
||||
loop_to_vertex=loop_to_vertex,
|
||||
housing_vertices_mm=housing_vertices * mm_per_unit,
|
||||
housing_vertices_mm=housing_mm,
|
||||
housing_faces=housing_faces,
|
||||
housing_part_names=part_names,
|
||||
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:
|
||||
"""The millimetres-per-mesh-unit factor, derived from the pose.
|
||||
|
||||
|
||||
@@ -16,14 +16,47 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
|
||||
#: Which platform axis each sensor axis points along, and in which sign.
|
||||
#: ``"z": "-y"`` keeps the frame right-handed: sensor (x, y, z) → platform
|
||||
#: (x, z, -y), whose determinant is +1 (verified by a test).
|
||||
AXIS_MAP: dict[str, str] = {"x": "x", "y": "z", "z": "-y"}
|
||||
#: The bench (2026-10-15) showed both table commands reversed: a point further
|
||||
#: along the shell (sensor +Z, toward the tip) must move the table toward
|
||||
#: 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
|
||||
#: 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"
|
||||
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: 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
|
||||
|
||||
|
||||
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:
|
||||
"""``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)
|
||||
return "sensor " + ", ".join(
|
||||
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 ..multipoint import Campaign, next_point_id
|
||||
from ..sensor.frames import machine_table_xy
|
||||
from ..sensor.uv_flat import site_at
|
||||
|
||||
#: A stored point and the mapping's answer for the same UV are the same point when
|
||||
@@ -291,9 +292,10 @@ class MultipointPanel(QWidget):
|
||||
@staticmethod
|
||||
def _pose_of(solved, stack) -> dict:
|
||||
"""The record of one solve: the targets, and what the solver said of them."""
|
||||
move_x, move_y = machine_table_xy(solved.table_xy_mm)
|
||||
return {
|
||||
"x": float(solved.table_xy_mm[0]),
|
||||
"y": float(solved.table_xy_mm[1]),
|
||||
"x": float(move_x),
|
||||
"y": float(move_y),
|
||||
"yaw": float(solved.yaw_deg),
|
||||
"pitch": float(solved.pitch_deg),
|
||||
"z": float(solved.contact_z_mm),
|
||||
|
||||
@@ -48,6 +48,7 @@ from ..motion.kinematics import PlatformStack
|
||||
from ..motion.pose_move import PressAxis, press_targets
|
||||
from ..sensor.export_bundle import SensorBundle
|
||||
from ..sensor.frames import describe as describe_frames
|
||||
from ..sensor.frames import machine_table_xy
|
||||
from ..sensor.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||
|
||||
#: How many picked points the list keeps before the oldest is dropped.
|
||||
@@ -377,6 +378,7 @@ class FingerPanel(QGroupBox):
|
||||
"table_move",
|
||||
"press",
|
||||
"residual",
|
||||
"match",
|
||||
"frames",
|
||||
)
|
||||
for row, key in enumerate(rows):
|
||||
@@ -386,7 +388,7 @@ class FingerPanel(QGroupBox):
|
||||
value.setObjectName("statusValue")
|
||||
value.setWordWrap(True)
|
||||
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
|
||||
# picker beside them down to a thumbnail; wrapping keeps them readable
|
||||
# at whatever width the row can spare.
|
||||
@@ -771,7 +773,7 @@ class FingerPanel(QGroupBox):
|
||||
pose = self._last_pose
|
||||
if pose is None:
|
||||
return None
|
||||
table_x, table_y = pose.table_xy_mm
|
||||
table_x, table_y = machine_table_xy(pose.table_xy_mm)
|
||||
targets = {
|
||||
"x": float(table_x),
|
||||
"y": float(table_y),
|
||||
@@ -868,6 +870,7 @@ class FingerPanel(QGroupBox):
|
||||
point = np.array([record["x"], record["y"], record["z"]], dtype=np.float64)
|
||||
normal = np.array([record["nx"], record["ny"], record["nz"]], dtype=np.float64)
|
||||
pose = self._stack.solve(point, normal)
|
||||
move_x, move_y = machine_table_xy(pose.table_xy_mm)
|
||||
points.append({
|
||||
"site_id": f"site-{index:03d}",
|
||||
"x": float(point[0]),
|
||||
@@ -879,8 +882,8 @@ class FingerPanel(QGroupBox):
|
||||
"u_px": record.get("u_px"),
|
||||
"v_px": record.get("v_px"),
|
||||
"pose": {
|
||||
"x": float(pose.table_xy_mm[0]),
|
||||
"y": float(pose.table_xy_mm[1]),
|
||||
"x": float(move_x),
|
||||
"y": float(move_y),
|
||||
"yaw": float(pose.yaw_deg),
|
||||
"pitch": float(pose.pitch_deg),
|
||||
"z": float(pose.contact_z_mm),
|
||||
@@ -1246,6 +1249,7 @@ class FingerPanel(QGroupBox):
|
||||
|
||||
_last_pose = None
|
||||
_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:
|
||||
self._readout_labels["frames"].setText(describe_frames(self._stack.axis_map))
|
||||
@@ -1278,10 +1282,9 @@ class FingerPanel(QGroupBox):
|
||||
self._readout_labels["platform_point"].setText(
|
||||
" ".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(
|
||||
"—"
|
||||
if pose is None
|
||||
else f"{pose.table_xy_mm[0]:+.3f} {pose.table_xy_mm[1]:+.3f} mm"
|
||||
"—" if pose is None else f"{move_x:+.3f} {move_y:+.3f} mm"
|
||||
)
|
||||
if pose is not None:
|
||||
self._readout_labels["pose"].setText(pose.describe())
|
||||
@@ -1298,8 +1301,55 @@ class FingerPanel(QGroupBox):
|
||||
),
|
||||
)
|
||||
)
|
||||
self._refresh_match()
|
||||
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:
|
||||
"""Report what the planned motion does, and where it actually arrives.
|
||||
|
||||
@@ -1377,6 +1427,7 @@ class FingerPanel(QGroupBox):
|
||||
"table_move": "finger.row_table_move",
|
||||
"press": "finger.row_press",
|
||||
"residual": "finger.row_residual",
|
||||
"match": "finger.row_match",
|
||||
"frames": "finger.row_frames",
|
||||
}
|
||||
grid = self._readout_box.layout()
|
||||
|
||||
@@ -10,6 +10,7 @@ the ctypes handle single-threaded.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -47,7 +48,7 @@ from ..motion.pose_move import (
|
||||
press_targets,
|
||||
)
|
||||
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 ..sensor import (
|
||||
BundleError,
|
||||
@@ -869,6 +870,36 @@ class AutoCalMainWindow(QMainWindow):
|
||||
"""Signal handler for the poller's periodic state read."""
|
||||
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:
|
||||
self._last_snapshot = snapshot
|
||||
self.connection_panel.set_connected(
|
||||
@@ -894,6 +925,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.finger_panel.set_move_available(
|
||||
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
|
||||
# of which tab is showing.
|
||||
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
|
||||
height; otherwise the panel hands back that role as held, so the move
|
||||
carries the roles it can justify and says which one it left alone. The
|
||||
numbers in the confirmation are the ones the preview drew.
|
||||
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()
|
||||
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"
|
||||
)
|
||||
|
||||
# Sensor +Z is the sensing face's own axis and must face the viewer. It is
|
||||
# the platform's -Y on this mount. ``forward`` points from the eye into the
|
||||
# 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.
|
||||
# The commissioned camera sits in front of the shell and a little below
|
||||
# it, looking back toward platform −Y. That is the ad049d12 preview.
|
||||
eye, _distance = preview._camera() # noqa: SLF001
|
||||
centre = preview._view_target() # 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)
|
||||
lens_axis = lens_axis / np.linalg.norm(lens_axis)
|
||||
assert float(lens_axis @ forward) > 0.2, (
|
||||
f"sensor +Z must face the viewer, but points away: "
|
||||
f"{float(lens_axis @ forward):+.3f}"
|
||||
)
|
||||
assert eye[1] > centre[1]
|
||||
assert eye[2] < centre[2]
|
||||
assert forward[1] < -0.5, f"the view must look toward −Y, but forward is {forward}"
|
||||
finally:
|
||||
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):
|
||||
"""The one line the bench will want to check when x and y are interchanged."""
|
||||
text = panel._readout_labels["frames"].text() # noqa: SLF001
|
||||
assert "x" in text and "z" in text
|
||||
assert "-y" in text or "−y" in text
|
||||
assert "x->-x" in text
|
||||
assert "z->y" in text
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
# And the alternative's cost is on screen too, not just the chosen one's.
|
||||
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()
|
||||
assert held == ("z",)
|
||||
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.
|
||||
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:
|
||||
"""Re-apply a returned pose with the library's own rotations."""
|
||||
return (
|
||||
rotation_about(stack.pitch_axis, pitch_deg)
|
||||
@ rotation_about(stack.yaw_axis, yaw_deg)
|
||||
@ unit
|
||||
)
|
||||
"""Re-apply a returned pose with the same rotation the drives execute."""
|
||||
return stack.platform_rotation(yaw_deg, pitch_deg) @ unit
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ exactness
|
||||
@@ -108,11 +104,10 @@ def test_the_two_methods_agree_inside_the_envelope():
|
||||
|
||||
|
||||
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
|
||||
which axis is called pitch is a naming and zero-offset choice, not a
|
||||
correctness question — the two reach exactly the same set of alignments.
|
||||
This is a property of the right-hand matrices. The drive does not get to
|
||||
pick either binding: pitch is about ``x`` with the measured command sign.
|
||||
"""
|
||||
unit = np.array([0.3, 0.5, -0.81])
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
|
||||
@@ -59,9 +59,9 @@ def _brute_force_best(
|
||||
pitch_limit = min(360.0, stack.pitch_limit_deg)
|
||||
best = 1e9
|
||||
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):
|
||||
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))))
|
||||
best = min(best, residual)
|
||||
return best
|
||||
@@ -77,13 +77,78 @@ def test_the_axis_map_is_a_rotation():
|
||||
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():
|
||||
"""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])
|
||||
|
||||
|
||||
def test_the_sensor_x_maps_to_the_platform_x():
|
||||
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [1.0, 0.0, 0.0])
|
||||
def test_the_sensor_x_maps_to_the_platform_minus_x():
|
||||
"""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():
|
||||
@@ -105,7 +170,7 @@ def test_an_incomplete_map_is_refused():
|
||||
|
||||
|
||||
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():
|
||||
@@ -155,9 +220,7 @@ def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
|
||||
unit = unit / np.linalg.norm(unit)
|
||||
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
|
||||
|
||||
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
|
||||
stack.yaw_axis, pose.yaw_deg
|
||||
) @ unit
|
||||
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ unit
|
||||
landed = landed / np.linalg.norm(landed)
|
||||
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.
|
||||
@@ -241,7 +304,7 @@ def test_the_closed_form_pitch_beats_a_direct_scan():
|
||||
tool = stack.tool_direction
|
||||
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
|
||||
stationary, first, second, sign_first, sign_second = (
|
||||
stack._rotation_coefficients(stack.pitch_axis) # noqa: SLF001
|
||||
stack._signed_coefficients(stack.pitch_axis) # noqa: SLF001
|
||||
)
|
||||
dot = (
|
||||
yawed[stationary] * tool[stationary]
|
||||
@@ -262,28 +325,11 @@ def _independent_residual(
|
||||
"""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
|
||||
an error in that shared helper. This builds the two 3x3 matrices from the
|
||||
rotation formulas directly, so the two paths agree only if both are right.
|
||||
an error in that shared helper. The matrices live in ``_independent_rotation``.
|
||||
"""
|
||||
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
|
||||
unit = normal / np.linalg.norm(normal)
|
||||
|
||||
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
|
||||
turned = _independent_rotation(yaw_deg, pitch_deg) @ unit
|
||||
return float(
|
||||
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.
|
||||
|
||||
``docs/motion-correction-plan.zh.md`` measures a point at (-2.698, 5.186, 18.228)
|
||||
with normal (-0.343, 0.699, 0.627). It has two exact solutions — -61.319°/+45.636°
|
||||
and +118.681°/-45.636° — and the second sits outside the ±90 note this bench was
|
||||
once configured with. At ±180 both branches are reachable, so a change in which
|
||||
branch is preferred cannot strand the point on "cannot reach".
|
||||
with normal (-0.343, 0.699, 0.627). Under the drive's rotation it has two exact
|
||||
solutions, -28.681° and +151.319° of yaw, and the second sits outside the ±90
|
||||
note this bench was once configured with. At ±180 both branches are reachable,
|
||||
so a change in which branch is preferred cannot strand the point on "cannot reach".
|
||||
"""
|
||||
point = np.array([-2.698, 5.186, 18.228])
|
||||
normal = np.array([-0.343, 0.699, 0.627])
|
||||
@@ -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)
|
||||
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 sum(branch.in_range for branch in narrow_branches) == 1, (
|
||||
"±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])
|
||||
|
||||
|
||||
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():
|
||||
"""The one invariant the whole machine exists to satisfy."""
|
||||
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)
|
||||
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)
|
||||
# An in-plane offset leaves the *mounting* height alone, but not the height the
|
||||
# stages present: the same angles now lift a contact that sits further from the
|
||||
# trunnion axis. This line used to compare contact_z_mm on both sides, which
|
||||
# only held while that field was reported before the rotation.
|
||||
# An in-plane offset leaves the mounting height alone. The stages then present
|
||||
# a different height, because the same angles tilt a contact that sits somewhere
|
||||
# else relative to the pitch axis.
|
||||
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)
|
||||
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)
|
||||
# The mounting yaw is absorbed by the stage: it has to turn that much less.
|
||||
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
|
||||
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
|
||||
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
|
||||
# Mounting yaw is right-handed about Z. Positive stage yaw is the opposite
|
||||
# sense, so the same-pitch branch's command rises by the mounting angle.
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
stack = PlatformStack(sensor_yaw_deg=90.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.
|
||||
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])
|
||||
# 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 stack.normal_to_platform(normal) == pytest.approx([-1.0, 0.0, 0.0])
|
||||
|
||||
|
||||
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:
|
||||
"""``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)
|
||||
about_yaw = np.array(
|
||||
[
|
||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
||||
[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)],
|
||||
[1.0, 0.0, 0.0],
|
||||
[0.0, np.cos(pitch), -np.sin(pitch)],
|
||||
[0.0, np.sin(pitch), np.cos(pitch)],
|
||||
]
|
||||
)
|
||||
return about_pitch @ about_yaw
|
||||
|
||||
@@ -17,6 +17,7 @@ import numpy as np
|
||||
import pytest
|
||||
from orisys_autocal.sensor.export_bundle import (
|
||||
BundleError,
|
||||
housing_tail_origin_mm,
|
||||
load_bundle,
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Mixed winding would make 'the normal' meaningless."""
|
||||
normals = bundle.face_normals
|
||||
|
||||
@@ -193,7 +193,7 @@ STRINGS = {
|
||||
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
|
||||
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
|
||||
["对轴残差", "0.03° 可对轴", "#12b76a"],
|
||||
["坐标系映射", "x→x, y→z, z→-y", None],
|
||||
["坐标系映射", "x→-x, y→z, z→y", None],
|
||||
],
|
||||
"finger_tool_label": "刀具 X / Y",
|
||||
"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],
|
||||
["Table move X / Y", "+2.841 -0.940 mm", None],
|
||||
["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_values": ["0.000", "0.000"],
|
||||
|
||||
Reference in New Issue
Block a user