Files
orisys-app/apps/autocal/tests/test_kinematics.py
T
2026-09-27 19:20:43 +08:00

572 lines
24 KiB
Python

"""The simulated 5-axis solve: residuals, the stage envelope, and the frames.
The shipped solver is **closed form** (see ``test_ik_analytical.py``, which checks
it against both the sweep and an exhaustive brute force). What this module covers
is everything around it: the mount mapping, the envelope, the frames, the reported
contact point, and the selectable sweep path.
The solver is the part of this feature that is easiest to get wrong and hardest
to notice — a wrong derivation still returns two plausible angles — so its results
are still checked against a brute-force search, just not with a fine grid over
seven normals when the closed form now guarantees exactness on its own.
"""
from __future__ import annotations
import dataclasses
import numpy as np
import pytest
from orisys_autocal.motion.kinematics import (
ALIGNED_TOL_DEG,
PlatformStack,
Pose,
residual_for,
rotation_about,
)
from orisys_autocal.sensor.frames import (
describe,
mount_rotation,
to_platform,
to_sensor,
)
#: Normals to exercise, including the awkward ones.
NORMALS = (
np.array([0.0, 1.0, 0.0]), # the patch's nominal normal
np.array([0.0, -1.0, 0.0]), # straight into the tool
np.array([1.0, 0.0, 0.0]),
np.array([0.0, 0.0, 1.0]),
np.array([0.3, 0.5, -0.81]),
np.array([-0.6, 0.2, 0.77]),
np.array([0.0, 0.3, 0.954]),
)
def _brute_force_best(
stack: PlatformStack, unit_platform: np.ndarray, *, step: float = 0.25
) -> float:
"""The lowest residual any pose in the envelope achieves — by search.
Kept, but no longer run over the whole grid for every normal: the shipped
solver is closed form and ``test_ik_analytical.py`` checks it against an
exhaustive sweep. This survives as the reference for the *sweep* path, which
is still selectable and still needs a floor to be measured against.
"""
tool = stack.tool_direction
yaw_limit = min(180.0, stack.yaw_limit_deg)
pitch_limit = min(360.0, stack.pitch_limit_deg)
best = 1e9
for yaw in np.arange(-yaw_limit, yaw_limit + step, step):
turned = rotation_about(stack.yaw_axis, float(yaw)) @ unit_platform
for pitch in np.arange(-pitch_limit, pitch_limit + step, step):
rotated = rotation_about(stack.pitch_axis, float(pitch)) @ turned
residual = float(np.degrees(np.arccos(np.clip(rotated @ tool, -1.0, 1.0))))
best = min(best, residual)
return best
# ------------------------------------------------------------------- the mount
def test_the_axis_map_is_a_rotation():
"""A mapping that mirrors the part is worse than none: it looks fine."""
matrix = mount_rotation()
assert float(np.linalg.det(matrix)) == pytest.approx(1.0)
assert np.allclose(matrix @ matrix.T, np.eye(3))
def test_the_sensor_y_maps_to_the_platform_z():
"""The bench's frame relation, stated once so it can be corrected once."""
assert np.allclose(to_platform(np.array([0.0, 1.0, 0.0])), [0.0, 0.0, 1.0])
def test_the_sensor_x_maps_to_the_platform_x():
assert np.allclose(to_platform(np.array([1.0, 0.0, 0.0])), [1.0, 0.0, 0.0])
def test_the_mount_round_trips():
vector = np.array([0.3, -0.5, 0.81])
assert np.allclose(to_sensor(to_platform(vector)), vector)
def test_a_mirroring_map_is_refused():
"""A map that reflects the part is worse than none: the numbers look sane."""
with pytest.raises(ValueError) as excinfo:
mount_rotation({"x": "x", "y": "y", "z": "-z"}) # det = -1
assert "not a rotation" in str(excinfo.value)
def test_an_incomplete_map_is_refused():
with pytest.raises(ValueError) as excinfo:
mount_rotation({"x": "x", "y": "z"})
assert "no entry" in str(excinfo.value)
def test_the_mapping_is_described_for_the_ui():
assert describe() == "sensor x->x, y->z, z->-y"
def test_swapping_the_mapping_changes_the_answer():
"""The one-line edit the bench may need, proven to actually do something."""
# Asymmetric on purpose: a point on a coordinate axis maps to the same place
# under both rotations, which would make this test pass without the mapping
# doing anything at all.
point = np.array([1.0, 5.0, 2.0])
default = PlatformStack()
# A different, still right-handed mapping: sensor x->y, y->z, z->x.
swapped = PlatformStack(axis_map={"x": "y", "y": "z", "z": "x"})
assert float(np.linalg.det(swapped.mount)) == pytest.approx(1.0)
assert not np.allclose(
default.point_to_platform(point), swapped.point_to_platform(point)
)
# --------------------------------------------------------------- the solving
def test_the_bench_envelope_is_the_default():
stack = PlatformStack()
# Confirmed at the bench and against the trunnion drawing: the vertical stage
# turns a full half-turn either way, the horizontal one turns continuously.
assert stack.yaw_limit_deg == 180.0
assert stack.pitch_limit_deg == 360.0
def test_the_nominal_normal_needs_no_rotation_and_lands_on_plus_z():
"""The polarity check, and the anchor for the whole solve.
The sensor sits at the bottom of its shell with the sensing face up, so the
face points along ``+tool_axis`` and the nominal pose is *no rotation at all*.
This is the test that was missing. ``approach_sign`` was ``-1``, aiming every
normal at ``-Z``; the solver dutifully used pitch 180 and turned the sensor
over, and nothing noticed because the residual is sign-blind — a face turned
fully over still scored 0.0000 degrees. Assert the direction landed in, not
just the angle between the lines.
"""
stack = PlatformStack()
assert stack.approach_sign == 1
assert np.allclose(stack.tool_direction, [0.0, 0.0, 1.0])
nominal = np.array([0.0, 1.0, 0.0]) # sensor +Y, the direction the face looks
unit = stack.normal_to_platform(nominal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.array([0.0, 5.0, 0.0]), nominal)
landed = rotation_about(stack.pitch_axis, pose.pitch_deg) @ rotation_about(
stack.yaw_axis, pose.yaw_deg
) @ unit
landed = landed / np.linalg.norm(landed)
assert landed[2] > 0.0, f"the face was aimed at {np.round(landed, 3)}, not +Z"
# And it gets there without turning: the commissioned pose is the identity.
assert abs(pose.pitch_deg) < 1e-6
assert abs(pose.yaw_deg) < 1e-6
assert pose.upright
def test_the_sweep_is_never_worse_than_a_brute_force_search():
"""The sweep path still needs a floor, but one normal is enough to pin it.
The shipped solver is closed form and ``test_ik_analytical.py`` checks it
against an exhaustive sweep over many normals. This keeps the *search* path
honest with one coarse brute force, rather than seven fine ones — the fine
version cost about three seconds per normal to confirm something the closed
form now guarantees outright.
"""
stack = PlatformStack()
normal = NORMALS[4]
unit = stack.normal_to_platform(normal)
unit = unit / np.linalg.norm(unit)
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal, method="search")
best = _brute_force_best(stack, unit, step=2.0)
assert pose.residual_deg <= best + 0.1, (
f"the sweep returned {pose.residual_deg:.3f} deg where "
f"{best:.3f} deg is achievable"
)
# --------------------------------------------- the fast sweep vs the full grid
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 test_the_fast_sweep_agrees_with_the_full_grid_sweep():
"""The closed-form sweep must find what the exhaustive grid finds.
The shipped search solves the best pitch at each yaw in closed form, which is
what made a full patch 14x faster. The exhaustive grid is kept as the slow
reference precisely so this comparison exists: an optimisation in this file has
silently cost 68 degrees before, while looking entirely reasonable.
"""
stack = PlatformStack()
for unit in _random_units(12, seed=20240517)[:6]:
fast_yaw, fast_pitch, fast_residual = stack._search_alignment( # noqa: SLF001
unit, stack.tool_direction
)
ref_yaw, ref_pitch, ref_residual = stack.search_alignment_reference(unit, step=5.0)
# The reference is quantised to the sweep step and is not polished, so it
# may be slightly worse; it must never be better than the shipped path.
assert fast_residual <= ref_residual + 1e-9, (
f"fast sweep left {fast_residual:.6f} deg where the full grid found "
f"{ref_residual:.6f} deg (fast {fast_yaw:.3f}/{fast_pitch:.3f}, "
f"reference {ref_yaw:.3f}/{ref_pitch:.3f})"
)
# And the polished answer should be far better than the grid's own step.
assert fast_residual <= ref_residual + 0.05
def test_the_closed_form_pitch_beats_a_direct_scan():
"""Guard the sign convention the closed form depends on.
There are two cancellation-free coefficients and picking the wrong one returns
the antipode: a pitch 180 degrees away that still looks like a number. This is
the check that caught exactly that, so it is stated directly against a numeric
scan of the pitch rather than against the rest of the solver.
"""
stack = PlatformStack()
for unit in _random_units(6, seed=99):
yaws = np.array([-80.0, -35.0, -10.0, 0.0, 15.0, 47.0, 90.0])
closed_form = stack._best_pitch_per_yaw(unit, yaws) # noqa: SLF001
for yaw, pitch in zip(yaws, closed_form):
scan = np.arange(-360.0, 360.0, 0.1)
yawed = stack._turn(stack.yaw_axis, np.array([float(yaw)]), unit)[0] # noqa: SLF001
# The residual is monotone in the dot product, so comparing the dot is
# enough and avoids a million arccos calls.
tool = stack.tool_direction
cosine, sine = np.cos(np.radians(scan)), np.sin(np.radians(scan))
stationary, first, second, sign_first, sign_second = (
stack._rotation_coefficients(stack.pitch_axis) # noqa: SLF001
)
dot = (
yawed[stationary] * tool[stationary]
+ (yawed[first] * cosine + sign_first * yawed[second] * sine) * tool[first]
+ (sign_second * yawed[first] * sine + yawed[second] * cosine) * tool[second]
)
achieved = stack._cell(unit, float(yaw), float(pitch)) # noqa: SLF001
achieved_dot = float(np.dot(achieved, tool))
assert achieved_dot >= float(dot.max()) - 1e-9, (
f"closed-form pitch {pitch:.3f} deg at yaw {yaw:.1f} reaches dot "
f"{achieved_dot:.6f}, but a direct scan reaches {dot.max():.6f}"
)
def _independent_residual(
stack: PlatformStack, normal_sensor: np.ndarray, yaw_deg: float, pitch_deg: float
) -> float:
"""The residual worked out from raw matrices built here, not by the solver.
``residual_for`` shares ``rotation_about`` with the solver, so it cannot catch
an error in that shared helper. This builds the two 3x3 matrices from the
rotation formulas directly, so the two paths agree only if both are right.
"""
normal = stack.normal_to_platform(np.asarray(normal_sensor, dtype=np.float64))
unit = normal / np.linalg.norm(normal)
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)],
]
)
turned = about_pitch @ about_yaw @ unit
return float(
np.degrees(np.arccos(np.clip(float(turned @ stack.tool_direction), -1.0, 1.0)))
)
@pytest.mark.parametrize("normal", NORMALS)
def test_a_returned_pose_actually_achieves_its_residual(normal):
"""Independent of how it was found: apply the angles and measure."""
stack = PlatformStack()
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
measured = residual_for(stack, normal, pose.yaw_deg, pose.pitch_deg)
assert measured == pytest.approx(pose.residual_deg, abs=1e-6)
@pytest.mark.parametrize("normal", NORMALS)
def test_the_residual_survives_an_independently_built_rotation(normal):
"""The shared rotation helper cannot be the only thing agreeing with itself."""
stack = PlatformStack()
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
measured = _independent_residual(stack, normal, pose.yaw_deg, pose.pitch_deg)
assert measured == pytest.approx(pose.residual_deg, abs=1e-6)
def test_every_sampled_patch_normal_is_reachable_within_the_yaw_limit():
"""The whole patch aligns, and the yaw limit is not what stops it.
An earlier revision of this solver claimed a quarter of the patch was
unreachable at 65 deg. That was the solver's bug. The real answer is that the
360 deg pitch stage does the work and the +-90 deg yaw limit costs at most a
quarter of a degree anywhere on the patch, so no re-mount is needed.
"""
stack = PlatformStack()
worst = 0.0
for normal in NORMALS:
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
assert pose.ok
worst = max(worst, pose.residual_deg)
assert worst < 0.5, f"worst residual across the sampled patch is {worst:.3f} deg"
def test_a_generic_normal_aligns_almost_exactly():
"""Away from the yaw clamp the solver should reach the polish floor."""
stack = PlatformStack()
for normal in NORMALS[:6]:
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
assert pose.residual_deg < 0.01, (
f"normal {normal} left {pose.residual_deg:.4f} deg behind"
)
def test_the_nominal_normal_needs_no_rotation():
"""Sensor +Y is the platform +Z, so the patch's centre is already aligned."""
stack = PlatformStack()
pose = stack.solve(np.array([0.0, 5.0, 0.0]), np.array([0.0, 1.0, 0.0]))
assert pose.ok
assert pose.residual_deg == pytest.approx(0.0, abs=1e-6)
def test_a_normal_straight_into_the_tool_is_flagged_indeterminate():
stack = PlatformStack()
pose = stack.solve(np.array([0.0, 5.0, 0.0]), np.array([0.0, -1.0, 0.0]))
assert pose.ok
assert not pose.yaw_determined
assert "indeterminate" in pose.reason
def test_a_degenerate_normal_is_refused():
stack = PlatformStack()
pose = stack.solve(np.array([0.0, 5.0, 0.0]), np.zeros(3))
assert not pose.ok
assert "degenerate" in pose.reason
def test_the_yaw_limit_is_respected():
stack = PlatformStack(yaw_limit_deg=90.0)
for normal in NORMALS:
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
assert abs(pose.yaw_deg) <= 90.0 + 1e-6
def test_the_pitch_limit_is_respected():
stack = PlatformStack(pitch_limit_deg=45.0)
for normal in NORMALS:
pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal)
assert abs(pose.pitch_deg) <= 45.0 + 1e-6
def test_tightening_the_envelope_can_only_leave_more_residual():
"""A narrower stage cannot align better than a wider one."""
normal = np.array([-0.6, 0.2, 0.77])
point = np.array([0.0, 5.0, 0.0])
wide = PlatformStack(yaw_limit_deg=180.0).solve(point, normal).residual_deg
narrow = PlatformStack(yaw_limit_deg=30.0).solve(point, normal).residual_deg
assert narrow >= wide - 1e-6
def test_an_unbounded_yaw_can_align_arbitrary_normals():
"""The envelope, not the solver, is what leaves residual."""
stack = PlatformStack(yaw_limit_deg=180.0, pitch_limit_deg=180.0)
worst = 0.0
for index in range(40):
normal = np.random.default_rng(index).normal(size=3)
normal = normal / np.linalg.norm(normal)
worst = max(worst, stack.solve(np.array([0.0, 5.0, 0.0]), normal).residual_deg)
assert worst < 0.5, f"worst residual {worst:.3f} deg with a free yaw"
def test_the_reported_xy_is_the_contact_point_not_the_origin():
stack = PlatformStack()
point = np.array([2.0, 5.0, 3.0])
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
platform = stack.point_to_platform(point)
assert pose.target_xy_mm[0] == pytest.approx(platform[0])
assert pose.target_xy_mm[1] == pytest.approx(platform[1])
def test_the_plate_lifts_the_point_along_the_tool_axis():
stack = PlatformStack()
point = np.array([1.0, 5.0, 2.0])
plain = stack.point_to_platform(point)
lifted = stack.point_to_platform(point, plate_thickness_mm=2.5)
assert np.allclose(lifted - plain, [0.0, 0.0, 2.5])
def test_the_tool_position_is_the_same_number_the_move_uses():
"""One field for "where the tool is", not two that can drift apart.
There used to be a separate ``tool_offset_mm`` added to the *reported* contact
while the drawing left the geometry alone: the panel then reported one landing
point and drew another, 10 mm away. The reported move, the drawn move and the
landing check all read ``tool_xy_mm`` now, and this pins that.
"""
stack = PlatformStack(tool_xy_mm=(10.0, -4.0))
point = np.array([2.0, 9.0, 1.0])
normal = np.array([0.3, 0.9, 0.2])
normal = normal / np.linalg.norm(normal)
pose = stack.solve(point, normal)
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(point)
final = landed + np.array([pose.table_xy_mm[0], pose.table_xy_mm[1], 0.0])
assert final[0] == pytest.approx(10.0, abs=1e-9)
assert final[1] == pytest.approx(-4.0, abs=1e-9)
# And there is no second place a tool position could hide.
assert not hasattr(stack, "tool_offset_mm")
def test_the_reported_xy_is_where_the_point_is_not_where_the_table_goes():
"""Two different numbers, kept apart on purpose."""
stack = PlatformStack(tool_xy_mm=(3.0, 3.0))
point = np.array([2.0, 5.0, 3.0])
stored = stack.setup_point(point)
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
assert pose.target_xy_mm[0] == pytest.approx(stored[0])
assert pose.target_xy_mm[1] == pytest.approx(stored[1])
# The move is what carries that point to the tool, so it is the rest of the way.
assert pose.table_xy_mm[0] == pytest.approx(3.0 - stored[0])
assert pose.table_xy_mm[1] == pytest.approx(3.0 - stored[1])
# ------------------------------------------------- the setup and the tool position
def test_the_default_tool_is_above_the_table_origin():
"""The default has to be the origin, or every reported move is off by a patch."""
stack = PlatformStack()
assert stack.tool_xy_mm == (0.0, 0.0)
point = np.array([3.0, 8.0, 1.0])
pose = stack.solve(point, np.array([0.0, 1.0, 0.0]))
# With the tool at the origin, the move is the contact's position negated: the
# table has to bring a point that is over there back to the middle.
assert pose.table_xy_mm[0] == pytest.approx(-pose.target_xy_mm[0])
assert pose.table_xy_mm[1] == pytest.approx(-pose.target_xy_mm[1])
def test_the_table_move_lands_the_contact_on_the_tool():
"""The one invariant the whole machine exists to satisfy."""
stack = PlatformStack(
tool_xy_mm=(4.0, -7.0),
sensor_offset_mm=(1.0, 2.0, 3.0),
sensor_yaw_deg=25.0,
)
for point, normal in (
(np.array([2.0, 9.0, 1.0]), np.array([0.3, 0.9, 0.2])),
(np.array([-4.0, 6.0, 3.0]), np.array([-0.6, 0.5, 0.4])),
(np.array([0.0, 11.0, 0.0]), np.array([0.0, 1.0, 0.0])),
):
pose = stack.solve(point, normal / np.linalg.norm(normal))
assert pose.ok
landed = stack.platform_rotation(pose.yaw_deg, pose.pitch_deg) @ stack.setup_point(
point
)
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)
def test_the_mounting_offset_moves_the_point_and_the_mounting_yaw_the_whole_frame():
"""x/y translate the contact; the yaw turns it about the table origin."""
stack = PlatformStack()
point = np.array([2.0, 6.0, 1.0])
normal = np.array([0.2, 0.95, 0.1])
normal = normal / np.linalg.norm(normal)
base = stack.solve(point, normal)
shifted = dataclasses.replace(stack, sensor_offset_mm=(1.5, -2.5, 0.0))
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)
turned = dataclasses.replace(stack, sensor_yaw_deg=35.0)
yawed = turned.solve(point, normal)
# A yaw about the table's z keeps the radius from the origin and the height.
assert float(np.hypot(*yawed.target_xy_mm)) == pytest.approx(
float(np.hypot(*base.target_xy_mm)), rel=1e-9
)
assert yawed.contact_z_mm == pytest.approx(base.contact_z_mm)
# The mounting yaw is absorbed by the stage: it has to turn that much less.
difference = (base.yaw_deg - yawed.yaw_deg) % 360.0
assert min(difference, 360.0 - difference) == pytest.approx(35.0, abs=1e-9)
assert yawed.pitch_deg == pytest.approx(base.pitch_deg)
assert yawed.residual_deg == pytest.approx(base.residual_deg, abs=1e-9)
def test_the_setup_z_is_the_mounting_plate():
"""The plate is not a separate input: it is the z of the mounting pose."""
stack = PlatformStack()
point = np.array([1.0, 7.0, 2.0])
normal = np.array([0.1, 0.99, 0.0])
normal = normal / np.linalg.norm(normal)
plain = stack.solve(point, normal)
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)
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(
stack.point_to_platform(point, plate_thickness_mm=4.25)[2]
)
def test_the_setup_normal_is_the_mounting_yaw_applied_to_the_mount():
stack = PlatformStack(sensor_yaw_deg=90.0)
normal = np.array([1.0, 0.0, 0.0])
# Sensor +x is platform +x; a 90 degree mounting yaw sends it to platform +y.
assert np.allclose(stack.setup_normal(normal), [0.0, 1.0, 0.0], atol=1e-12)
assert stack.normal_to_platform(normal) == pytest.approx([1.0, 0.0, 0.0])
def test_a_non_finite_sensor_yaw_is_refused():
with pytest.raises(ValueError):
PlatformStack(sensor_yaw_deg=float("nan")).setup_rotation()
def test_the_approach_sign_is_validated():
with pytest.raises(ValueError):
PlatformStack(approach_sign=0)
def test_swapping_the_approach_sign_changes_the_target():
"""The sign is the difference between pressing in and pulling away."""
normal = np.array([0.0, 1.0, 0.0])
point = np.array([0.0, 5.0, 0.0])
inward = PlatformStack(approach_sign=-1).solve(point, normal)
outward = PlatformStack(approach_sign=1).solve(point, normal)
assert inward.residual_deg == pytest.approx(0.0, abs=1e-6)
assert outward.pitch_deg != pytest.approx(inward.pitch_deg)
def test_a_pose_describes_itself():
pose = Pose(ok=True, target_xy_mm=(1.0, 2.0), yaw_deg=3.0, pitch_deg=4.0, residual_deg=0.01)
text = pose.describe()
assert "yaw" in text and "pitch" in text and "residual" in text
assert Pose(ok=False, reason="nope").describe() == "no pose: nope"
def test_an_aligned_pose_reports_that_it_reached_tolerance():
aligned = Pose(ok=True, residual_deg=0.0)
assert aligned.reached_tolerance and aligned.reachable
# ``reachable`` is the coarser statement: within half a degree counts as
# "the machine can put the tool on this normal".
loose = Pose(ok=True, residual_deg=ALIGNED_TOL_DEG * 10)
assert not loose.reached_tolerance
assert loose.reachable
far = Pose(ok=True, residual_deg=30.0)
assert not far.reached_tolerance and not far.reachable