Files
solver/tests/test_drag.py
forbes-0023 000f54adaa fix(solver): use world-anchored reference normal in PlanarConstraint distance residual
PlanarConstraint's point-in-plane residual used a body-attached normal
(z_j) that rotates with body_j. When combined with CylindricalConstraint,
which allows rotation about the shared axis, the plane normal tilts
during Newton iteration. This allows the body to drift along the cylinder
axis while technically satisfying the rotated distance residual.

Fix: snapshot the world-frame normal at system-build time (as Const
nodes) and use it in the distance residual. The cross-product residuals
still use body-attached normals to enforce parallelism. Only the
distance residual needs the fixed reference direction.

Works for any offset value: (p_i - p_j) · z_ref - offset = 0.
2026-02-26 11:06:55 -06:00

371 lines
14 KiB
Python

"""Regression tests for interactive drag.
These tests exercise the drag protocol at the solver-internals level,
verifying that constraints remain enforced across drag steps when the
pre-pass has been applied to cached residuals.
Bug scenario: single_equation_pass runs during pre_drag, analytically
solving variables from upstream constraints and baking their values as
constants into downstream residual expressions. When a drag step
changes those variables, the cached residuals use stale constants and
downstream constraints (e.g. Planar distance=0) stop being enforced.
Fix: skip single_equation_pass in the drag path. Only substitution_pass
(which replaces genuinely grounded parameters) is safe to cache.
"""
import math
import pytest
from kindred_solver.constraints import (
CoincidentConstraint,
CylindricalConstraint,
PlanarConstraint,
RevoluteConstraint,
)
from kindred_solver.entities import RigidBody
from kindred_solver.geometry import marker_z_axis
from kindred_solver.newton import newton_solve
from kindred_solver.params import ParamTable
from kindred_solver.prepass import single_equation_pass, substitution_pass
ID_QUAT = (1, 0, 0, 0)
def _build_residuals(bodies, constraint_objs):
"""Build raw residual list + quat groups (no prepass)."""
all_residuals = []
for c in constraint_objs:
all_residuals.extend(c.residuals())
quat_groups = []
for body in bodies:
if not body.grounded:
all_residuals.append(body.quat_norm_residual())
quat_groups.append(body.quat_param_names())
return all_residuals, quat_groups
def _eval_raw_residuals(bodies, constraint_objs, params):
"""Evaluate original constraint residuals at current param values.
Returns the max absolute residual value — the ground truth for
whether constraints are satisfied regardless of prepass state.
"""
raw, _ = _build_residuals(bodies, constraint_objs)
env = params.get_env()
return max(abs(r.eval(env)) for r in raw)
class TestPrepassDragRegression:
"""single_equation_pass bakes stale values that break drag.
Setup: ground --Revolute--> arm --Planar(d=0)--> plate
The Revolute pins arm's origin to ground (fixes arm/tx, arm/ty,
arm/tz via single_equation_pass). The Planar keeps plate coplanar
with arm. After prepass, the Planar residual has arm's position
baked as Const(0.0).
During drag: arm/tz is set to 5.0. Because arm/tz is marked fixed
by prepass, Newton can't correct it, AND the Planar residual still
uses Const(0.0) instead of the live value 5.0. The Revolute
constraint (arm at origin) is silently violated.
"""
def _setup(self):
pt = ParamTable()
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
arm = RigidBody("arm", pt, (10, 0, 0), ID_QUAT)
plate = RigidBody("plate", pt, (10, 5, 0), ID_QUAT)
constraints = [
RevoluteConstraint(ground, (0, 0, 0), ID_QUAT, arm, (0, 0, 0), ID_QUAT),
PlanarConstraint(arm, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
]
bodies = [ground, arm, plate]
return pt, bodies, constraints
def test_bug_stale_constants_after_single_equation_pass(self):
"""Document the bug: prepass bakes arm/tz=0, drag breaks constraints."""
pt, bodies, constraints = self._setup()
raw_residuals, quat_groups = _build_residuals(bodies, constraints)
# Simulate OLD pre_drag: substitution + single_equation_pass
residuals = substitution_pass(raw_residuals, pt)
residuals = single_equation_pass(residuals, pt)
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
assert ok
# Verify prepass fixed arm's position params
assert pt.is_fixed("arm/tx")
assert pt.is_fixed("arm/ty")
assert pt.is_fixed("arm/tz")
# Simulate drag: move arm up (set_value, as drag_step does)
pt.set_value("arm/tz", 5.0)
pt.set_value("plate/tz", 5.0) # initial guess near drag
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
# Solver "converges" on the stale cached residuals
assert ok
# But the TRUE constraints are violated: arm should be at z=0
# (Revolute pins it to ground) yet it's at z=5
max_err = _eval_raw_residuals(bodies, constraints, pt)
assert max_err > 1.0, (
f"Expected large raw residual violation, got {max_err:.6e}. "
"The bug should cause the Revolute z-residual to be ~5.0"
)
def test_fix_no_single_equation_pass_for_drag(self):
"""With the fix: skip single_equation_pass, constraints hold."""
pt, bodies, constraints = self._setup()
raw_residuals, quat_groups = _build_residuals(bodies, constraints)
# Simulate FIXED pre_drag: substitution only
residuals = substitution_pass(raw_residuals, pt)
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
assert ok
# arm/tz should NOT be fixed
assert not pt.is_fixed("arm/tz")
# Simulate drag: move arm up
pt.set_value("arm/tz", 5.0)
pt.set_value("plate/tz", 5.0)
ok = newton_solve(residuals, pt, quat_groups=quat_groups, max_iter=100, tol=1e-10)
assert ok
# Newton pulls arm back to z=0 (Revolute enforced) and plate follows
max_err = _eval_raw_residuals(bodies, constraints, pt)
assert max_err < 1e-8, f"Raw residual violation {max_err:.6e} — constraints not satisfied"
class TestCoincidentPlanarDragRegression:
"""Coincident upstream + Planar downstream — same bug class.
ground --Coincident--> bracket --Planar(d=0)--> plate
Coincident fixes bracket/tx,ty,tz. After prepass, the Planar
residual has bracket's position baked. Drag moves bracket;
the Planar uses stale constants.
"""
def _setup(self):
pt = ParamTable()
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
bracket = RigidBody("bracket", pt, (0, 0, 0), ID_QUAT)
plate = RigidBody("plate", pt, (10, 5, 0), ID_QUAT)
constraints = [
CoincidentConstraint(ground, (0, 0, 0), bracket, (0, 0, 0)),
PlanarConstraint(bracket, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
]
bodies = [ground, bracket, plate]
return pt, bodies, constraints
def test_bug_coincident_planar(self):
"""Prepass fixes bracket/tz, Planar uses stale constant during drag."""
pt, bodies, constraints = self._setup()
raw, qg = _build_residuals(bodies, constraints)
residuals = substitution_pass(raw, pt)
residuals = single_equation_pass(residuals, pt)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
assert pt.is_fixed("bracket/tz")
# Drag bracket up
pt.set_value("bracket/tz", 5.0)
pt.set_value("plate/tz", 5.0)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
# True constraints violated
max_err = _eval_raw_residuals(bodies, constraints, pt)
assert max_err > 1.0, f"Expected raw violation from stale prepass, got {max_err:.6e}"
def test_fix_coincident_planar(self):
"""With the fix: constraints satisfied after drag."""
pt, bodies, constraints = self._setup()
raw, qg = _build_residuals(bodies, constraints)
residuals = substitution_pass(raw, pt)
# No single_equation_pass
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
assert not pt.is_fixed("bracket/tz")
# Drag bracket up
pt.set_value("bracket/tz", 5.0)
pt.set_value("plate/tz", 5.0)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
max_err = _eval_raw_residuals(bodies, constraints, pt)
assert max_err < 1e-8, f"Raw residual violation {max_err:.6e} — constraints not satisfied"
class TestDragDoesNotBreakStaticSolve:
"""Verify that the static solve path (with single_equation_pass) still works.
The fix only affects pre_drag — the static solve() path continues to
use single_equation_pass for faster convergence.
"""
def test_static_solve_still_uses_prepass(self):
"""Static solve with single_equation_pass converges correctly."""
pt = ParamTable()
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
arm = RigidBody("arm", pt, (10, 0, 0), ID_QUAT)
plate = RigidBody("plate", pt, (10, 5, 8), ID_QUAT)
constraints = [
RevoluteConstraint(ground, (0, 0, 0), ID_QUAT, arm, (0, 0, 0), ID_QUAT),
PlanarConstraint(arm, (0, 0, 0), ID_QUAT, plate, (0, 0, 0), ID_QUAT, offset=0.0),
]
bodies = [ground, arm, plate]
raw, qg = _build_residuals(bodies, constraints)
# Full prepass (static solve path)
residuals = substitution_pass(raw, pt)
residuals = single_equation_pass(residuals, pt)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
# All raw constraints satisfied
max_err = _eval_raw_residuals(bodies, constraints, pt)
assert max_err < 1e-8
# arm at origin (Revolute), plate coplanar (z=0)
env = pt.get_env()
assert abs(env["arm/tx"]) < 1e-8
assert abs(env["arm/ty"]) < 1e-8
assert abs(env["arm/tz"]) < 1e-8
assert abs(env["plate/tz"]) < 1e-8
def _set_reference_normal(planar_obj, params):
"""Snapshot world-frame normal as reference (mirrors _build_system logic)."""
env = params.get_env()
z_j = marker_z_axis(planar_obj.body_j, planar_obj.marker_j_quat)
planar_obj.reference_normal = (
z_j[0].eval(env),
z_j[1].eval(env),
z_j[2].eval(env),
)
class TestPlanarCylindricalAxialDrift:
"""Planar + Cylindrical on the same body pair: axial drift regression.
Bug: PlanarConstraint's distance residual uses a body-attached normal
(z_j) that rotates with the body. When combined with Cylindrical
(which allows rotation about the axis), the normal can tilt during
Newton iteration, allowing the body to drift along the cylinder axis
while technically satisfying the rotated distance residual.
Fix: snapshot the world-frame normal at system-build time and use
Const nodes in the distance residual (reference_normal).
"""
def _setup(self, offset=0.0):
"""Cylindrical + Planar along Z between ground and a free body.
Free body starts displaced along Z so the solver must move it.
"""
pt = ParamTable()
ground = RigidBody("g", pt, (0, 0, 0), ID_QUAT, grounded=True)
free = RigidBody("free", pt, (0, 0, 5), ID_QUAT)
cyl = CylindricalConstraint(
ground,
(0, 0, 0),
ID_QUAT,
free,
(0, 0, 0),
ID_QUAT,
)
planar = PlanarConstraint(
ground,
(0, 0, 0),
ID_QUAT,
free,
(0, 0, 0),
ID_QUAT,
offset=offset,
)
bodies = [ground, free]
constraints = [cyl, planar]
return pt, bodies, constraints, planar
def test_reference_normal_prevents_axial_drift(self):
"""With reference_normal, free body converges to z=0 (distance=0)."""
pt, bodies, constraints, planar = self._setup(offset=0.0)
_set_reference_normal(planar, pt)
raw, qg = _build_residuals(bodies, constraints)
residuals = substitution_pass(raw, pt)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
env = pt.get_env()
assert abs(env["free/tz"]) < 1e-8, (
f"free/tz = {env['free/tz']:.6e}, expected ~0.0 (axial drift)"
)
def test_reference_normal_with_offset(self):
"""With reference_normal and offset=3.0, free body converges to z=-3.
Sign convention: point_plane_distance = (p_ground - p_free) · n,
so offset=3 means -z_free - 3 = 0, i.e. z_free = -3.
"""
pt, bodies, constraints, planar = self._setup(offset=3.0)
_set_reference_normal(planar, pt)
raw, qg = _build_residuals(bodies, constraints)
residuals = substitution_pass(raw, pt)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
env = pt.get_env()
assert abs(env["free/tz"] + 3.0) < 1e-8, f"free/tz = {env['free/tz']:.6e}, expected ~-3.0"
def test_drag_step_no_drift(self):
"""After drag perturbation, re-solve keeps axial position locked."""
pt, bodies, constraints, planar = self._setup(offset=0.0)
_set_reference_normal(planar, pt)
raw, qg = _build_residuals(bodies, constraints)
residuals = substitution_pass(raw, pt)
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
env = pt.get_env()
assert abs(env["free/tz"]) < 1e-8
# Simulate drag: perturb the free body's position
pt.set_value("free/tx", 3.0)
pt.set_value("free/ty", 2.0)
pt.set_value("free/tz", 1.0) # axial perturbation
ok = newton_solve(residuals, pt, quat_groups=qg, max_iter=100, tol=1e-10)
assert ok
env = pt.get_env()
# Cylindrical allows x/y freedom only on the axis line,
# but Planar distance=0 must hold: z stays at 0
assert abs(env["free/tz"]) < 1e-8, (
f"free/tz = {env['free/tz']:.6e}, expected ~0.0 after drag re-solve"
)