修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。
离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
This commit is contained in:
@@ -20,8 +20,10 @@ from .pose_move import (
|
||||
MovePlan,
|
||||
MoveStep,
|
||||
PoseMover,
|
||||
PressAxis,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
press_targets,
|
||||
)
|
||||
from .service import (
|
||||
AXIS_ROLES,
|
||||
@@ -76,6 +78,7 @@ __all__ = [
|
||||
"MoveStep",
|
||||
"NativeTransport",
|
||||
"PoseMover",
|
||||
"PressAxis",
|
||||
"Snapshot",
|
||||
"ZmcTransport",
|
||||
"clamp_bas_task",
|
||||
@@ -84,6 +87,7 @@ __all__ = [
|
||||
"normalize_axis_roles",
|
||||
"plan_pose_move",
|
||||
"plan_return_to_initial",
|
||||
"press_targets",
|
||||
"safe_float",
|
||||
"units_per_degree",
|
||||
"user_units_to_deg",
|
||||
|
||||
@@ -72,6 +72,14 @@ _UPRIGHT_TIE = 1e-6
|
||||
#: has to absorb the sweep's own quantisation, not excuse a real misalignment.
|
||||
_UPRIGHT_TOL_DEG = 0.05
|
||||
|
||||
#: Two exact poses whose residuals differ by less than this are the *same* alignment
|
||||
#: to any tolerance the machine can hold, so the choice between them must not turn on
|
||||
#: which one the arithmetic put a hair lower. They are compared as equals and the
|
||||
#: machine decides (travel, reachability, which way up). The closed form puts both
|
||||
#: within floating point — or reports the same residual twice — so this band is what
|
||||
#: keeps the reported pose from moving when nothing moved.
|
||||
_BRANCH_TIE_DEG = 1e-6
|
||||
|
||||
|
||||
|
||||
def rotation_about(axis: str, degrees: float) -> np.ndarray:
|
||||
@@ -100,7 +108,11 @@ class Branch:
|
||||
|
||||
Both branches put the normal on the tool axis to within floating point, so
|
||||
what separates them is the machine: whether the pose is inside the stage
|
||||
travel, and which way up it leaves the sensing face.
|
||||
travel, which way up it leaves the sensing face, and how far the stages have to
|
||||
turn to get there. The two branches are not two similar poses: they are a
|
||||
half-turn apart about the vertical (``R_z(180°)`` of each other), so they reach
|
||||
the same contact height with different body orientations. A half-turn of yaw
|
||||
does not by itself mean that the sensing face points down.
|
||||
"""
|
||||
|
||||
yaw_deg: float
|
||||
@@ -109,10 +121,21 @@ class Branch:
|
||||
upright: bool
|
||||
in_range: bool
|
||||
|
||||
@property
|
||||
def travel_deg(self) -> float:
|
||||
"""How far the two stages turn to reach this pose, degrees.
|
||||
|
||||
The sum of the absolute angles, which is what the machine pays: a half-turn
|
||||
of yaw instead of a small one is the difference between the two branches,
|
||||
and it is the number the operator needs to hear when it is chosen.
|
||||
"""
|
||||
return abs(self.yaw_deg) + abs(self.pitch_deg)
|
||||
|
||||
def describe(self) -> str:
|
||||
return (
|
||||
f"yaw {self.yaw_deg:+.3f} pitch {self.pitch_deg:+.3f} "
|
||||
f"residual {self.residual_deg:.6f} "
|
||||
f"travel {self.travel_deg:.3f} "
|
||||
f"{'upright' if self.upright else 'inverted'}"
|
||||
f"{'' if self.in_range else ' out-of-range'}"
|
||||
)
|
||||
@@ -123,13 +146,31 @@ class Pose:
|
||||
"""One solved platform pose, or the reason there is not one."""
|
||||
|
||||
ok: bool
|
||||
#: Platform-frame coordinates of the contact point, mm — where the point *is*,
|
||||
#: with the sensor's mounting pose applied.
|
||||
#: Platform-frame XY of the contact point **as mounted**, mm: the sensor's
|
||||
#: mounting pose and the plate applied, the stages still at zero. It is where
|
||||
#: the point sits on the machine, not where the machine has to go — the move is
|
||||
#: :attr:`table_xy_mm`. The stages turn the point away from here, so this is
|
||||
#: *not* its position while the pose is executed; that is
|
||||
#: :attr:`contact_point_mm`.
|
||||
target_xy_mm: tuple[float, float] = (0.0, 0.0)
|
||||
#: Platform-frame Z of the contact point, mm. Carries the mounting plate and
|
||||
#: any Z tool offset, which ``target_xy_mm`` cannot: without it those inputs
|
||||
#: are computed and then discarded, and nothing downstream can tell.
|
||||
#: Platform-frame coordinates of the contact point as mounted, mm: the same
|
||||
#: point as ``target_xy_mm`` with its Z, so the mounting geometry reads as one
|
||||
#: triple and can be checked against the setup.
|
||||
mounted_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: Platform-frame Z of the contact point **once the stages have turned**, mm:
|
||||
#: the height the contact actually reaches at the solved pose, which is the
|
||||
#: number any press-axis target has to be related to. Carries the mounting plate
|
||||
#: and any Z tool offset. The unrotated height is ``mounted_point_mm``'s Z and
|
||||
#: it is a different number: the stages turn the point about the table origin,
|
||||
#: so a 47° pose lifts it by several millimetres. Reporting the unrotated Z here
|
||||
#: is what made every commanded height wrong by that lift.
|
||||
contact_z_mm: float = 0.0
|
||||
#: The contact point at the **end of the move**, mm: after the stages turn and
|
||||
#: the table slides. Its XY is the tool position — bringing the point there is
|
||||
#: what the move is for — and its Z is ``contact_z_mm``. Reported so the three
|
||||
#: points in the chain (as mounted, after the stages, at the tool) cannot be
|
||||
#: confused for one another again.
|
||||
contact_point_mm: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
#: The move the XY table has to make, mm: the tool's position minus where the
|
||||
#: contact ends up after the stages turn. This is the number a run commands,
|
||||
#: and it is what the preview animates — ``target_xy_mm`` says where the point
|
||||
@@ -143,14 +184,25 @@ class Pose:
|
||||
residual_deg: float = 0.0
|
||||
#: Whether the yaw could be determined at all (indeterminate at the pole).
|
||||
yaw_determined: bool = True
|
||||
#: Whether the pose leaves the sensing face pointing along the tool axis. A
|
||||
#: pose can align the normal perfectly and still be turned over; the residual
|
||||
#: alone cannot say which, so it is reported rather than inferred.
|
||||
#: Whether the pose leaves the *body* up — the axis the mount sends to the
|
||||
#: platform's vertical — still pointing up. A pose can align the normal
|
||||
#: perfectly and still be turned over: the two branches of a measured point are
|
||||
#: exactly that, half a turn apart about the vertical. The residual cannot tell
|
||||
#: them apart, so this is measured and reported rather than inferred.
|
||||
upright: bool = True
|
||||
#: False when the chosen pose is outside the stage travel — reported, never
|
||||
#: silently clamped.
|
||||
in_range: bool = True
|
||||
reason: str = ""
|
||||
#: Which selection key decided this pose, as a key the panel names in words:
|
||||
#: ``only``, ``alignment``, ``in_range``, ``upright`` or ``smallest``. The rule
|
||||
#: is a machine convention, so it is shown with the pose instead of assumed.
|
||||
branch_reason: str = ""
|
||||
#: How far the stages turn for this pose, degrees.
|
||||
branch_travel_deg: float = 0.0
|
||||
#: Total travel of the pose that was *not* chosen, degrees, when the closed form
|
||||
#: offered one. Zero when this pose was the only candidate.
|
||||
other_travel_deg: float = 0.0
|
||||
|
||||
@property
|
||||
def reached_tolerance(self) -> bool:
|
||||
@@ -191,11 +243,15 @@ class PlatformStack:
|
||||
#: ``+tool_axis``, and the tool feeds down onto it.
|
||||
#:
|
||||
#: This was ``-1``, which asked the solver to bring every normal onto **-Z**.
|
||||
#: The residual could not see the difference (``arccos`` is sign-blind, so a
|
||||
#: face turned fully over still scored 0.0000 deg), so the mistake showed up
|
||||
#: only as the model flipping upside down: **981 of 1032 vertices** ended with
|
||||
#: the face pointing down. With ``+1`` that is **51**, and those are edge-on
|
||||
#: rather than inverted (worst cosine -0.013).
|
||||
#: Nothing reported that as a fault, because the residual was measured against
|
||||
#: the same inverted aim: a pose that turned the sensor over aligned with ``-Z``
|
||||
#: *perfectly*, scored 0.0000 deg, and passed every test that asked only for a
|
||||
#: small residual. The mistake showed up as the model flipping upside down:
|
||||
#: **981 of 1032 vertices** ended with the face pointing down. With ``+1`` the
|
||||
#: alignment is measured against the direction the face really aims, and the
|
||||
#: count is 0 — every normal on the export is aligned to within 1.3e-06 deg,
|
||||
#: with the body's own orientation reported separately by
|
||||
#: :meth:`PlatformStack.uprightness`.
|
||||
approach_sign: int = 1
|
||||
#: Where the tool is, in the table frame, mm. Default ``(0, 0)`` — straight
|
||||
#: above the table origin, which is where a dialled-in machine has it. Every
|
||||
@@ -386,6 +442,12 @@ class PlatformStack:
|
||||
residual = chosen.residual_deg
|
||||
upright = chosen.upright
|
||||
in_range = True
|
||||
branch_reason = self.branch_reason(usable, chosen)
|
||||
branch_travel = chosen.travel_deg
|
||||
other_travel = max(
|
||||
(branch.travel_deg for branch in usable if branch is not chosen),
|
||||
default=0.0,
|
||||
)
|
||||
elif branches:
|
||||
# Every exact pose needs more travel than the stage has. Report the
|
||||
# best pose the limits *do* allow, with the residual it really
|
||||
@@ -394,14 +456,23 @@ class PlatformStack:
|
||||
yaw_deg, pitch_deg, residual = self._best_within_limits(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
branch_reason = "in_range"
|
||||
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||
other_travel = 0.0
|
||||
else:
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = False
|
||||
branch_reason = ""
|
||||
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||
other_travel = 0.0
|
||||
elif method == "search":
|
||||
yaw_deg, pitch_deg, residual = self._search_alignment(unit, tool)
|
||||
upright = self.uprightness(yaw_deg, pitch_deg) >= 0.0
|
||||
in_range = True
|
||||
branch_reason = ""
|
||||
branch_travel = abs(yaw_deg) + abs(pitch_deg)
|
||||
other_travel = 0.0
|
||||
else: # pragma: no cover - guarded by the signature
|
||||
raise ValueError(f"unknown method {method!r}")
|
||||
|
||||
@@ -413,17 +484,29 @@ class PlatformStack:
|
||||
elif residual > self.tolerance_deg:
|
||||
reason = "no pose meets the tolerance"
|
||||
|
||||
# The stages turn the contact about the table origin; the table then slides
|
||||
# it under the tool. Reported rather than animated here, but computed from
|
||||
# the same angles the pose reports, so a caller cannot disagree with it.
|
||||
# Three points, and they are three different places:
|
||||
#
|
||||
# * ``point`` — the contact as mounted, the stages at zero.
|
||||
# * ``landed`` — the contact once the stages turn about the table origin.
|
||||
# * ``landed + table`` — the contact at the end of the move, under the tool.
|
||||
#
|
||||
# The contact *height* is the middle one's Z. Reporting the first one's Z
|
||||
# (as this did) drops the lift the stage rotation gives the point, which is
|
||||
# several millimetres at the angles a real point needs, and that height is
|
||||
# what any press-axis target is derived from.
|
||||
landed = self.platform_rotation(yaw_deg, pitch_deg) @ point
|
||||
tool_x, tool_y = (float(value) for value in self.tool_xy_mm)
|
||||
table_x = tool_x - self._axis(landed, "x")
|
||||
table_y = tool_y - self._axis(landed, "y")
|
||||
contact_z = self._axis(landed, "z")
|
||||
|
||||
return Pose(
|
||||
ok=True,
|
||||
target_xy_mm=(self._axis(point, "x"), self._axis(point, "y")),
|
||||
contact_z_mm=self._axis(point, "z"),
|
||||
table_xy_mm=(tool_x - self._axis(landed, "x"), tool_y - self._axis(landed, "y")),
|
||||
mounted_point_mm=(self._axis(point, "x"), self._axis(point, "y"), self._axis(point, "z")),
|
||||
contact_z_mm=contact_z,
|
||||
contact_point_mm=(tool_x, tool_y, contact_z),
|
||||
table_xy_mm=(table_x, table_y),
|
||||
yaw_deg=yaw_deg,
|
||||
pitch_deg=pitch_deg,
|
||||
tool_z_mm=float(tool_z_mm),
|
||||
@@ -432,6 +515,9 @@ class PlatformStack:
|
||||
upright=upright,
|
||||
in_range=in_range,
|
||||
reason=reason,
|
||||
branch_reason=branch_reason,
|
||||
branch_travel_deg=branch_travel,
|
||||
other_travel_deg=other_travel,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------- closed-form solve
|
||||
@@ -582,34 +668,6 @@ class PlatformStack:
|
||||
sine_term = sign_first * u2 * w1 - u1 * w2
|
||||
return math.degrees(math.atan2(sine_term, cosine_term))
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list[Branch]) -> Branch:
|
||||
"""Pick from :meth:`alignment_branches`: reachable, then upright, then best.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
separates them. What does is the machine: a pose inside the stage travel
|
||||
beats one outside it, and — among those — a pose that leaves the sensing
|
||||
face up beats one that turns the sensor over.
|
||||
|
||||
Alignment is still the third key rather than ignored: if one branch really
|
||||
is materially better than another, that wins, because the point of the
|
||||
stages is to align. The tie only decides between poses that are the same
|
||||
to any tolerance the machine can hold.
|
||||
"""
|
||||
def rank(branch: Branch) -> tuple:
|
||||
# Alignment first: if one branch genuinely aligns better, that is the
|
||||
# answer, because aligning is what the stages are for. Reachability and
|
||||
# orientation decide between branches that align the same, which is the
|
||||
# usual case — the closed form puts both within floating point of exact.
|
||||
return (
|
||||
branch.residual_deg,
|
||||
not branch.in_range,
|
||||
not branch.upright,
|
||||
abs(branch.yaw_deg) + abs(branch.pitch_deg),
|
||||
)
|
||||
|
||||
return min(branches, key=rank)
|
||||
|
||||
def reachable(self, unit: np.ndarray, tool: np.ndarray) -> tuple[bool, str]:
|
||||
"""Whether any pose can align this normal, and why not if none can.
|
||||
|
||||
@@ -702,12 +760,13 @@ class PlatformStack:
|
||||
"""
|
||||
turned = self._cell(unit, yaw_deg, pitch_deg)
|
||||
tool = self.tool_direction
|
||||
cosine = (
|
||||
turned[0] * float(tool[0])
|
||||
+ turned[1] * float(tool[1])
|
||||
+ turned[2] * float(tool[2])
|
||||
)
|
||||
return float(math.degrees(math.acos(max(-1.0, min(1.0, cosine)))))
|
||||
x, y, z = turned
|
||||
tx, ty, tz = (float(value) for value in tool)
|
||||
cosine = x * tx + y * ty + z * tz
|
||||
# acos loses angular precision near a dot product of 1, which can make
|
||||
# equivalent exact branches appear different. atan2 stays well conditioned.
|
||||
sine = math.hypot(y * tz - z * ty, z * tx - x * tz, x * ty - y * tx)
|
||||
return float(math.degrees(math.atan2(sine, cosine)))
|
||||
|
||||
def _turn(self, axis: str, angle_deg: np.ndarray, vector: np.ndarray) -> np.ndarray:
|
||||
"""Rotate ``vector`` about a platform axis by each of ``angle_deg``.
|
||||
@@ -972,18 +1031,30 @@ class PlatformStack:
|
||||
def uprightness(self, yaw_deg: float, pitch_deg: float) -> float:
|
||||
"""How much the sensing face still points up, in -1..1.
|
||||
|
||||
Measured, not derived: the sensor's own up axis (mesh ``+Y``, which
|
||||
``AXIS_MAP`` sends to platform ``+Z``) is put through the composed stage
|
||||
rotation and compared with that same axis *unrotated*. ``+1`` is the
|
||||
nominal pose — no rotation, face straight up — ``-1`` is fully turned over.
|
||||
Measured, not derived: the body's own up axis — the mesh axis the mount
|
||||
sends to the platform frame's vertical, i.e. mesh ``+Y`` under the default
|
||||
``AXIS_MAP`` — is put through the composed stage rotation and dotted with
|
||||
that same axis *unrotated*. ``+1`` is the nominal pose (no rotation, face
|
||||
straight up), ``-1`` is fully turned over.
|
||||
|
||||
Both vectors are expressed in the same frame, so this is the angle between
|
||||
the face and where it started, and it does not depend on which way the tool
|
||||
points. An earlier version dotted the turned axis against the tool
|
||||
direction, which quietly moved with ``approach_sign`` and so reported the
|
||||
polarity as an orientation.
|
||||
|
||||
The reference axis is the whole point of the measurement, so it is taken
|
||||
from the mount rather than assumed: ``tool_axis_direction`` is the platform
|
||||
axis the face aims along (``+Z`` here), and ``to_sensor`` maps it back
|
||||
through the same ``AXIS_MAP`` the geometry uses. An earlier version again
|
||||
passed ``eye(3)[1]`` — the platform frame's *Y*, a horizontal axis — so the
|
||||
number measured how far the body leaned sideways and went on reporting
|
||||
rolled-over poses (a half-turn about the vertical) as upright. That is the
|
||||
second half of the false branch decision: with the axis corrected, the two
|
||||
branches of a measured point are both upright, and the choice falls to the
|
||||
machine instead of to a mis-measured cosine.
|
||||
"""
|
||||
reference = to_sensor(np.eye(3)[1], self.axis_map)
|
||||
reference = to_sensor(self.tool_axis_direction, self.axis_map)
|
||||
reference = reference / max(float(np.linalg.norm(reference)), 1e-12)
|
||||
turned = self.stage_rotation(yaw_deg, pitch_deg) @ reference
|
||||
turned = turned / max(float(np.linalg.norm(turned)), 1e-12)
|
||||
@@ -996,10 +1067,71 @@ class PlatformStack:
|
||||
return True
|
||||
if lean < -_UPRIGHT_TIE:
|
||||
return False
|
||||
# Indeterminate: the two poses are the same alignment to any tolerance the
|
||||
# machine has, so take the one needing the smaller move.
|
||||
# Edge-on: the body's up is perpendicular to the vertical to within the
|
||||
# noise floor, so neither answer is wrong and the sign is rounding. Prefer
|
||||
# the pose that asks for the smaller pitch. Arbitrary, but stable, and it
|
||||
# only ever decides poses no machine tolerance can tell apart.
|
||||
return abs(pitch_deg) <= 90.0
|
||||
|
||||
@staticmethod
|
||||
def select_branch(branches: list[Branch]) -> Branch:
|
||||
"""Pick from :meth:`alignment_branches`: reachable, upright, then smallest.
|
||||
|
||||
Every branch aligns to within floating point, so alignment itself rarely
|
||||
separates them. What does is the machine: a pose inside the stage travel
|
||||
beats one outside it, and — among those — a pose that leaves the body up
|
||||
beats one that rolls it over, and then the smaller turn wins.
|
||||
|
||||
**Equivalent poses are compared as equals.** Only branches within
|
||||
:data:`_BRANCH_TIE_DEG` of the best residual compete on the machine keys:
|
||||
two exact poses of a measured point differ by floating point at most, and
|
||||
letting that difference choose between them would move the reported pose —
|
||||
and the machine — for a reason nobody could see or defend. A branch that is
|
||||
*materially* better aligned still wins, because aligning is what the stages
|
||||
are for.
|
||||
|
||||
Which key decided the answer is reported separately by
|
||||
:meth:`branch_reason`, so the panel can say why rather than only what.
|
||||
"""
|
||||
if not branches:
|
||||
raise ValueError("no branches to choose from")
|
||||
|
||||
best = min(branch.residual_deg for branch in branches)
|
||||
equivalent = [
|
||||
branch
|
||||
for branch in branches
|
||||
if branch.residual_deg <= best + _BRANCH_TIE_DEG
|
||||
]
|
||||
return min(
|
||||
equivalent,
|
||||
key=lambda branch: (
|
||||
not branch.in_range,
|
||||
not branch.upright,
|
||||
branch.travel_deg,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def branch_reason(branches: list[Branch], chosen: Branch) -> str:
|
||||
"""Which selection key decided ``chosen``, as a key for the panel to name.
|
||||
|
||||
Returns ``only`` when there was nothing to choose between, otherwise the
|
||||
first key that rules the other candidates out: ``alignment`` (a materially
|
||||
better residual), ``in_range``, ``upright``, or ``smallest`` when the poses
|
||||
are equivalent and the shorter turn won.
|
||||
"""
|
||||
others = [branch for branch in branches if branch is not chosen]
|
||||
if not others:
|
||||
return "only"
|
||||
best = min(branch.residual_deg for branch in branches)
|
||||
if all(branch.residual_deg > best + _BRANCH_TIE_DEG for branch in others):
|
||||
return "alignment"
|
||||
if all(not branch.in_range for branch in others):
|
||||
return "in_range"
|
||||
if all(not branch.upright for branch in others):
|
||||
return "upright"
|
||||
return "smallest"
|
||||
|
||||
def _prefer_smallest(
|
||||
self,
|
||||
unit: np.ndarray,
|
||||
|
||||
@@ -26,6 +26,7 @@ translation is what the rotations are resolved against.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -55,6 +56,77 @@ MOVE_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||
ALIGN_ORDER: tuple[str, ...] = ("yaw", "pitch", "x", "y", "z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PressAxis:
|
||||
"""How the press axis's coordinate relates to the geometric contact height.
|
||||
|
||||
A solved pose reports ``contact_z_mm``: how high the contact sits above the
|
||||
table's origin plane, in geometry. The press axis is commanded in its own
|
||||
coordinate, counted from its own mechanical zero. They are two different
|
||||
numbers, and only a bench measurement relates them:
|
||||
|
||||
* ``zero_mm`` — the press axis reading at which the tool tip meets the table's
|
||||
origin plane. The tip's own height is inside this number, which is why it is
|
||||
not a separate field.
|
||||
* ``rising`` — the drive's sign: true when a larger coordinate lifts the tip.
|
||||
* ``calibrated`` — whether somebody has measured the two on *this* machine.
|
||||
Until then :meth:`target_for` returns ``None`` and the caller holds the press
|
||||
axis rather than driving it to a geometric height nobody has anchored.
|
||||
"""
|
||||
|
||||
zero_mm: float = 0.0
|
||||
rising: bool = True
|
||||
calibrated: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: object) -> PressAxis:
|
||||
"""Malformed or incomplete calibration settings keep the press axis held."""
|
||||
if not isinstance(settings, Mapping):
|
||||
return cls()
|
||||
if isinstance(settings.get("zero_mm"), bool):
|
||||
return cls()
|
||||
try:
|
||||
zero = float(settings["zero_mm"])
|
||||
rising = settings["rising"]
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return cls()
|
||||
if not math.isfinite(zero) or not isinstance(rising, bool):
|
||||
return cls()
|
||||
return cls(zero_mm=zero, rising=rising, calibrated=settings.get("calibrated") is True)
|
||||
|
||||
def target_for(self, contact_z_mm: float) -> float | None:
|
||||
"""The axis coordinate that puts the tool tip at ``contact_z_mm``.
|
||||
|
||||
``None`` means the relation is not calibrated on this machine, and the
|
||||
caller must hold the press axis: passing the geometric number through as an
|
||||
absolute target is what this class exists to stop.
|
||||
"""
|
||||
if not self.calibrated:
|
||||
return None
|
||||
height = float(contact_z_mm)
|
||||
return self.zero_mm + (height if self.rising else -height)
|
||||
|
||||
|
||||
def press_targets(
|
||||
targets: Mapping[str, float], press: PressAxis
|
||||
) -> tuple[dict[str, float], tuple[str, ...]]:
|
||||
"""Resolve the press role in ``targets``, or hand it back as a held role.
|
||||
|
||||
Returns the roles to command and the roles to hold. Everything but ``z`` passes
|
||||
through untouched: the stage angles and the table move are already in the units
|
||||
their axes report, while the contact height is geometry.
|
||||
"""
|
||||
resolved = dict(targets)
|
||||
if "z" not in resolved:
|
||||
return resolved, ()
|
||||
coordinate = press.target_for(resolved["z"])
|
||||
if coordinate is None:
|
||||
resolved.pop("z")
|
||||
return resolved, ("z",)
|
||||
resolved["z"] = float(coordinate)
|
||||
return resolved, ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoveStep:
|
||||
"""One axis, one target, in that role's own unit."""
|
||||
|
||||
@@ -106,6 +106,29 @@ def _default_safety_limits() -> dict[str, float]:
|
||||
}
|
||||
|
||||
|
||||
def _default_press_axis() -> dict[str, Any]:
|
||||
"""How the press axis's coordinate relates to the geometric contact height.
|
||||
|
||||
The solved contact sits at ``Pose.contact_z_mm`` above the table's origin
|
||||
plane, in millimetres of *geometry*. The press axis reports its own coordinate,
|
||||
in its own units, counted from its own mechanical zero — the two are different
|
||||
numbers, and only a measurement on the bench relates them.
|
||||
|
||||
``zero_mm`` is the press axis reading at which the tool tip meets the table's
|
||||
origin plane (so the tool tip's own height is already inside it), and
|
||||
``rising`` is the drive's sign: true when a larger coordinate lifts the tip.
|
||||
``calibrated`` stays false until an operator has measured both against the
|
||||
machine, and until then the pose-move path holds the press axis instead of
|
||||
driving it to a geometric height nobody has anchored — the previous behaviour,
|
||||
which passed the geometric number straight through as an absolute target.
|
||||
"""
|
||||
return {
|
||||
"zero_mm": 0.0,
|
||||
"rising": True,
|
||||
"calibrated": False,
|
||||
}
|
||||
|
||||
|
||||
def _default_sensor_setup() -> dict[str, Any]:
|
||||
"""Workflow P3/P4: what is under test, what touches it, and the transform.
|
||||
|
||||
@@ -204,6 +227,10 @@ def default_preferences() -> dict[str, Any]:
|
||||
"sensor_setup": _default_sensor_setup(),
|
||||
#: P5: grid generation and run parameters.
|
||||
"sequence_profile": _default_sequence_profile(),
|
||||
#: The press axis's mechanical zero and sign, so a solved contact height
|
||||
#: can become a controller target. Uncalibrated means the press axis is
|
||||
#: held rather than driven.
|
||||
"press_axis": _default_press_axis(),
|
||||
# --- finger debug screen ---------------------------------------------
|
||||
#: The export bundle the debug screen loads. Empty means "ask", and the
|
||||
#: default below points at the revision the bench confirmed.
|
||||
@@ -242,7 +269,13 @@ def default_preferences() -> dict[str, Any]:
|
||||
#: a cosmetic problem: the move is refused with "the limit is not set" while the file
|
||||
#: in front of the operator says nothing about it. Filling the block key by key keeps
|
||||
#: what was chosen and supplies only what was never there.
|
||||
_MERGED_BLOCKS = ("connection", "safety_limits", "sensor_setup", "sequence_profile")
|
||||
_MERGED_BLOCKS = (
|
||||
"connection",
|
||||
"safety_limits",
|
||||
"sensor_setup",
|
||||
"sequence_profile",
|
||||
"press_axis",
|
||||
)
|
||||
|
||||
|
||||
def load_preferences() -> dict[str, Any]:
|
||||
|
||||
@@ -48,7 +48,7 @@ def read_plan(path: Path, definition: dict) -> dict:
|
||||
def overview_rows(review: dict, plan: dict | None = None) -> list[dict]:
|
||||
planned = plan_points(plan, review["uv_definition"]) if plan is not None else []
|
||||
planned_uv = {tuple(p["uv_xy_px"]) for p in planned}
|
||||
actual = {tuple(site["uv_xy_px"]): site for site in review["sites"]}
|
||||
actual = {tuple(site["uv_xy_px"]): site for site in review["sites"] if site.get("uv_xy_px")}
|
||||
rows = []
|
||||
for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]:
|
||||
site = actual.get(tuple(point["uv_xy_px"]))
|
||||
|
||||
@@ -110,6 +110,290 @@ def _quality(windows: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
#: The multi-point campaign writes its own manifest and keeps each repeat's frame
|
||||
#: source in a nested recorder session; both are reviewed through this module's one
|
||||
#: window contract so the operator sees one review screen for either recording.
|
||||
MULTIPOINT_FORMAT = "autocal-multipoint-1.0"
|
||||
CAPTURE_DIRECTORY = "capture"
|
||||
#: Live capture stamps frames against this host's monotonic clock, the same base the
|
||||
#: campaign uses for its hold windows. A replayed or absent basis cannot be joined to
|
||||
#: a reference window, so it is reported instead of being compared.
|
||||
LIVE_TIME_BASIS = "host_camera"
|
||||
#: The campaign calls the initial zero-load hold ``baseline``; the review contract names
|
||||
#: that branch ``zero``, so one fitting and hysteresis rule serves both recordings.
|
||||
BRANCH_ALIASES = {"baseline": "zero"}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _capture_source(directory: Path, root: Path, inputs: dict) -> dict:
|
||||
"""Read the frame source recorded beside one repeat, or report why it is unusable.
|
||||
|
||||
Image-derived force samples only exist where the shared recorder ran: simulation and
|
||||
a detached camera leave no frames, and the gap has to stay visible as a reason rather
|
||||
than become an empty window that reads like a measurement.
|
||||
"""
|
||||
source = {
|
||||
"recorded": False, "reason": "no_capture_directory",
|
||||
"directory": str(directory.relative_to(root)) if directory.is_relative_to(root) else str(directory),
|
||||
"capture_session_id": None, "frames": 0, "in_video": 0, "time_bases": [],
|
||||
"segments": [], "samples": [],
|
||||
}
|
||||
if not directory.is_dir():
|
||||
return source
|
||||
for name in ("session.json", "frames.csv", "forces.csv"):
|
||||
if not (directory / name).is_file():
|
||||
source["reason"] = f"missing {name}"
|
||||
return source
|
||||
for name in ("session.json", "frames.csv", "forces.csv"):
|
||||
inputs[str((directory / name).relative_to(root))] = _sha256(directory / name)
|
||||
metadata = json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
||||
frames: dict[int, int] = {}
|
||||
bases: set[str] = set()
|
||||
in_video = 0
|
||||
segments: set[int] = set()
|
||||
for row in _rows(directory / "frames.csv"):
|
||||
if row.get("dropped") != "0" or not row.get("t_host_mono_ns"):
|
||||
continue
|
||||
if row.get("in_video") != "1":
|
||||
continue
|
||||
segment = int(row["segment"])
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
if segment < 1 or not video.is_file() or video.stat().st_size == 0:
|
||||
source["reason"] = "missing_video_segment"
|
||||
return source
|
||||
frame_id = int(row["frame_id"])
|
||||
if frame_id in frames:
|
||||
raise ValueError("duplicate frame identity in a recorded frame source")
|
||||
frames[frame_id] = int(row["t_host_mono_ns"])
|
||||
bases.add(row.get("time_basis", ""))
|
||||
if row.get("in_video") == "1":
|
||||
in_video += 1
|
||||
if row.get("segment"):
|
||||
segments.add(int(row["segment"]))
|
||||
if not frames:
|
||||
source["reason"] = "no_recorded_video_frames"
|
||||
return source
|
||||
for segment in segments:
|
||||
video = directory / f"raw_{segment:04d}.mp4"
|
||||
inputs[str(video.relative_to(root))] = _sha256(video)
|
||||
session_id = metadata.get("session_id")
|
||||
samples = []
|
||||
for row in _rows(directory / "forces.csv"):
|
||||
if row.get("valid") != "1" or not row.get("FN_raw"):
|
||||
continue
|
||||
if session_id is not None and int(row["session_id"]) != int(session_id):
|
||||
raise ValueError("frame source identity changed inside one repeat")
|
||||
stamp = frames.get(int(row["frame_id"]))
|
||||
raw = float(row["FN_raw"])
|
||||
if stamp is not None and math.isfinite(raw):
|
||||
samples.append((stamp, int(row["frame_id"]), raw))
|
||||
samples.sort()
|
||||
source.update({
|
||||
"recorded": True, "reason": "", "capture_session_id": session_id,
|
||||
"frames": len(frames), "in_video": in_video, "time_bases": sorted(bases),
|
||||
"segments": sorted(segments), "samples": samples,
|
||||
})
|
||||
return source
|
||||
|
||||
|
||||
def _multipoint_site(record: dict, windows: list[dict]) -> dict:
|
||||
"""Keep the campaign's own record and add the review contract's aliases to it."""
|
||||
position = record.get("position") or {}
|
||||
position_sensor_mm = None
|
||||
if record.get("position_frame") == "sensor":
|
||||
try:
|
||||
values = [float(position[axis]) for axis in "xyz"]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
values = []
|
||||
if len(values) == 3 and all(math.isfinite(value) for value in values):
|
||||
position_sensor_mm = values
|
||||
uv = record.get("uv_px")
|
||||
if isinstance(uv, dict):
|
||||
uv = [uv["u"], uv["v"]] if "u" in uv and "v" in uv else None
|
||||
training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"]
|
||||
confirmed = [w for w in training if w["stability_verified"]]
|
||||
# The declared steps in window order are the loading and unloading curve the
|
||||
# operator asked for; measured means are added where the frame source provided them.
|
||||
curve = [{
|
||||
"trial_id": w["trial_id"], "step_index": w["step_index"], "branch": w["branch"],
|
||||
"recorded_branch": w["recorded_branch"],
|
||||
"target_load_n": w["target_load_n"], "reference_n": w["reference_n"],
|
||||
"samples": w["samples"], "raw_mean": w["raw_mean"], "raw_std": w["raw_std"],
|
||||
} for w in windows]
|
||||
return {
|
||||
**record,
|
||||
"uv_xy_px": [int(uv[0]), int(uv[1])] if isinstance(uv, (list, tuple)) and len(uv) == 2 else None,
|
||||
"position_sensor_mm": position_sensor_mm,
|
||||
"windows": windows, "curve": curve,
|
||||
"fit": _fit(training), "confirmed_fit": _fit(confirmed), "quality": _quality(windows),
|
||||
}
|
||||
|
||||
|
||||
def review_multipoint(root: Path, *, manifest: dict, selections: dict | None = None) -> dict:
|
||||
"""Review a multi-point session through the same hold-window contract.
|
||||
|
||||
The campaign declares every reference hold and records whether the repeat finished;
|
||||
the raw normal signal is whatever the shared recorder beside it captured. Nothing is
|
||||
filled in for a missing frame source, an unfinished repeat or an unverified window.
|
||||
"""
|
||||
selections = selections or {}
|
||||
used: set[str] = set()
|
||||
issues: list[dict] = []
|
||||
windows: list[dict] = []
|
||||
coverage: list[dict] = []
|
||||
inputs = {"session.json": _sha256(root / "session.json")}
|
||||
maximum = float(manifest["max_force_n"])
|
||||
if not math.isfinite(maximum) or maximum <= 0:
|
||||
raise ValueError("invalid session force range")
|
||||
simulated = manifest.get("simulate") is True
|
||||
for trial in manifest["trials"]:
|
||||
trial_id = trial["trial_id"]
|
||||
path = (root / trial["directory"]).resolve()
|
||||
if not path.is_relative_to(root):
|
||||
raise ValueError("trial directory is outside the multi-point session")
|
||||
record = json.loads((path / "session.json").read_text(encoding="utf-8"))
|
||||
for name in ("session.json", "reference.csv"):
|
||||
file = path / name
|
||||
if file.is_file():
|
||||
inputs[str(file.relative_to(root))] = _sha256(file)
|
||||
complete = trial.get("status") == record.get("status") == "complete"
|
||||
if not complete:
|
||||
issues.append({"trial_id": trial_id, "reason": "incomplete_trial",
|
||||
"detail": str(trial.get("reason") or record.get("status") or "")})
|
||||
source = _capture_source(path / CAPTURE_DIRECTORY, root, inputs)
|
||||
samples = source["samples"]
|
||||
unusable = [basis for basis in source["time_bases"] if basis != LIVE_TIME_BASIS]
|
||||
if not source["recorded"]:
|
||||
images = record.get("images") or {}
|
||||
issues.append({"trial_id": trial_id, "reason": "no_frame_source",
|
||||
"detail": str(images.get("reason") or source["reason"])})
|
||||
basis_reason = "no_frame_source"
|
||||
elif not source["time_bases"] or unusable:
|
||||
issues.append({"trial_id": trial_id, "reason": "time_basis_not_comparable",
|
||||
"detail": ", ".join(source["time_bases"]) or "time_basis absent"})
|
||||
samples = []
|
||||
basis_reason = "time_basis_not_comparable"
|
||||
else:
|
||||
basis_reason = ""
|
||||
blocked = basis_reason or ("simulation" if simulated else "") or (
|
||||
"incomplete_trial" if not complete else "")
|
||||
if simulated and not basis_reason:
|
||||
issues.append({"trial_id": trial_id, "reason": "simulation", "detail": "debug acquisition"})
|
||||
reference_rows = _rows(path / "reference.csv")
|
||||
curve_reference = [r for r in reference_rows if r.get("hold_complete") == "1"]
|
||||
coverage.append({
|
||||
"trial_id": trial_id, "site_id": trial["site_id"],
|
||||
"repeat_index": trial.get("repeat_index"), "status": trial.get("status"),
|
||||
"reason": trial.get("reason") or "", "windows": len(curve_reference),
|
||||
"frame_source": source["directory"] if source["recorded"] else None,
|
||||
"frames": source["frames"], "in_video": source["in_video"],
|
||||
"time_bases": source["time_bases"], "segments": source["segments"],
|
||||
"usable": bool(samples) and not blocked,
|
||||
"blocked_by": blocked,
|
||||
})
|
||||
for reference in reference_rows:
|
||||
if reference.get("hold_complete") != "1":
|
||||
continue
|
||||
start = int(reference["t_start_host_mono_ns"])
|
||||
end = int(reference["t_end_host_mono_ns"])
|
||||
load = float(reference["F_ref_normal_N"])
|
||||
if end <= start or not math.isfinite(load) or not 0 <= load <= maximum:
|
||||
raise ValueError(f"invalid reference window in {trial_id}")
|
||||
key = f"{trial_id}:{int(reference['step_index'])}"
|
||||
choice = selections.get(key)
|
||||
selected_start, selected_end = start, end
|
||||
verified = False
|
||||
if choice is not None:
|
||||
used.add(key)
|
||||
selected_start = int(choice["t_start_host_mono_ns"])
|
||||
selected_end = int(choice["t_end_host_mono_ns"])
|
||||
verified = choice["stability_verified"] is True
|
||||
if not start <= selected_start < selected_end <= end:
|
||||
raise ValueError(f"selection outside recorded hold: {key}")
|
||||
trace = [(stamp, frame, value) for stamp, frame, value in samples if start <= stamp < end]
|
||||
raw = np.array([value for stamp, _, value in trace if selected_start <= stamp < selected_end], dtype=float)
|
||||
if verified and raw.size < 2:
|
||||
raise ValueError(f"not enough valid samples to confirm: {key}")
|
||||
if blocked:
|
||||
status = blocked
|
||||
elif raw.size >= 2:
|
||||
status = "candidate"
|
||||
elif basis_reason:
|
||||
status = basis_reason
|
||||
else:
|
||||
status = "insufficient_samples"
|
||||
windows.append({
|
||||
"trial_id": trial_id, "site_id": trial["site_id"],
|
||||
"step_index": int(reference["step_index"]),
|
||||
"branch": BRANCH_ALIASES.get(reference["branch"], reference["branch"]),
|
||||
"recorded_branch": reference["branch"], "reference_n": load,
|
||||
"target_load_n": float(reference["target_load_n"]),
|
||||
# Declared references only: a hand-entered force is not an alignment.
|
||||
"reference_source": reference.get("reference_source", ""),
|
||||
"hold_start_host_mono_ns": start, "hold_end_host_mono_ns": end,
|
||||
"t_start_host_mono_ns": selected_start, "t_end_host_mono_ns": selected_end,
|
||||
"duration_s": (selected_end - selected_start) / 1e9, "samples": int(raw.size),
|
||||
"trace": trace,
|
||||
"raw_mean": float(raw.mean()) if raw.size else None,
|
||||
"raw_std": float(raw.std()) if raw.size else None,
|
||||
"raw_drift_per_s": float(np.polyfit(
|
||||
np.array([(stamp - selected_start) / 1e9 for stamp, _, _ in trace
|
||||
if selected_start <= stamp < selected_end]), raw, 1)[0])
|
||||
if raw.size >= 2 and len({stamp for stamp, _, _ in trace
|
||||
if selected_start <= stamp < selected_end}) >= 2 else None,
|
||||
"stability_verified": verified,
|
||||
"reference_alignment_possible": False,
|
||||
"frame_source": source["directory"] if source["recorded"] else None,
|
||||
"status": status,
|
||||
})
|
||||
if set(selections) != used:
|
||||
raise ValueError("selection refers to an unavailable hold window")
|
||||
sites = [_multipoint_site(record, [w for w in windows if w["site_id"] == record["site_id"]])
|
||||
for record in manifest["sites"]]
|
||||
completion = {entry["site_id"]: entry for entry in manifest.get("completion") or []}
|
||||
for site in sites:
|
||||
entry = completion.get(site["site_id"], {})
|
||||
site["coverage"] = {
|
||||
"repeats_planned": entry.get("repeats_planned"), "repeats_complete": entry.get("repeats_complete"),
|
||||
"trials": sum(1 for t in coverage if t["site_id"] == site["site_id"]),
|
||||
"usable_trials": sum(1 for t in coverage
|
||||
if t["site_id"] == site["site_id"] and t["usable"]),
|
||||
}
|
||||
site["formal_conclusion"] = False
|
||||
site["blockers"] = sorted({
|
||||
*[t["blocked_by"] for t in coverage
|
||||
if t["site_id"] == site["site_id"] and t["blocked_by"]],
|
||||
*["incomplete_trial" for t in coverage
|
||||
if t["site_id"] == site["site_id"] and t["status"] != "complete"],
|
||||
*[f"unverified_window:{w['step_index']}" for w in site["windows"]
|
||||
if not w["stability_verified"]],
|
||||
*(["no_fit"] if site["fit"] is None else []),
|
||||
})
|
||||
return {
|
||||
"format_version": "autocal-review-1.0", "source_format": MULTIPOINT_FORMAT,
|
||||
"session_id": manifest["session_id"], "source_directory": str(root),
|
||||
"session_status": manifest["status"], "status": "exploratory",
|
||||
"formal_conclusion": False, "sites": sites, "issues": issues,
|
||||
"selections": selections, "input_sha256": inputs, "analysis_inputs": {},
|
||||
"trials": manifest["trials"], "coverage": coverage,
|
||||
"uv_definition": manifest.get("uv_definition"),
|
||||
"sensor_model": manifest.get("sensor_model"), "sensor_sn": manifest.get("sensor_sn"),
|
||||
"started_at": manifest.get("started_at"), "uv_outline": manifest.get("uv_outline"),
|
||||
"completion": manifest.get("completion") or [],
|
||||
"session_reason": manifest.get("reason") or "",
|
||||
"sync": manifest.get("sync"), "platform_motion": bool(manifest.get("platform_motion")),
|
||||
"automatic_force_control": bool(manifest.get("automatic_force_control")),
|
||||
"force_range_n": [manifest.get("min_force_n"), manifest.get("max_force_n")],
|
||||
}
|
||||
|
||||
|
||||
def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
"""Join frame identity to host timestamps and inspect only complete windows.
|
||||
|
||||
@@ -118,8 +402,8 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
"""
|
||||
root = Path(directory).resolve()
|
||||
manifest = json.loads((root / "session.json").read_text(encoding="utf-8"))
|
||||
if manifest.get("format_version") == "autocal-multipoint-1.0":
|
||||
raise ValueError("multi-point sessions are raw acquisition folders; this review only opens finger sessions")
|
||||
if manifest.get("format_version") == MULTIPOINT_FORMAT:
|
||||
return review_multipoint(root, manifest=manifest, selections=selections)
|
||||
if manifest.get("format_version") != "autocal-session-1.0":
|
||||
raise ValueError("select a calibration session root, not an individual trial")
|
||||
windows = []
|
||||
@@ -208,6 +492,7 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
if raw.size >= 2 and len({stamp for stamp, _, _ in trace if selected_start <= stamp < selected_end}) >= 2 else None,
|
||||
"stability_verified": verified,
|
||||
"reference_alignment_possible": bool(metadata["reference_alignment_possible"]),
|
||||
"frame_source": trial["directory"],
|
||||
"status": "candidate" if raw.size >= 2 else "insufficient_samples",
|
||||
})
|
||||
if set(selections) != used:
|
||||
@@ -283,7 +568,9 @@ def _save_baselines(result: dict, destination: Path) -> list[dict]:
|
||||
selected = [r for r in window["trace"] if window["t_start_host_mono_ns"] <= r[0] < window["t_end_host_mono_ns"]]
|
||||
frame_id = selected[0][1]
|
||||
trial_id = window["trial_id"]
|
||||
trial_path = (root / trials[trial_id]["directory"]).resolve()
|
||||
# A multi-point repeat keeps its frames in the nested capture session, so the
|
||||
# window, not the format, says where the video index belongs.
|
||||
trial_path = (root / (window.get("frame_source") or trials[trial_id]["directory"])).resolve()
|
||||
row = next(r for r in _rows(trial_path / "frames.csv") if int(r["frame_id"]) == frame_id)
|
||||
item = {"site_id": site["site_id"], "trial_id": trial_id,
|
||||
"frame_id": frame_id, "role": "reviewed_zero_load_inspection",
|
||||
|
||||
@@ -26,9 +26,11 @@ from __future__ import annotations
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
@@ -43,11 +45,13 @@ from .bus_panel import BusPanel
|
||||
from .connection_panel import ConnectionPanel
|
||||
|
||||
#: Tab identity -> locale key, in the order they are shown. The order is the work:
|
||||
#: reach the machine, bring its bus up, then say what each drive moves.
|
||||
#: reach the machine, bring its bus up, then say what each drive moves and how the
|
||||
#: press axis's coordinate relates to the contact height the solver reports.
|
||||
TABS: tuple[tuple[str, str], ...] = (
|
||||
("connection", "setup.tab_connection"),
|
||||
("bus", "setup.tab_bus"),
|
||||
("axes", "setup.tab_axes"),
|
||||
("press", "setup.tab_press"),
|
||||
)
|
||||
|
||||
#: Role -> locale key for the mapping rows.
|
||||
@@ -165,6 +169,116 @@ class AxisRolesPage(QWidget):
|
||||
self._refresh_problem(announce=False)
|
||||
|
||||
|
||||
class PressAxisPage(QWidget):
|
||||
"""How the press axis's coordinate relates to the solved contact height.
|
||||
|
||||
A page of its own because it is a measurement on the machine, not a preference:
|
||||
the solver reports millimetres of geometry above the table's origin plane, and
|
||||
the drive is commanded in its own units from its own zero. Nothing in the model
|
||||
can supply the difference, so the operator does — and until they do, the pose
|
||||
move holds the press axis instead of driving it to a geometric number that was
|
||||
never anchored to this machine.
|
||||
"""
|
||||
|
||||
pressChanged = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
press: object = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
column = QVBoxLayout(self)
|
||||
column.setContentsMargins(8, 8, 8, 8)
|
||||
column.setSpacing(8)
|
||||
|
||||
self.hint_label = QLabel(self)
|
||||
self.hint_label.setObjectName("hintLabel")
|
||||
self.hint_label.setWordWrap(True)
|
||||
column.addWidget(self.hint_label)
|
||||
|
||||
self.box = QGroupBox(self)
|
||||
self.box.setObjectName("groupBox")
|
||||
form = QFormLayout(self.box)
|
||||
form.setContentsMargins(10, 8, 10, 10)
|
||||
form.setSpacing(6)
|
||||
|
||||
self.zero_label = QLabel(self.box)
|
||||
self.zero_spin = QDoubleSpinBox(self.box)
|
||||
self.zero_spin.setDecimals(4)
|
||||
self.zero_spin.setRange(-10000.0, 10000.0)
|
||||
self.zero_spin.setSuffix(" mm")
|
||||
self.zero_spin.valueChanged.connect(self._on_changed)
|
||||
form.addRow(self.zero_label, self.zero_spin)
|
||||
|
||||
self.rising_check = QCheckBox(self.box)
|
||||
self.rising_check.toggled.connect(self._on_changed)
|
||||
form.addRow(self.rising_check)
|
||||
|
||||
self.calibrated_check = QCheckBox(self.box)
|
||||
self.calibrated_check.toggled.connect(self._on_changed)
|
||||
form.addRow(self.calibrated_check)
|
||||
column.addWidget(self.box)
|
||||
|
||||
self.state_label = QLabel(self)
|
||||
self.state_label.setObjectName("statusValue")
|
||||
self.state_label.setWordWrap(True)
|
||||
column.addWidget(self.state_label)
|
||||
column.addStretch(1)
|
||||
|
||||
self.set_press(press)
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
|
||||
def set_press(self, press: object) -> None:
|
||||
"""Show the stored relation without announcing a change nobody made."""
|
||||
zero = getattr(press, "zero_mm", 0.0)
|
||||
rising = getattr(press, "rising", True)
|
||||
calibrated = getattr(press, "calibrated", False)
|
||||
for widget, value in (
|
||||
(self.zero_spin, float(zero)),
|
||||
(self.rising_check, bool(rising)),
|
||||
(self.calibrated_check, bool(calibrated)),
|
||||
):
|
||||
widget.blockSignals(True)
|
||||
try:
|
||||
if widget is self.zero_spin:
|
||||
widget.setValue(value)
|
||||
else:
|
||||
widget.setChecked(value)
|
||||
finally:
|
||||
widget.blockSignals(False)
|
||||
self._refresh_state(announce=False)
|
||||
|
||||
def press(self) -> dict[str, object]:
|
||||
return {
|
||||
"zero_mm": float(self.zero_spin.value()),
|
||||
"rising": bool(self.rising_check.isChecked()),
|
||||
"calibrated": bool(self.calibrated_check.isChecked()),
|
||||
}
|
||||
|
||||
def _on_changed(self, _value: object = None) -> None:
|
||||
self._refresh_state()
|
||||
|
||||
def _refresh_state(self, *, announce: bool = True) -> None:
|
||||
self.state_label.setText(
|
||||
t("setup.press_set") if self.calibrated_check.isChecked() else t("setup.press_unset")
|
||||
)
|
||||
if announce:
|
||||
self.pressChanged.emit()
|
||||
|
||||
# ------------------------------------------------------------- translations
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.hint_label.setText(t("setup.press_hint"))
|
||||
self.box.setTitle(t("setup.tab_press"))
|
||||
self.zero_label.setText(t("setup.press_zero"))
|
||||
self.rising_check.setText(t("setup.press_rising"))
|
||||
self.calibrated_check.setText(t("setup.press_calibrated"))
|
||||
self._refresh_state(announce=False)
|
||||
|
||||
|
||||
class DeviceSetupDialog(QDialog):
|
||||
"""Connection and bus initialisation, the once-per-start controls."""
|
||||
|
||||
@@ -173,6 +287,7 @@ class DeviceSetupDialog(QDialog):
|
||||
spec: ConnectionSpec,
|
||||
bas_path: str = "",
|
||||
roles: dict[str, int] | None = None,
|
||||
press: object = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -195,6 +310,7 @@ class DeviceSetupDialog(QDialog):
|
||||
self.connection_panel = ConnectionPanel(spec, self.tabs)
|
||||
self.bus_panel = BusPanel(bas_path, self.tabs)
|
||||
self.roles_page = AxisRolesPage(roles, self.tabs)
|
||||
self.press_page = PressAxisPage(press, self.tabs)
|
||||
for key, locale_key in TABS:
|
||||
self.tabs.addTab(self.panel(key), "")
|
||||
column.addWidget(self.tabs, 1)
|
||||
@@ -215,10 +331,12 @@ class DeviceSetupDialog(QDialog):
|
||||
# ----------------------------------------------------------------- content
|
||||
|
||||
def panel(self, key: str) -> QWidget:
|
||||
"""One of the three pages, by tab key."""
|
||||
"""One of the four pages, by tab key."""
|
||||
if key == "connection":
|
||||
return self.connection_panel
|
||||
return self.bus_panel if key == "bus" else self.roles_page
|
||||
if key == "bus":
|
||||
return self.bus_panel
|
||||
return self.roles_page if key == "axes" else self.press_page
|
||||
|
||||
def open_for(self, tab: str | None = None) -> None:
|
||||
"""Show the dialog, raising it if it is already up, and pick a tab.
|
||||
@@ -283,3 +401,4 @@ class DeviceSetupDialog(QDialog):
|
||||
self.connection_panel.retranslate()
|
||||
self.bus_panel.retranslate()
|
||||
self.roles_page.retranslate()
|
||||
self.press_page.retranslate()
|
||||
|
||||
@@ -25,6 +25,12 @@ from orisys_app_shared.i18n import t
|
||||
from ..multipoint import Campaign, next_point_id
|
||||
from ..sensor.uv_flat import site_at
|
||||
|
||||
#: A stored point and the mapping's answer for the same UV are the same point when
|
||||
#: they agree this closely. Further apart means the map, the bundle or the mounting
|
||||
#: moved under the record, and the point has to be taken again rather than retargeted.
|
||||
_POINT_MATCH_MM = 0.05
|
||||
_NORMAL_MATCH = 1e-3
|
||||
|
||||
|
||||
class _WrappingLabel(QLabel):
|
||||
"""Keep wrapped text inside its own row instead of painting over the next control."""
|
||||
@@ -275,29 +281,17 @@ class MultipointPanel(QWidget):
|
||||
def _hold(self) -> float:
|
||||
return float(self.host.hold.value())
|
||||
|
||||
def _solve_uv(self, u: float, v: float):
|
||||
"""Pose for the point currently chosen on 选择测点. Does not move the platform."""
|
||||
flat = getattr(self.host, "_flat", None)
|
||||
if flat is None:
|
||||
raise ValueError(t("multipoint.no_map"))
|
||||
site = site_at(flat, int(round(u)), int(round(v)), origin=getattr(self.host, "_origin", None))
|
||||
sample = flat.sample(*site.raster_xy_px)
|
||||
if sample is None:
|
||||
raise ValueError(t("multipoint.mesh_required"))
|
||||
point, normal, _face = sample
|
||||
def _solver(self):
|
||||
finger = self._finger
|
||||
stack = None if finger is None else getattr(finger, "stack", None)
|
||||
if stack is None:
|
||||
raise ValueError(t("multipoint.no_solver"))
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
length = float(np.linalg.norm(normal))
|
||||
unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple(
|
||||
float(value) for value in normal
|
||||
)
|
||||
pose = {
|
||||
return stack
|
||||
|
||||
@staticmethod
|
||||
def _pose_of(solved, stack) -> dict:
|
||||
"""The record of one solve: the targets, and what the solver said of them."""
|
||||
return {
|
||||
"x": float(solved.table_xy_mm[0]),
|
||||
"y": float(solved.table_xy_mm[1]),
|
||||
"yaw": float(solved.yaw_deg),
|
||||
@@ -310,13 +304,42 @@ class MultipointPanel(QWidget):
|
||||
"normal_aligned_to": f"tool_axis:{stack.tool_axis}",
|
||||
"reason": str(solved.reason or ""),
|
||||
}
|
||||
|
||||
def _solve_uv(self, u: float, v: float):
|
||||
"""Pose for the point currently chosen on 选择测点. Does not move the platform."""
|
||||
flat = getattr(self.host, "_flat", None)
|
||||
if flat is None:
|
||||
raise ValueError(t("multipoint.no_map"))
|
||||
site = site_at(flat, int(round(u)), int(round(v)), origin=getattr(self.host, "_origin", None))
|
||||
sample = flat.sample(*site.raster_xy_px)
|
||||
if sample is None:
|
||||
raise ValueError(t("multipoint.mesh_required"))
|
||||
point, normal, _face = sample
|
||||
stack = self._solver()
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
length = float(np.linalg.norm(normal))
|
||||
unit = tuple(float(value) / length for value in normal) if length >= 1e-9 else tuple(
|
||||
float(value) for value in normal
|
||||
)
|
||||
return (
|
||||
(float(point[0]), float(point[1]), float(point[2])),
|
||||
unit,
|
||||
pose,
|
||||
self._pose_of(solved, stack),
|
||||
(float(site.u_px), float(site.v_px)),
|
||||
)
|
||||
|
||||
def _solve_point(self, point, normal) -> dict:
|
||||
"""Pose for a recorded sensor-frame point, against the geometry in force now."""
|
||||
stack = self._solver()
|
||||
solved = stack.solve(
|
||||
np.asarray(point, dtype=np.float64),
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
)
|
||||
return self._pose_of(solved, stack)
|
||||
|
||||
def _solve_current(self):
|
||||
return self._solve_uv(float(self.host.u_spin.value()), float(self.host.v_spin.value()))
|
||||
|
||||
@@ -328,6 +351,70 @@ class MultipointPanel(QWidget):
|
||||
site.uv_px = uv
|
||||
site.position_frame = "sensor"
|
||||
|
||||
def _store_solved_pose(self, site_id: str, pose: dict) -> None:
|
||||
"""Record a fresh solve of a point without retaking the point itself."""
|
||||
site = next(item for item in self.campaign.sites if item.site_id == site_id)
|
||||
site.pose = pose
|
||||
|
||||
def _require_same_point(self, site, point, normal) -> None:
|
||||
"""Refuse a point the current map no longer puts where the record says.
|
||||
|
||||
A re-solve is allowed to answer differently; it is not allowed to move the
|
||||
point quietly. The recorded position is what a session's rows are
|
||||
attributed to, so a changed map, bundle or mounting has to reach the
|
||||
operator as "take this point again", never as a silently retargeted move.
|
||||
"""
|
||||
if site.position_frame != "sensor" or site.normal is None:
|
||||
return
|
||||
stored = np.asarray((site.x, site.y, site.z), dtype=np.float64)
|
||||
moved = float(np.linalg.norm(np.asarray(point, dtype=np.float64) - stored))
|
||||
if moved > _POINT_MATCH_MM:
|
||||
raise ValueError(t("multipoint.point_moved").format(mm=f"{moved:.2f}"))
|
||||
dot = float(np.dot(
|
||||
np.asarray(normal, dtype=np.float64),
|
||||
np.asarray(site.normal, dtype=np.float64),
|
||||
))
|
||||
turned = float(np.degrees(np.arccos(np.clip(dot, -1.0, 1.0))))
|
||||
if turned > _NORMAL_MATCH:
|
||||
raise ValueError(t("multipoint.normal_moved").format(deg=f"{turned:.2f}"))
|
||||
|
||||
def _resolve_site(self, site) -> dict:
|
||||
"""The pose to align now, solved against the geometry in force now.
|
||||
|
||||
``site.pose`` records what the solver said when the point was taken. The
|
||||
mounting offsets, the mounting angle, the tool position and the travel
|
||||
limits are all editable afterwards, so a stored pose can name a target
|
||||
this machine no longer reaches — or reach the same point a different way.
|
||||
The closed form is cheap and stateless, so this asks it again instead of
|
||||
keeping a cache and a rule for when to distrust it.
|
||||
"""
|
||||
if site.uv_px is not None:
|
||||
point, normal, pose, _uv = self._solve_uv(*site.uv_px)
|
||||
self._require_same_point(site, point, normal)
|
||||
self._store_solved_pose(site.site_id, pose)
|
||||
return pose
|
||||
if site.normal is None or site.position_frame != "sensor":
|
||||
raise ValueError(t("multipoint.point_unmapped"))
|
||||
pose = self._solve_point((site.x, site.y, site.z), site.normal)
|
||||
self._store_solved_pose(site.site_id, pose)
|
||||
return pose
|
||||
|
||||
def _pose_for_display(self, site):
|
||||
"""What the status line shows: solved now, or the record when that fails.
|
||||
|
||||
The line is read before a move, so it has to answer for the geometry in
|
||||
force now; a stored pose the current mounting no longer reaches would
|
||||
otherwise read as reachable right up to the refusal.
|
||||
"""
|
||||
if site is None:
|
||||
return None
|
||||
if self.campaign.is_active:
|
||||
return site.pose
|
||||
try:
|
||||
return self._resolve_site(site)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return site.pose
|
||||
|
||||
def _max_force(self) -> float:
|
||||
text = self.max_force.text().strip()
|
||||
if not text:
|
||||
@@ -417,18 +504,26 @@ class MultipointPanel(QWidget):
|
||||
"""The right-hand confirm button records the current multi-point step."""
|
||||
self._confirm()
|
||||
|
||||
def _can_align(self) -> bool:
|
||||
return bool(self._selected) and (
|
||||
not self.campaign.is_active
|
||||
or self._selected == self.campaign.pending_alignment_site
|
||||
)
|
||||
|
||||
def _align(self) -> None:
|
||||
if not self._selected:
|
||||
self.message.setText(t("multipoint.align_select"))
|
||||
return
|
||||
if not self._can_align():
|
||||
# Moving the platform in the middle of a run would invalidate the trial
|
||||
# being recorded, so a second point waits until this one is finished.
|
||||
self.message.setText(t("multipoint.align_busy"))
|
||||
return
|
||||
def go():
|
||||
site = next(item for item in self.campaign.sites if item.site_id == self._selected)
|
||||
pose = site.pose
|
||||
if pose is None or not pose.get("in_range"):
|
||||
if site.uv_px is None:
|
||||
raise ValueError(t("multipoint.no_pose"))
|
||||
point, normal, pose, uv = self._solve_uv(*site.uv_px)
|
||||
self._store_pose(site.site_id, point, normal, pose, uv)
|
||||
# Solved, not looked up: the stored pose may predate any of the
|
||||
# mounting, tool or travel settings the operator has changed since.
|
||||
pose = self._resolve_site(site)
|
||||
if not pose.get("ok") or not pose.get("in_range"):
|
||||
raise ValueError(t("multipoint.pose_refused").format(reason=pose.get("reason") or ""))
|
||||
self.alignRequested.emit(self.campaign.command_targets(self._selected))
|
||||
@@ -522,7 +617,7 @@ class MultipointPanel(QWidget):
|
||||
break
|
||||
elif self.campaign.phase != "liftoff":
|
||||
self._offered_liftoff = ""
|
||||
self.align_button.setEnabled(self.campaign.phase != "hold" and bool(self._selected))
|
||||
self.align_button.setEnabled(self._can_align())
|
||||
branch = {
|
||||
"baseline": t("multipoint.branch_baseline"),
|
||||
"load": t("multipoint.branch_load"),
|
||||
@@ -546,7 +641,7 @@ class MultipointPanel(QWidget):
|
||||
(site for site in self.campaign.sites if site.site_id == (state["site_id"] or self._selected)),
|
||||
None,
|
||||
)
|
||||
pose = None if shown is None else shown.pose
|
||||
pose = self._pose_for_display(shown)
|
||||
if pose:
|
||||
text = t("multipoint.pose").format(
|
||||
x=pose["x"], y=pose["y"], yaw=pose["yaw"], pitch=pose["pitch"],
|
||||
|
||||
@@ -45,6 +45,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
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.uv_flat import FlatSurface, mapping_sha256, site_at, uv_origin
|
||||
@@ -372,7 +373,9 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"branch",
|
||||
"table_move",
|
||||
"press",
|
||||
"residual",
|
||||
"frames",
|
||||
)
|
||||
@@ -751,13 +754,19 @@ class FingerPanel(QGroupBox):
|
||||
finally:
|
||||
self.confirm_check.blockSignals(False)
|
||||
|
||||
_press = PressAxis()
|
||||
|
||||
def solved_pose(self) -> tuple[dict[str, float], tuple[str, ...]] | None:
|
||||
"""The selected point's per-role targets, and the roles held back.
|
||||
|
||||
The two numbers that matter are the *table's* move and the stage angles: the
|
||||
table translation is what the stages are resolved against, and it is what the
|
||||
preview drew as the cyan path's end. Z is the contact height, commanded with
|
||||
the other four axes in the same move.
|
||||
preview drew as the cyan path's end.
|
||||
|
||||
Z is the *geometric* contact height, and it goes through
|
||||
:func:`press_targets` with the bench's press-axis relation. With no measured
|
||||
zero that role comes back as held, because a geometric height is not a
|
||||
controller coordinate and must not be passed off as one.
|
||||
"""
|
||||
pose = self._last_pose
|
||||
if pose is None:
|
||||
@@ -770,7 +779,41 @@ class FingerPanel(QGroupBox):
|
||||
"pitch": float(pose.pitch_deg),
|
||||
"z": float(pose.contact_z_mm),
|
||||
}
|
||||
return targets, ()
|
||||
return press_targets(targets, self._press)
|
||||
|
||||
def set_press(self, press: PressAxis) -> None:
|
||||
"""Remember how the press axis's coordinate relates to the contact height.
|
||||
|
||||
A bench measurement, entered in device setup. This panel does not invent it:
|
||||
until it is set the press role is held, and the readout says so rather than
|
||||
showing a target nobody has anchored to the machine.
|
||||
"""
|
||||
self._press = press
|
||||
self._readout_labels["press"].setText(
|
||||
"—" if self._last_pose is None else self._press_text(self._last_pose)
|
||||
)
|
||||
|
||||
def _branch_text(self, pose) -> str:
|
||||
"""Which of the exact poses was chosen, and which key chose it."""
|
||||
if not getattr(pose, "branch_reason", ""):
|
||||
return t("finger.branch_none")
|
||||
return t(
|
||||
"finger.branch_value",
|
||||
reason=t(f"finger.branch_{pose.branch_reason}"),
|
||||
travel=f"{pose.branch_travel_deg:.1f}",
|
||||
other=f"{pose.other_travel_deg:.1f}",
|
||||
)
|
||||
|
||||
def _press_text(self, pose) -> str:
|
||||
"""Where the press axis has to read for this contact, or why it is held."""
|
||||
target = self._press.target_for(pose.contact_z_mm)
|
||||
if target is None:
|
||||
return t("finger.press_unset")
|
||||
return t(
|
||||
"finger.press_target",
|
||||
target=f"{target:+.4f}",
|
||||
height=f"{pose.contact_z_mm:+.4f}",
|
||||
)
|
||||
|
||||
def _sync_sim_button(self) -> None:
|
||||
self.sim_button.setText(
|
||||
@@ -1212,7 +1255,9 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal",
|
||||
"platform_point",
|
||||
"pose",
|
||||
"branch",
|
||||
"table_move",
|
||||
"press",
|
||||
"residual",
|
||||
):
|
||||
self._readout_labels[key].setText("—")
|
||||
@@ -1240,6 +1285,8 @@ class FingerPanel(QGroupBox):
|
||||
)
|
||||
if pose is not None:
|
||||
self._readout_labels["pose"].setText(pose.describe())
|
||||
self._readout_labels["branch"].setText(self._branch_text(pose))
|
||||
self._readout_labels["press"].setText(self._press_text(pose))
|
||||
self._readout_labels["residual"].setText(
|
||||
t(
|
||||
"finger.residual_value",
|
||||
@@ -1326,7 +1373,9 @@ class FingerPanel(QGroupBox):
|
||||
"sensor_normal": "finger.row_sensor_normal",
|
||||
"platform_point": "finger.row_platform_point",
|
||||
"pose": "finger.row_pose",
|
||||
"branch": "finger.row_branch",
|
||||
"table_move": "finger.row_table_move",
|
||||
"press": "finger.row_press",
|
||||
"residual": "finger.row_residual",
|
||||
"frames": "finger.row_frames",
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ from PySide6.QtWidgets import (
|
||||
from .. import __version__ as app_version
|
||||
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
|
||||
from ..motion.kinematics import PlatformStack
|
||||
from ..motion.pose_move import PoseMover, plan_pose_move, plan_return_to_initial
|
||||
from ..motion.pose_move import (
|
||||
PoseMover,
|
||||
PressAxis,
|
||||
plan_pose_move,
|
||||
plan_return_to_initial,
|
||||
press_targets,
|
||||
)
|
||||
from ..motion.service import clamp_bas_task
|
||||
from ..motion.units import DEFAULT_UNITS
|
||||
from ..preferences import load_preferences, save_preferences
|
||||
@@ -219,6 +225,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self.spec,
|
||||
str(self.prefs.get("bas_path") or ""),
|
||||
self.prefs.get("axis_roles"),
|
||||
self._press_axis,
|
||||
self,
|
||||
)
|
||||
self.connection_panel = self.device_setup.connection_panel
|
||||
@@ -226,6 +233,9 @@ class AutoCalMainWindow(QMainWindow):
|
||||
# The mapping is live: the guards read it from the service, so an edit on the
|
||||
# page has to reach the service rather than only the preference file.
|
||||
self.device_setup.roles_page.rolesChanged.connect(self._on_axis_roles_changed)
|
||||
# The press-axis relation is live for the same reason: it decides whether a
|
||||
# geometric contact height may become a controller coordinate at all.
|
||||
self.device_setup.press_page.pressChanged.connect(self._on_press_axis_changed)
|
||||
|
||||
self.axis_readout = AxisReadoutPanel(
|
||||
MotionService().axis_count, self._theme, self.rail_content
|
||||
@@ -289,6 +299,7 @@ class AutoCalMainWindow(QMainWindow):
|
||||
tool_xy_mm=_triple(self.prefs.get("finger_tool_xy_mm"), 2),
|
||||
parent=body,
|
||||
)
|
||||
self.finger_panel.set_press(self._press_axis)
|
||||
self.finger_source_panel = FingerSourcePanel(
|
||||
video_path=str(self.prefs.get("finger_video_path") or ""),
|
||||
config_ref=str(self.prefs.get("finger_config_ref") or ""),
|
||||
@@ -404,6 +415,24 @@ class AutoCalMainWindow(QMainWindow):
|
||||
if problem:
|
||||
self.finger_panel.set_move_status(f"✗ {problem}")
|
||||
|
||||
@property
|
||||
def _press_axis(self) -> PressAxis:
|
||||
"""How the press axis's coordinate relates to a contact height.
|
||||
|
||||
A bench measurement, not a derived quantity: the geometric height a pose
|
||||
produces says nothing about what the controller will read when the press
|
||||
face reaches it. Until the operator has measured that relation the press
|
||||
role is held, so an uncalibrated machine cannot be commanded to a height
|
||||
that means nothing to it.
|
||||
"""
|
||||
return PressAxis.from_settings(self.prefs.get("press_axis"))
|
||||
|
||||
def _on_press_axis_changed(self) -> None:
|
||||
"""Apply a press-axis edit to the panel that turns heights into targets."""
|
||||
press = self.device_setup.press_page.press()
|
||||
self.prefs["press_axis"] = dict(press)
|
||||
self.finger_panel.set_press(self._press_axis)
|
||||
|
||||
# ---------------------------------------------------------- finger debug
|
||||
|
||||
def load_bundle(self, path: str, *, flat_size: tuple[int, int] = DEFAULT_FLAT_SIZE):
|
||||
@@ -1070,9 +1099,11 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _on_execute_pose(self) -> None:
|
||||
"""Move the platform to the solved pose of the finger screen's selection.
|
||||
|
||||
Yaw, pitch, table X/Y and the press axis start together. The Z target is
|
||||
the solved contact height. The numbers in the confirmation are the ones
|
||||
the preview drew.
|
||||
Yaw, pitch and table X/Y start together. The press axis joins them only
|
||||
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.
|
||||
"""
|
||||
solve = self.finger_panel.solved_pose()
|
||||
if solve is None:
|
||||
@@ -1089,12 +1120,18 @@ class AutoCalMainWindow(QMainWindow):
|
||||
self._start_move(plan, t("move.execute"))
|
||||
|
||||
def _on_align_multipoint(self, targets: dict) -> None:
|
||||
"""Same five-axis move as the finger screen, for the point chosen on 选择测点."""
|
||||
"""Same five-axis move as the finger screen, for the point chosen on 选择测点.
|
||||
|
||||
The calibration panel hands over geometry, not controller coordinates, so
|
||||
the press-axis relation is applied here as well — one place decides which
|
||||
roles a solved point may command, whichever screen asked for the move.
|
||||
"""
|
||||
targets, held = press_targets(targets, self._press_axis)
|
||||
plan = plan_pose_move(
|
||||
self.poller.service,
|
||||
targets,
|
||||
title=t("multipoint.align"),
|
||||
hold=(),
|
||||
hold=held,
|
||||
together=True,
|
||||
)
|
||||
self._start_move(
|
||||
@@ -1248,7 +1285,9 @@ class AutoCalMainWindow(QMainWindow):
|
||||
def _apply_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.setStyleSheet(build_qss(self._theme))
|
||||
stylesheet = build_qss(self._theme)
|
||||
if app.styleSheet() != stylesheet:
|
||||
app.setStyleSheet(stylesheet)
|
||||
self.brand_bar.set_theme(self._theme)
|
||||
self.status_strip.set_theme(self._theme)
|
||||
self.run_bar.set_theme(self._theme)
|
||||
|
||||
Reference in New Issue
Block a user