修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。
离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
This commit is contained in:
@@ -19,6 +19,7 @@ import numpy as np
|
||||
import pytest
|
||||
from orisys_autocal.motion.kinematics import (
|
||||
ALIGNED_TOL_DEG,
|
||||
Branch,
|
||||
PlatformStack,
|
||||
Pose,
|
||||
residual_for,
|
||||
@@ -512,6 +513,10 @@ def test_the_table_move_lands_the_contact_on_the_tool():
|
||||
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
|
||||
assert final[0] == pytest.approx(stack.tool_xy_mm[0], abs=1e-9)
|
||||
assert final[1] == pytest.approx(stack.tool_xy_mm[1], abs=1e-9)
|
||||
# The reported contact is that same landing point, height included — the
|
||||
# whole point of the move is to arrive there, not merely at its xy.
|
||||
assert pose.contact_point_mm == pytest.approx(tuple(final), abs=1e-9)
|
||||
assert pose.contact_z_mm == pytest.approx(float(final[2]), abs=1e-9)
|
||||
|
||||
|
||||
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
|
||||
@@ -526,7 +531,12 @@ def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_fram
|
||||
moved = shifted.solve(point, normal)
|
||||
assert moved.target_xy_mm[0] - base.target_xy_mm[0] == pytest.approx(1.5)
|
||||
assert moved.target_xy_mm[1] - base.target_xy_mm[1] == pytest.approx(-2.5)
|
||||
assert moved.contact_z_mm == pytest.approx(base.contact_z_mm)
|
||||
# An in-plane offset leaves the *mounting* height alone, but not the height the
|
||||
# stages present: the same angles now lift a contact that sits further from the
|
||||
# trunnion axis. This line used to compare contact_z_mm on both sides, which
|
||||
# only held while that field was reported before the rotation.
|
||||
assert moved.mounted_point_mm[2] == pytest.approx(base.mounted_point_mm[2])
|
||||
assert moved.contact_z_mm - base.contact_z_mm > 0.1
|
||||
|
||||
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
|
||||
yawed = turned.solve(point, normal)
|
||||
@@ -552,7 +562,11 @@ def test_the_setup_z_is_the_mounting_plate():
|
||||
|
||||
lifted = dataclasses.replace(stack, sensor_offset_mm=(0.0, 0.0, 4.25))
|
||||
raised = lifted.solve(point, normal)
|
||||
assert raised.contact_z_mm - plain.contact_z_mm == pytest.approx(4.25)
|
||||
# The plate lifts the mounting pose by exactly its thickness. The height the
|
||||
# stages present rises by less, because the lift is applied before the stage
|
||||
# rotation and the rotation tilts it — 4.2285 mm of the 4.25 mm here.
|
||||
assert raised.mounted_point_mm[2] - plain.mounted_point_mm[2] == pytest.approx(4.25)
|
||||
assert 0.0 < raised.contact_z_mm - plain.contact_z_mm < 4.25
|
||||
assert raised.target_xy_mm == pytest.approx(plain.target_xy_mm)
|
||||
# And it is the same number as the plate parameter it replaced.
|
||||
assert lifted.setup_point(point)[2] == pytest.approx(
|
||||
@@ -605,3 +619,181 @@ def test_an_aligned_pose_reports_that_it_reached_tolerance():
|
||||
assert loose.reachable
|
||||
far = Pose(ok=True, residual_deg=30.0)
|
||||
assert not far.reached_tolerance and not far.reachable
|
||||
|
||||
|
||||
# --------------------------------------- the branch, the height and the up axis
|
||||
|
||||
|
||||
def _independent_rotation(yaw_deg: float, pitch_deg: float) -> np.ndarray:
|
||||
"""``R_pitch @ R_yaw`` from the formulas, not through ``rotation_about``."""
|
||||
yaw, pitch = np.radians(yaw_deg), np.radians(pitch_deg)
|
||||
about_yaw = np.array(
|
||||
[
|
||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
]
|
||||
)
|
||||
about_pitch = np.array(
|
||||
[
|
||||
[np.cos(pitch), 0.0, np.sin(pitch)],
|
||||
[0.0, 1.0, 0.0],
|
||||
[-np.sin(pitch), 0.0, np.cos(pitch)],
|
||||
]
|
||||
)
|
||||
return about_pitch @ about_yaw
|
||||
|
||||
|
||||
#: The two measured points from the bench screenshots, in the sensor frame:
|
||||
#: point, normal, mounting height, and the height the stages present. The mounting
|
||||
#: height is the plate (8 mm) over the point's own height; the presented height is
|
||||
#: what the plan records as 19.051 and 21.578 mm.
|
||||
MEASURED = (
|
||||
((4.872, 5.444, 16.176), (0.595, 0.673, 0.439), 13.444, 19.051),
|
||||
((-2.698, 5.186, 18.228), (-0.343, 0.699, 0.627), 13.186, 21.578),
|
||||
)
|
||||
|
||||
|
||||
def _measured_stack() -> PlatformStack:
|
||||
"""The bench's mounting: the plate is the mounting pose's z, 8 mm."""
|
||||
return PlatformStack(sensor_offset_mm=(0.0, 0.0, 8.0))
|
||||
|
||||
|
||||
def test_the_contact_height_is_the_height_after_the_stages_turn():
|
||||
"""Defect: the pose reported the contact's height *before* the stages turned.
|
||||
|
||||
On the bench that is the difference between 13.444 mm and 19.051 mm — the
|
||||
stages lift the contact several millimetres, and any press-axis target is
|
||||
derived from the height the contact is *presented* at, not from where it sits
|
||||
with the stages at zero.
|
||||
"""
|
||||
stack = _measured_stack()
|
||||
for point, normal, mounting, presented in MEASURED:
|
||||
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||
assert pose.mounted_point_mm[2] == pytest.approx(mounting, abs=5e-4)
|
||||
assert pose.contact_z_mm == pytest.approx(presented, abs=5e-4)
|
||||
|
||||
# The same height again, from a rotation built here rather than by the
|
||||
# solver: the landing point of the reported angles.
|
||||
landed = _independent_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
|
||||
np.array(point)
|
||||
)
|
||||
assert pose.contact_z_mm == pytest.approx(float(landed[2]), abs=1e-9)
|
||||
assert pose.contact_point_mm == pytest.approx(
|
||||
(stack.tool_xy_mm[0], stack.tool_xy_mm[1], float(landed[2])), abs=1e-9
|
||||
)
|
||||
assert pose.contact_point_mm[2] > pose.mounted_point_mm[2]
|
||||
|
||||
|
||||
def test_the_pose_aligns_the_normal_and_keeps_the_body_up():
|
||||
"""Task-1 acceptance: independent rotation, tool axis and the platform's up.
|
||||
|
||||
"Up" is the direction the sensing face nominally aims — platform +Z, which is
|
||||
where the mount sends the sensor's own up axis — not a horizontal axis of the
|
||||
platform frame.
|
||||
"""
|
||||
stack = _measured_stack()
|
||||
up = np.array([0.0, 0.0, 1.0])
|
||||
for point, normal, _mounting, _presented in MEASURED:
|
||||
pose = stack.solve(np.array(point), np.array(normal) / np.linalg.norm(normal))
|
||||
rotate = _independent_rotation(pose.yaw_deg, pose.pitch_deg)
|
||||
|
||||
aligned = rotate @ stack.setup_normal(np.array(normal))
|
||||
angle = np.degrees(np.arccos(np.clip(float(aligned @ up), -1.0, 1.0)))
|
||||
assert angle <= 1e-5
|
||||
|
||||
# ``uprightness`` is that same rotation carried onto the up axis: the
|
||||
# component left pointing up, 1.0 when nothing has tipped at all.
|
||||
assert stack.uprightness(pose.yaw_deg, pose.pitch_deg) == pytest.approx(
|
||||
float((rotate @ up)[2]), abs=1e-12
|
||||
)
|
||||
assert 0.0 < stack.uprightness(pose.yaw_deg, pose.pitch_deg) <= 1.0
|
||||
assert pose.upright
|
||||
|
||||
|
||||
def test_both_exact_poses_of_a_measured_point_leave_the_sensor_face_up():
|
||||
"""Defect: the up test measured the body against a *horizontal* axis.
|
||||
|
||||
The two exact poses of a measured point are one pose rolled 180 degrees about
|
||||
the vertical: same contact, same height, mirrored azimuth. Neither turns the
|
||||
sensing face over, so flagging one of them inverted was the bug — and the
|
||||
panel, the preview and the guards all read that flag.
|
||||
"""
|
||||
stack = PlatformStack()
|
||||
first = _independent_rotation(-143.580, 47.692)
|
||||
second = _independent_rotation(36.420, -47.692)
|
||||
assert second == pytest.approx(rotation_about("z", 180.0) @ first, abs=1e-9)
|
||||
|
||||
for yaw_deg, pitch_deg in (
|
||||
(36.420, -47.692),
|
||||
(-143.580, 47.692),
|
||||
(-61.319, 45.636),
|
||||
(118.681, -45.636),
|
||||
):
|
||||
assert stack.uprightness(yaw_deg, pitch_deg) > 0.0
|
||||
assert stack.ends_upright(yaw_deg, pitch_deg)
|
||||
|
||||
|
||||
def test_equivalent_poses_are_chosen_by_the_machine_not_by_the_residual():
|
||||
"""Two exact poses differ by float noise; that must not decide the answer.
|
||||
|
||||
The right-hand screenshot point, both ways round: 84 degrees of stage travel
|
||||
against 191, and the shorter one carries the marginally *larger* residual.
|
||||
"""
|
||||
short = Branch(
|
||||
yaw_deg=36.420, pitch_deg=-47.692, residual_deg=1e-9, upright=True, in_range=True
|
||||
)
|
||||
long_way = Branch(
|
||||
yaw_deg=-143.580, pitch_deg=47.692, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
assert PlatformStack.select_branch([long_way, short]) is short
|
||||
assert PlatformStack.branch_reason([long_way, short], short) == "smallest"
|
||||
|
||||
|
||||
def test_a_materially_better_alignment_still_wins():
|
||||
"""Equivalent is a tolerance, not an excuse to ignore the residual."""
|
||||
coarse = Branch(yaw_deg=0.0, pitch_deg=0.0, residual_deg=3.0, upright=True, in_range=True)
|
||||
exact = Branch(yaw_deg=80.0, pitch_deg=40.0, residual_deg=0.05, upright=True, in_range=True)
|
||||
assert PlatformStack.select_branch([coarse, exact]) is exact
|
||||
assert PlatformStack.branch_reason([coarse, exact], exact) == "alignment"
|
||||
|
||||
|
||||
def test_real_roundoff_does_not_choose_a_half_turn_over_the_shorter_pose():
|
||||
stack = PlatformStack(pitch_limit_deg=90)
|
||||
normal = np.array([-0.499590704094966, 0.6734102426572587, -0.5449107940444822])
|
||||
unit = stack.setup_normal(normal)
|
||||
branches = stack.alignment_branches(unit, stack.tool_direction)
|
||||
short = min(branches, key=lambda branch: branch.travel_deg)
|
||||
for branch in branches:
|
||||
turned = stack.platform_rotation(branch.yaw_deg, branch.pitch_deg) @ unit
|
||||
np.testing.assert_allclose(turned, [0, 0, 1], atol=1e-12)
|
||||
assert stack.select_branch(branches) is short
|
||||
|
||||
|
||||
def test_the_branch_keys_are_applied_in_the_order_the_machine_cares_about():
|
||||
"""The envelope, then which way the body ends up, then the shorter turn."""
|
||||
inside = Branch(
|
||||
yaw_deg=-61.319, pitch_deg=45.636, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
outside = Branch(
|
||||
yaw_deg=0.0, pitch_deg=0.0, residual_deg=0.0, upright=True, in_range=False
|
||||
)
|
||||
assert PlatformStack.select_branch([inside, outside]) is inside
|
||||
assert PlatformStack.branch_reason([inside, outside], inside) == "in_range"
|
||||
|
||||
upright = Branch(
|
||||
yaw_deg=118.681, pitch_deg=-45.636, residual_deg=0.0, upright=True, in_range=True
|
||||
)
|
||||
rolled = Branch(
|
||||
yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=False, in_range=True
|
||||
)
|
||||
assert PlatformStack.select_branch([upright, rolled]) is upright
|
||||
assert PlatformStack.branch_reason([upright, rolled], upright) == "upright"
|
||||
|
||||
|
||||
def test_a_lone_branch_is_its_own_reason_and_an_empty_list_is_refused():
|
||||
only = Branch(yaw_deg=10.0, pitch_deg=10.0, residual_deg=0.0, upright=True, in_range=True)
|
||||
assert PlatformStack.select_branch([only]) is only
|
||||
assert PlatformStack.branch_reason([only], only) == "only"
|
||||
with pytest.raises(ValueError):
|
||||
PlatformStack.select_branch([])
|
||||
|
||||
Reference in New Issue
Block a user