feat(mates): add mate-level ground truth labels
Some checks failed
CI / lint (push) Successful in 1m45s
CI / type-check (push) Successful in 2m32s
CI / test (push) Failing after 3m36s

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
This commit is contained in:
2026-02-03 13:08:23 -06:00
parent 239e45c7f9
commit 93bda28f67
3 changed files with 456 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
"""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",
}