Files
solver/tests/test_constraints.py
forbes-0023 98051ba0c9 feat: add Phase 1 constraint solver addon, move prior content to GNN/
- Move existing OndselSolver, GNN ML layer, and tooling into GNN/
  directory for integration in later phases
- Add Create addon scaffold: package.xml, Init.py
- Add expression DAG with eval, symbolic diff, simplification
- Add parameter table with fixed/free variable tracking
- Add quaternion rotation as polynomial Expr trees
- Add RigidBody entity (7 DOF: position + unit quaternion)
- Add constraint classes: Coincident, DistancePointPoint, Fixed
- Add Newton-Raphson solver with symbolic Jacobian + numpy lstsq
- Add pre-solve passes: substitution + single-equation
- Add DOF counting via Jacobian SVD rank
- Add KindredSolver IKCSolver bridge for kcsolve integration
- Add 82 unit tests covering all modules

Registers as 'kindred' solver via kcsolve.register_solver() when
loaded by Create's addon_loader.
2026-02-20 20:35:47 -06:00

136 lines
4.7 KiB
Python

"""Tests for constraint residual generation."""
import math
import pytest
from kindred_solver.constraints import (
CoincidentConstraint,
DistancePointPointConstraint,
FixedConstraint,
)
from kindred_solver.entities import RigidBody
from kindred_solver.params import ParamTable
class TestCoincident:
def test_satisfied(self):
"""Two bodies at same position → residuals are zero."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = CoincidentConstraint(b1, (0, 0, 0), b2, (0, 0, 0))
env = pt.get_env()
for r in c.residuals():
assert abs(r.eval(env)) < 1e-10
def test_unsatisfied(self):
"""Bodies at different positions → non-zero residuals."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (1, 2, 3), (1, 0, 0, 0))
c = CoincidentConstraint(b1, (0, 0, 0), b2, (0, 0, 0))
env = pt.get_env()
vals = [r.eval(env) for r in c.residuals()]
assert abs(vals[0] - (-1.0)) < 1e-10
assert abs(vals[1] - (-2.0)) < 1e-10
assert abs(vals[2] - (-3.0)) < 1e-10
def test_with_markers(self):
"""Marker offsets shift the coincidence point."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (10, 0, 0), (1, 0, 0, 0))
# marker on b1 at local (5,0,0), marker on b2 at local (-5,0,0)
c = CoincidentConstraint(b1, (5, 0, 0), b2, (-5, 0, 0))
env = pt.get_env()
# b1 marker world = (5,0,0), b2 marker world = (10-5, 0, 0) = (5,0,0)
for r in c.residuals():
assert abs(r.eval(env)) < 1e-10
def test_residual_count(self):
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0))
b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = CoincidentConstraint(b1, (0, 0, 0), b2, (0, 0, 0))
assert len(c.residuals()) == 3
class TestDistancePointPoint:
def test_satisfied(self):
"""Bodies at distance 5 apart with d=5 → residual zero."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (3, 4, 0), (1, 0, 0, 0))
c = DistancePointPointConstraint(b1, (0, 0, 0), b2, (0, 0, 0), 5.0)
env = pt.get_env()
# 3^2 + 4^2 - 5^2 = 9+16-25 = 0
assert abs(c.residuals()[0].eval(env)) < 1e-10
def test_unsatisfied(self):
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (3, 4, 0), (1, 0, 0, 0))
c = DistancePointPointConstraint(b1, (0, 0, 0), b2, (0, 0, 0), 3.0)
env = pt.get_env()
# 9+16-9 = 16
assert abs(c.residuals()[0].eval(env) - 16.0) < 1e-10
def test_residual_count(self):
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0))
b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = DistancePointPointConstraint(b1, (0, 0, 0), b2, (0, 0, 0), 1.0)
assert len(c.residuals()) == 1
class TestFixed:
def test_satisfied(self):
"""Identical pose + markers → all residuals zero."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = FixedConstraint(
b1,
(0, 0, 0),
(1, 0, 0, 0),
b2,
(0, 0, 0),
(1, 0, 0, 0),
)
env = pt.get_env()
for r in c.residuals():
assert abs(r.eval(env)) < 1e-10
def test_residual_count(self):
"""Fixed constraint: 3 position + 3 orientation = 6 residuals."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0))
b2 = RigidBody("b", pt, (0, 0, 0), (1, 0, 0, 0))
c = FixedConstraint(
b1,
(0, 0, 0),
(1, 0, 0, 0),
b2,
(0, 0, 0),
(1, 0, 0, 0),
)
assert len(c.residuals()) == 6
def test_unsatisfied_position(self):
"""Offset position → non-zero position residuals."""
pt = ParamTable()
b1 = RigidBody("a", pt, (0, 0, 0), (1, 0, 0, 0), grounded=True)
b2 = RigidBody("b", pt, (1, 0, 0), (1, 0, 0, 0))
c = FixedConstraint(
b1,
(0, 0, 0),
(1, 0, 0, 0),
b2,
(0, 0, 0),
(1, 0, 0, 0),
)
env = pt.get_env()
vals = [r.eval(env) for r in c.residuals()]
# Position residual [0] should be -1 (0 - 1)
assert abs(vals[0] - (-1.0)) < 1e-10