JointPattern enum (9 patterns), PatternMatch dataclass, and recognize_patterns() function with data-driven pattern rules. Supports canonical, partial, and ambiguous pattern matching. Closes #12
286 lines
10 KiB
Python
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
|