"""The simulated 5-axis solve: residuals, the stage envelope, and the frames. 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 the central test here does not check a formula — it brute-forces the stage envelope and requires the solver to be no worse than the best pose that exists. That check is what caught the four wrong versions before it: rotating the sensor-frame normal instead of the platform-frame one, composing the stages in the wrong order, a seeded search that skipped the right basin, and a sweep that ranked yaws by the wrong quantity and so reported a quarter of the patch as unreachable when all of it aligns. """ from __future__ import annotations 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 ( AXIS_MAP, 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.""" 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() assert stack.yaw_limit_deg == 90.0 assert stack.pitch_limit_deg == 360.0 @pytest.mark.parametrize("normal", NORMALS) def test_the_solver_is_never_worse_than_a_brute_force_search(normal): """The test that pins the derivation down.""" stack = PlatformStack() unit = stack.normal_to_platform(normal) unit = unit / np.linalg.norm(unit) pose = stack.solve(np.array([0.0, 5.0, 0.0]), normal) best = _brute_force_best(stack, unit, step=0.5) assert pose.residual_deg <= best + 0.05, ( f"the solver 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_offset_shifts_the_reported_point(): stack = PlatformStack(tool_offset_mm=(1.0, 2.0, 3.0)) point = np.array([0.0, 5.0, 0.0]) pose = stack.solve(point, np.array([0.0, 1.0, 0.0])) plain = PlatformStack().solve(point, np.array([0.0, 1.0, 0.0])) assert pose.target_xy_mm[0] == pytest.approx(plain.target_xy_mm[0] + 1.0) assert pose.target_xy_mm[1] == pytest.approx(plain.target_xy_mm[1] + 2.0) 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