"""Tests for solver.mates.patterns -- joint pattern recognition.""" from __future__ import annotations import numpy as np from solver.datagen.types import JointType from solver.mates.patterns import JointPattern, PatternMatch, recognize_patterns from solver.mates.primitives import GeometryRef, GeometryType, Mate, MateType # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_ref( body_id: int, geom_type: GeometryType, *, geometry_id: str = "Geom001", origin: np.ndarray | None = None, direction: np.ndarray | None = None, ) -> GeometryRef: """Factory for GeometryRef with sensible defaults.""" if origin is None: origin = np.zeros(3) if direction is None and geom_type in { GeometryType.FACE, GeometryType.AXIS, GeometryType.PLANE, }: direction = np.array([0.0, 0.0, 1.0]) return GeometryRef( body_id=body_id, geometry_type=geom_type, geometry_id=geometry_id, origin=origin, direction=direction, ) def _make_mate( mate_id: int, mate_type: MateType, body_a: int, body_b: int, geom_a: GeometryType = GeometryType.FACE, geom_b: GeometryType = GeometryType.FACE, ) -> Mate: """Factory for Mate with body pair and geometry types.""" return Mate( mate_id=mate_id, mate_type=mate_type, ref_a=_make_ref(body_a, geom_a), ref_b=_make_ref(body_b, geom_b), ) # --------------------------------------------------------------------------- # JointPattern enum # --------------------------------------------------------------------------- class TestJointPattern: """JointPattern enum.""" def test_member_count(self) -> None: assert len(JointPattern) == 9 def test_string_values(self) -> None: for jp in JointPattern: assert isinstance(jp.value, str) def test_access_by_name(self) -> None: assert JointPattern["HINGE"] is JointPattern.HINGE # --------------------------------------------------------------------------- # PatternMatch # --------------------------------------------------------------------------- class TestPatternMatch: """PatternMatch dataclass.""" def test_construction(self) -> None: mate = _make_mate(0, MateType.LOCK, 0, 1) pm = PatternMatch( pattern=JointPattern.FIXED, mates=[mate], body_a=0, body_b=1, confidence=1.0, equivalent_joint_type=JointType.FIXED, ) assert pm.pattern is JointPattern.FIXED assert pm.confidence == 1.0 assert pm.missing_mates == [] def test_to_dict(self) -> None: mate = _make_mate(5, MateType.LOCK, 0, 1) pm = PatternMatch( pattern=JointPattern.FIXED, mates=[mate], body_a=0, body_b=1, confidence=1.0, equivalent_joint_type=JointType.FIXED, ) d = pm.to_dict() assert d["pattern"] == "fixed" assert d["mate_ids"] == [5] assert d["equivalent_joint_type"] == "FIXED" # --------------------------------------------------------------------------- # recognize_patterns — canonical patterns # --------------------------------------------------------------------------- class TestRecognizeCanonical: """Full-confidence canonical pattern recognition.""" def test_empty_input(self) -> None: assert recognize_patterns([]) == [] def test_hinge(self) -> None: """Concentric(axis) + Coincident(plane) -> Hinge.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 0, 1, GeometryType.AXIS, GeometryType.AXIS), _make_mate(1, MateType.COINCIDENT, 0, 1, GeometryType.PLANE, GeometryType.PLANE), ] results = recognize_patterns(mates) top = results[0] assert top.pattern is JointPattern.HINGE assert top.confidence == 1.0 assert top.equivalent_joint_type is JointType.REVOLUTE assert top.missing_mates == [] def test_slider(self) -> None: """Coincident(plane) + Parallel(axis) -> Slider.""" mates = [ _make_mate(0, MateType.COINCIDENT, 0, 1, GeometryType.PLANE, GeometryType.PLANE), _make_mate(1, MateType.PARALLEL, 0, 1, GeometryType.AXIS, GeometryType.AXIS), ] results = recognize_patterns(mates) top = results[0] assert top.pattern is JointPattern.SLIDER assert top.confidence == 1.0 assert top.equivalent_joint_type is JointType.SLIDER def test_cylinder(self) -> None: """Concentric(axis) only -> Cylinder.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 0, 1, GeometryType.AXIS, GeometryType.AXIS), ] results = recognize_patterns(mates) # Should match cylinder at confidence 1.0 cylinder = [r for r in results if r.pattern is JointPattern.CYLINDER] assert len(cylinder) >= 1 assert cylinder[0].confidence == 1.0 assert cylinder[0].equivalent_joint_type is JointType.CYLINDRICAL def test_ball(self) -> None: """Coincident(point) -> Ball.""" mates = [ _make_mate(0, MateType.COINCIDENT, 0, 1, GeometryType.POINT, GeometryType.POINT), ] results = recognize_patterns(mates) top = results[0] assert top.pattern is JointPattern.BALL assert top.confidence == 1.0 assert top.equivalent_joint_type is JointType.BALL def test_planar_face(self) -> None: """Coincident(face) -> Planar.""" mates = [ _make_mate(0, MateType.COINCIDENT, 0, 1, GeometryType.FACE, GeometryType.FACE), ] results = recognize_patterns(mates) top = results[0] assert top.pattern is JointPattern.PLANAR assert top.confidence == 1.0 assert top.equivalent_joint_type is JointType.PLANAR def test_fixed(self) -> None: """Lock -> Fixed.""" mates = [ _make_mate(0, MateType.LOCK, 0, 1, GeometryType.FACE, GeometryType.FACE), ] results = recognize_patterns(mates) top = results[0] assert top.pattern is JointPattern.FIXED assert top.confidence == 1.0 assert top.equivalent_joint_type is JointType.FIXED # --------------------------------------------------------------------------- # recognize_patterns — partial matches # --------------------------------------------------------------------------- class TestRecognizePartial: """Partial pattern matches and hints.""" def test_concentric_without_plane_hints_hinge(self) -> None: """Concentric alone matches hinge at 0.5 confidence with missing hint.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 0, 1, GeometryType.AXIS, GeometryType.AXIS), ] results = recognize_patterns(mates) hinge_matches = [r for r in results if r.pattern is JointPattern.HINGE] assert len(hinge_matches) >= 1 hinge = hinge_matches[0] assert hinge.confidence == 0.5 assert len(hinge.missing_mates) > 0 def test_coincident_plane_without_parallel_hints_slider(self) -> None: """Coincident(plane) alone matches slider at 0.5 confidence.""" mates = [ _make_mate(0, MateType.COINCIDENT, 0, 1, GeometryType.PLANE, GeometryType.PLANE), ] results = recognize_patterns(mates) slider_matches = [r for r in results if r.pattern is JointPattern.SLIDER] assert len(slider_matches) >= 1 assert slider_matches[0].confidence == 0.5 # --------------------------------------------------------------------------- # recognize_patterns — ambiguous / multi-body # --------------------------------------------------------------------------- class TestRecognizeAmbiguous: """Ambiguous patterns and multi-body-pair assemblies.""" def test_concentric_matches_both_hinge_and_cylinder(self) -> None: """A single concentric mate produces both hinge (partial) and cylinder matches.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 0, 1, GeometryType.AXIS, GeometryType.AXIS), ] results = recognize_patterns(mates) patterns = {r.pattern for r in results} assert JointPattern.HINGE in patterns assert JointPattern.CYLINDER in patterns def test_multiple_body_pairs(self) -> None: """Mates across different body pairs produce separate pattern matches.""" mates = [ _make_mate(0, MateType.LOCK, 0, 1), _make_mate(1, MateType.COINCIDENT, 2, 3, GeometryType.POINT, GeometryType.POINT), ] results = recognize_patterns(mates) pairs = {(r.body_a, r.body_b) for r in results} assert (0, 1) in pairs assert (2, 3) in pairs def test_results_sorted_by_confidence(self) -> None: """All results should be sorted by confidence descending.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 0, 1, GeometryType.AXIS, GeometryType.AXIS), _make_mate(1, MateType.LOCK, 2, 3), ] results = recognize_patterns(mates) confidences = [r.confidence for r in results] assert confidences == sorted(confidences, reverse=True) def test_unknown_pattern(self) -> None: """A mate type that matches no rule returns UNKNOWN.""" mates = [ _make_mate(0, MateType.ANGLE, 0, 1, GeometryType.FACE, GeometryType.FACE), ] results = recognize_patterns(mates) assert any(r.pattern is JointPattern.UNKNOWN for r in results) def test_body_pair_normalization(self) -> None: """Mates with reversed body order should be grouped together.""" mates = [ _make_mate(0, MateType.CONCENTRIC, 1, 0, GeometryType.AXIS, GeometryType.AXIS), _make_mate(1, MateType.COINCIDENT, 0, 1, GeometryType.PLANE, GeometryType.PLANE), ] results = recognize_patterns(mates) hinge_matches = [r for r in results if r.pattern is JointPattern.HINGE] assert len(hinge_matches) >= 1 assert hinge_matches[0].confidence == 1.0