优化传感器对准压头

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
@@ -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)))
)
+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
#: 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()
+35 -2
View File
@@ -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: