Files
solver/tests/mates/test_patterns.py
forbes-0023 e8143cf64c
Some checks failed
CI / lint (push) Has been cancelled
CI / type-check (push) Has been cancelled
CI / test (push) Has been cancelled
feat(mates): add joint pattern recognition
JointPattern enum (9 patterns), PatternMatch dataclass, and
recognize_patterns() function with data-driven pattern rules.
Supports canonical, partial, and ambiguous pattern matching.

Closes #12
2026-02-03 12:59:53 -06:00

286 lines
10 KiB
Python

"""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