修正朝上判断、接触高度与过期姿态目标

朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。

离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
This commit is contained in:
ykcheungab
2026-10-15 06:52:28 +08:00
parent 5339624ffa
commit bcc55a3b97
29 changed files with 2179 additions and 209 deletions
@@ -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."""
+34 -1
View File
@@ -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",
}
+46 -7
View File
@@ -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)