MateLabel and MateAssemblyLabels dataclasses with label_mate_assembly() that back-attributes joint-level independence to originating mates. Detects redundant and degenerate mates with pattern membership tracking. Closes #15
225 lines
7.3 KiB
Python
225 lines
7.3 KiB
Python
"""Tests for solver.mates.labeling -- mate-level ground truth labels."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from solver.datagen.types import RigidBody
|
|
from solver.mates.labeling import MateAssemblyLabels, MateLabel, label_mate_assembly
|
|
from solver.mates.patterns import JointPattern
|
|
from solver.mates.primitives import GeometryRef, GeometryType, Mate, MateType
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_ref(
|
|
body_id: int,
|
|
geom_type: GeometryType,
|
|
*,
|
|
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="Geom001",
|
|
origin=origin,
|
|
direction=direction,
|
|
)
|
|
|
|
|
|
def _make_bodies(n: int) -> list[RigidBody]:
|
|
"""Create n bodies at distinct positions."""
|
|
return [RigidBody(body_id=i, position=np.array([float(i), 0.0, 0.0])) for i in range(n)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MateLabel
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMateLabel:
|
|
"""MateLabel dataclass."""
|
|
|
|
def test_defaults(self) -> None:
|
|
ml = MateLabel(mate_id=0)
|
|
assert ml.is_independent is True
|
|
assert ml.is_redundant is False
|
|
assert ml.is_degenerate is False
|
|
assert ml.pattern is None
|
|
assert ml.issue is None
|
|
|
|
def test_to_dict(self) -> None:
|
|
ml = MateLabel(
|
|
mate_id=5,
|
|
is_independent=False,
|
|
is_redundant=True,
|
|
pattern=JointPattern.HINGE,
|
|
issue="redundant",
|
|
)
|
|
d = ml.to_dict()
|
|
assert d["mate_id"] == 5
|
|
assert d["is_redundant"] is True
|
|
assert d["pattern"] == "hinge"
|
|
assert d["issue"] == "redundant"
|
|
|
|
def test_to_dict_none_pattern(self) -> None:
|
|
ml = MateLabel(mate_id=0)
|
|
d = ml.to_dict()
|
|
assert d["pattern"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MateAssemblyLabels
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMateAssemblyLabels:
|
|
"""MateAssemblyLabels dataclass."""
|
|
|
|
def test_to_dict_structure(self) -> None:
|
|
"""to_dict produces expected keys."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE),
|
|
ref_b=_make_ref(1, GeometryType.FACE),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates)
|
|
d = result.to_dict()
|
|
assert "per_mate" in d
|
|
assert "patterns" in d
|
|
assert "assembly" in d
|
|
assert isinstance(d["per_mate"], list)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# label_mate_assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLabelMateAssembly:
|
|
"""Full labeling pipeline."""
|
|
|
|
def test_clean_assembly_no_redundancy(self) -> None:
|
|
"""Two bodies with lock mate -> clean, no redundancy."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE),
|
|
ref_b=_make_ref(1, GeometryType.FACE),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates)
|
|
assert isinstance(result, MateAssemblyLabels)
|
|
assert len(result.per_mate) == 1
|
|
ml = result.per_mate[0]
|
|
assert ml.mate_id == 0
|
|
assert ml.is_independent is True
|
|
assert ml.is_redundant is False
|
|
assert ml.issue is None
|
|
|
|
def test_redundant_assembly(self) -> None:
|
|
"""Two lock mates on same body pair -> one is redundant."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE),
|
|
ref_b=_make_ref(1, GeometryType.FACE),
|
|
),
|
|
Mate(
|
|
mate_id=1,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE, origin=np.array([1.0, 0.0, 0.0])),
|
|
ref_b=_make_ref(1, GeometryType.FACE, origin=np.array([1.0, 0.0, 0.0])),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates)
|
|
assert len(result.per_mate) == 2
|
|
redundant_count = sum(1 for ml in result.per_mate if ml.is_redundant)
|
|
# At least one should be redundant
|
|
assert redundant_count >= 1
|
|
assert result.assembly.redundant_count > 0
|
|
|
|
def test_hinge_pattern_labeling(self) -> None:
|
|
"""Hinge mates get pattern membership."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.CONCENTRIC,
|
|
ref_a=_make_ref(0, GeometryType.AXIS),
|
|
ref_b=_make_ref(1, GeometryType.AXIS),
|
|
),
|
|
Mate(
|
|
mate_id=1,
|
|
mate_type=MateType.COINCIDENT,
|
|
ref_a=_make_ref(0, GeometryType.PLANE),
|
|
ref_b=_make_ref(1, GeometryType.PLANE),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates)
|
|
assert len(result.per_mate) == 2
|
|
# Both mates should be part of the hinge pattern
|
|
for ml in result.per_mate:
|
|
assert ml.pattern is JointPattern.HINGE
|
|
assert ml.is_independent is True
|
|
|
|
def test_grounded_assembly(self) -> None:
|
|
"""Grounded assembly labeling works."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE),
|
|
ref_b=_make_ref(1, GeometryType.FACE),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates, ground_body=0)
|
|
assert result.assembly.is_rigid
|
|
|
|
def test_empty_mates(self) -> None:
|
|
"""No mates -> no per_mate labels, underconstrained."""
|
|
bodies = _make_bodies(2)
|
|
result = label_mate_assembly(bodies, [])
|
|
assert len(result.per_mate) == 0
|
|
assert result.assembly.classification == "underconstrained"
|
|
|
|
def test_assembly_classification(self) -> None:
|
|
"""Assembly classification is present."""
|
|
bodies = _make_bodies(2)
|
|
mates = [
|
|
Mate(
|
|
mate_id=0,
|
|
mate_type=MateType.LOCK,
|
|
ref_a=_make_ref(0, GeometryType.FACE),
|
|
ref_b=_make_ref(1, GeometryType.FACE),
|
|
),
|
|
]
|
|
result = label_mate_assembly(bodies, mates)
|
|
assert result.assembly.classification in {
|
|
"well-constrained",
|
|
"overconstrained",
|
|
"underconstrained",
|
|
"mixed",
|
|
}
|