Files
orisys-app/apps/autocal/tests/test_ik_analytical.py
T
ykcheungab bcc55a3b97 修正朝上判断、接触高度与过期姿态目标
朝上判断误用平台轴、接触高度报旋转前数值、多点对准复用缓存姿态,且多点会话被复核入口拒绝;
现按修正计划改正并接通 autocal-multipoint-1.0 只读复核,缺失帧源如实报告而不补零。

离线验证:新回归在 dc5a6f8 上失败、此处通过,panel_check 通过,两截图点接触高度复现
19.0510 / 21.5781 mm;实机项未做,详见 .scratch/autocal-motion-fix/verification.md。
2026-10-15 06:52:28 +08:00

332 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The closed-form solve: exactness, agreement with the sweep, and what it reports.
The sweep in ``test_kinematics.py`` is the reference this path is checked against.
It is slow but line-by-line verifiable, and solvers in this file have been
confidently wrong five times; a fast path with no reference is how that happens.
"""
from __future__ import annotations
import math
from pathlib import Path
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import (
Branch,
PlatformStack,
rotation_about,
)
from orisys_autocal.sensor import load_bundle
#: The revision the bench confirmed. Skipped rather than failed when absent: the
#: export lives in a sibling design repo, and a machine without it has not broken
#: AutoCal.
BUNDLE = Path(r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute")
@pytest.fixture(scope="module")
def bundle():
if not BUNDLE.is_dir():
pytest.skip(f"no sensor export at {BUNDLE}")
return load_bundle(BUNDLE)
def _random_units(count: int, seed: int) -> list[np.ndarray]:
generator = np.random.default_rng(seed)
vectors = generator.normal(size=(count, 3))
return [vector / np.linalg.norm(vector) for vector in vectors]
def _apply(stack: PlatformStack, unit: np.ndarray, yaw_deg: float, pitch_deg: float) -> np.ndarray:
"""Re-apply a returned pose with the library's own rotations."""
return (
rotation_about(stack.pitch_axis, pitch_deg)
@ rotation_about(stack.yaw_axis, yaw_deg)
@ unit
)
# ------------------------------------------------------------------ exactness
def test_the_closed_form_is_exact_on_random_normals():
"""Two atan2 calls should land on the answer, not near it."""
stack = PlatformStack()
worst = 0.0
for unit in _random_units(2000, seed=20240517):
branches = stack.alignment_branches(unit, stack.tool_direction)
assert branches, f"no branch found for {unit}"
worst = max(worst, min(branch.residual_deg for branch in branches))
assert worst < 1e-4, f"closed form left {worst:.3e} deg on the table"
def test_every_branch_reproduces_its_own_claimed_residual():
"""The number reported must be the number the rotations actually give."""
stack = PlatformStack()
for unit in _random_units(200, seed=7):
for branch in stack.alignment_branches(unit, stack.tool_direction):
landed = _apply(stack, unit, branch.yaw_deg, branch.pitch_deg)
measured = math.degrees(
math.acos(
max(-1.0, min(1.0, float(landed @ stack.tool_direction)))
)
)
# ``acos`` near 1 loses about sqrt(eps) in radians because the
# derivative blows up there; 1e-5 deg is the floating-point floor,
# not slack in the answer.
assert measured == pytest.approx(branch.residual_deg, abs=1e-5)
# ------------------------------------------------------------------ agreement
def test_the_closed_form_is_no_worse_than_the_sweep():
"""The sweep is the reference; the fast path may beat it but never lose."""
stack = PlatformStack()
for unit in _random_units(400, seed=99):
fast = stack._search_alignment(unit, stack.tool_direction) # noqa: SLF001
branches = stack.alignment_branches(unit, stack.tool_direction)
best = min(branch.residual_deg for branch in branches)
assert best <= fast[2] + 1e-9, (
f"closed form left {best:.6f} deg where the sweep found {fast[2]:.6f}"
)
def test_the_two_methods_agree_inside_the_envelope():
stack = PlatformStack()
for unit in _random_units(200, seed=1234):
analytical = stack.solve(np.zeros(3), unit, method="analytical")
search = stack.solve(np.zeros(3), unit, method="search")
assert analytical.residual_deg <= search.residual_deg + 1e-6
for pose in (analytical, search):
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
assert abs(pose.pitch_deg) <= stack.pitch_limit_deg + 1e-6
# ----------------------------------------------------------- axis equivalence
def test_the_two_pitch_bindings_are_the_same_family():
"""Confirmed at the bench: a 90 degree yaw takes one binding to the other.
``R_y(p)·R_z(t)`` and ``R_x(p)·R_z(t − 90)`` agree to machine precision. So
which axis is called pitch is a naming and zero-offset choice, not a
correctness question — the two reach exactly the same set of alignments.
"""
unit = np.array([0.3, 0.5, -0.81])
unit = unit / np.linalg.norm(unit)
tool = np.array([0.0, 0.0, -1.0])
worst = 0.0
for yaw in (-120.0, -45.0, 0.0, 37.0, 90.0, 150.0):
for pitch in (-90.0, -20.0, 0.0, 15.0, 75.0):
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
about_x = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
worst = max(worst, abs(float(about_y @ tool) - float(about_x @ tool)))
assert worst == pytest.approx(0.0, abs=1e-12), (
f"the two pitch bindings diverge in alignment by {worst:.3e}"
)
def test_the_offset_between_the_bindings_is_a_constant_ninety_degrees():
"""A constant shift, so it is a commissioning zero rather than a correction."""
unit = np.array([0.3, 0.5, -0.81])
unit = unit / np.linalg.norm(unit)
tool = np.array([0.0, 0.0, -1.0])
for yaw in (-120.0, -30.0, 0.0, 45.0, 120.0):
for pitch in (-90.0, 0.0, 25.0, 75.0):
about_y = rotation_about("y", pitch) @ rotation_about("z", yaw) @ unit
same = rotation_about("x", pitch) @ rotation_about("z", yaw - 90.0) @ unit
assert float(about_y @ tool) == pytest.approx(float(same @ tool), abs=1e-12)
# ---------------------------------------------------------------- reporting
def test_the_normal_is_aligned_to_plus_z_not_minus_z(bundle):
"""The regression that would have caught the sensor being turned over.
``approach_sign`` was ``-1``, which asked the solver to bring every normal onto
**-Z** — the sensing face is the *top* surface of a sensor mounted at the bottom
of its shell, so it points up, and the tool feeds down onto it. Nothing caught
it because ``arccos`` is sign-blind: a face turned fully over still scored
``0.0000`` degrees, and every test here asserted only that the residual was
small. The failure showed up exclusively as the model flipping upside down.
So this asserts the *direction* the normal lands in, which is the thing the
residual cannot express.
"""
stack = PlatformStack()
assert stack.approach_sign == 1
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
for normal in bundle.vertex_normals():
unit = stack.normal_to_platform(normal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.zeros(3), normal)
# Rotate in the **platform** frame, which is where the residual is measured.
# ``stage_rotation`` is the same rotation conjugated into the mesh's frame,
# so using it here compares a sensor-frame vector against a platform-frame
# target and reports a mismatch that does not exist.
landed = _apply(stack, unit, pose.yaw_deg, pose.pitch_deg)
landed = landed / np.linalg.norm(landed)
# Aligned with the face's aim, not merely parallel to its line.
assert landed[2] > 0.0, (
f"the normal landed on {np.round(landed, 3)} — pointing away from +Z"
)
def test_the_nominal_pose_is_upright(bundle):
"""No rotation is the commissioned pose, and it must read as upright."""
stack = PlatformStack()
assert stack.uprightness(0.0, 0.0) == pytest.approx(1.0)
def test_the_surface_normals_point_up_so_the_face_faces_the_tool(bundle):
"""A sanity check on the geometry the polarity has to agree with.
The mean normal has a positive platform-Z component: the patch faces upward.
If this ever inverted, ``approach_sign`` would need revisiting rather than the
solver.
"""
stack = PlatformStack()
platform = np.array(
[stack.normal_to_platform(n) for n in bundle.vertex_normals()]
)
platform /= np.linalg.norm(platform, axis=1)[:, None]
assert float(platform[:, 2].mean()) > 0.0, (
f"mean platform-Z component of the normals is {platform[:, 2].mean():+.3f}"
)
def test_no_vertex_ends_turned_over(bundle):
"""Turning the face over is the failure mode; with the right up axis it is zero.
``uprightness`` used to compare the platform's Y axis, so 47 of these vertices read
as turned over even though they only rolled about the vertical. Against the
post-mount nominal up (the tool axis) no vertex ends inverted, and this now fails
if the reference regresses rather than reporting a small count as acceptable.
"""
stack = PlatformStack()
normals = bundle.vertex_normals()
flipped = 0
for normal in normals:
pose = stack.solve(np.zeros(3), normal)
if stack.uprightness(pose.yaw_deg, pose.pitch_deg) < -0.5:
flipped += 1
assert flipped == 0, f"{flipped} of {len(normals)} vertices ended turned over"
def test_a_pose_reports_whether_it_is_upright():
"""A pose can align the normal and still turn the sensor over."""
stack = PlatformStack()
seen = {True: 0, False: 0}
for unit in _random_units(300, seed=3):
pose = stack.solve(np.zeros(3), unit)
seen[pose.upright] += 1
# Whatever it says must match the measured orientation.
assert pose.upright == (stack.uprightness(pose.yaw_deg, pose.pitch_deg) >= 0.0)
assert seen[True] > 0
def test_an_out_of_range_pose_is_reported_and_never_silently_clamped():
"""Tight travel must give a measured residual, not a pose the stage cannot reach."""
stack = PlatformStack(pitch_limit_deg=45.0)
for unit in _random_units(50, seed=5):
pose = stack.solve(np.zeros(3), unit)
assert abs(pose.pitch_deg) <= 45.0 + 1e-6
assert abs(pose.yaw_deg) <= stack.yaw_limit_deg + 1e-6
if not pose.in_range:
assert "travel" in pose.reason
def test_nothing_is_reported_out_of_range_when_the_travel_is_ample():
stack = PlatformStack()
for unit in _random_units(300, seed=11):
pose = stack.solve(np.zeros(3), unit)
assert pose.in_range, f"{unit} reported out of range at full travel"
assert pose.reason == "" or "indeterminate" in pose.reason
def test_reachability_agrees_with_the_envelope():
"""Every normal is reachable when the yaw can turn a full half-turn."""
stack = PlatformStack()
for unit in _random_units(500, seed=13):
reachable, why = stack.reachable(unit, stack.tool_direction)
assert reachable, f"{unit} unreachable: {why}"
# And with the pitch stage able to turn, no normal is rejected either.
assert stack.reachable(
np.array([0.0, 0.0, 1.0]), stack.tool_direction
)[0]
# --------------------------------------------------------------------- poles
@pytest.mark.parametrize(
"normal",
[
np.array([0.0, 1.0, 0.0]), # on the tool axis
np.array([0.0, -1.0, 0.0]), # on the tool axis, other way
np.array([1.0, 0.0, 0.0]), # on the yaw plane
np.array([0.0, 0.0, 1.0]), # on the yaw axis
],
)
def test_poles_still_return_a_usable_pose(normal):
stack = PlatformStack()
pose = stack.solve(np.zeros(3), normal)
assert pose.ok
assert math.isfinite(pose.yaw_deg) and math.isfinite(pose.pitch_deg)
landed = _apply(stack, stack.normal_to_platform(normal) / np.linalg.norm(
stack.normal_to_platform(normal)
), pose.yaw_deg, pose.pitch_deg)
measured = math.degrees(
math.acos(max(-1.0, min(1.0, float(landed @ stack.tool_direction))))
)
assert measured <= stack.tolerance_deg + 1e-6
def test_the_degenerate_normal_is_still_refused():
stack = PlatformStack()
pose = stack.solve(np.zeros(3), np.zeros(3))
assert not pose.ok
assert "degenerate" in pose.reason
# ----------------------------------------------------------------- selection
def test_select_branch_prefers_in_range_then_upright_then_small():
reachable_upright = Branch(10.0, 5.0, 1e-6, True, True)
reachable_inverted = Branch(10.0, 5.0, 1e-6, False, True)
unreachable_upright = Branch(179.0, 5.0, 1e-6, True, False)
# In range beats everything: an inverted pose the machine can reach is worth
# more than an upright one it cannot.
assert PlatformStack.select_branch(
[unreachable_upright, reachable_inverted, reachable_upright]
) is reachable_upright
# Among reachable poses, upright wins.
assert PlatformStack.select_branch(
[reachable_inverted, reachable_upright]
) is reachable_upright
# Among poses that are out of range and one of them upright, upright wins.
unreachable_inverted = Branch(179.0, 5.0, 1e-6, False, False)
assert PlatformStack.select_branch(
[unreachable_inverted, unreachable_upright]
) is unreachable_upright
# A materially better alignment beats an upright pose.
much_better = Branch(10.0, 5.0, 0.0, False, True)
worse_upright = Branch(10.0, 5.0, 5.0, True, True)
assert PlatformStack.select_branch([much_better, worse_upright]) is much_better
# And with everything else equal, the smaller move wins.
smaller = Branch(5.0, 5.0, 1e-6, True, True)
larger = Branch(60.0, 5.0, 1e-6, True, True)
assert PlatformStack.select_branch([larger, smaller]) is smaller
def test_an_unknown_method_is_refused():
stack = PlatformStack()
with pytest.raises(ValueError):
stack.solve(np.zeros(3), np.array([0.0, 1.0, 0.0]), method="nonsense")