test(solver): add in-client console tests for Phase 5 assembly integration
Paste-into-console test script exercising the full pipeline: - Solver registry and loading - Preference switching between kindred/ondsel - Fixed joint placement matching - Revolute joint DOF reporting - No-ground error code - Solve determinism/stability - Standalone kcsolve API (no FreeCAD Assembly objects) - Diagnose API for overconstrained detection
This commit is contained in:
355
tests/console_test_phase5.py
Normal file
355
tests/console_test_phase5.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Phase 5 in-client console tests.
|
||||
|
||||
Paste into the FreeCAD Python console (or run via: exec(open(...).read())).
|
||||
Tests the full Assembly -> KindredSolver pipeline without the unittest harness.
|
||||
|
||||
Expected output: all lines print PASS. Any FAIL indicates a regression.
|
||||
"""
|
||||
|
||||
import FreeCAD as App
|
||||
import JointObject
|
||||
import kcsolve
|
||||
|
||||
_pref = App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
|
||||
_orig_solver = _pref.GetString("Solver", "")
|
||||
|
||||
_results = []
|
||||
|
||||
|
||||
def _report(name, passed, detail=""):
|
||||
status = "PASS" if passed else "FAIL"
|
||||
msg = f" [{status}] {name}"
|
||||
if detail:
|
||||
msg += f" — {detail}"
|
||||
print(msg)
|
||||
_results.append((name, passed))
|
||||
|
||||
|
||||
def _new_doc(name="Phase5Test"):
|
||||
if App.ActiveDocument and App.ActiveDocument.Name == name:
|
||||
App.closeDocument(name)
|
||||
App.newDocument(name)
|
||||
App.setActiveDocument(name)
|
||||
return App.ActiveDocument
|
||||
|
||||
|
||||
def _cleanup(doc):
|
||||
App.closeDocument(doc.Name)
|
||||
|
||||
|
||||
def _make_assembly(doc):
|
||||
asm = doc.addObject("Assembly::AssemblyObject", "Assembly")
|
||||
asm.resetSolver()
|
||||
jg = asm.newObject("Assembly::JointGroup", "Joints")
|
||||
return asm, jg
|
||||
|
||||
|
||||
def _make_box(asm, x=0, y=0, z=0, size=10):
|
||||
box = asm.newObject("Part::Box", "Box")
|
||||
box.Length = size
|
||||
box.Width = size
|
||||
box.Height = size
|
||||
box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation())
|
||||
return box
|
||||
|
||||
|
||||
def _ground(jg, obj):
|
||||
gnd = jg.newObject("App::FeaturePython", "GroundedJoint")
|
||||
JointObject.GroundedJoint(gnd, obj)
|
||||
return gnd
|
||||
|
||||
|
||||
def _make_joint(jg, joint_type, ref1, ref2):
|
||||
joint = jg.newObject("App::FeaturePython", "Joint")
|
||||
JointObject.Joint(joint, joint_type)
|
||||
refs = [[ref1[0], ref1[1]], [ref2[0], ref2[1]]]
|
||||
joint.Proxy.setJointConnectors(joint, refs)
|
||||
return joint
|
||||
|
||||
|
||||
# ── Test 1: Registry ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_solver_registry():
|
||||
"""Verify kindred solver is registered and available."""
|
||||
names = kcsolve.available()
|
||||
_report(
|
||||
"registry: kindred in available()", "kindred" in names, f"available={names}"
|
||||
)
|
||||
|
||||
solver = kcsolve.load("kindred")
|
||||
_report("registry: load('kindred') succeeds", solver is not None)
|
||||
_report(
|
||||
"registry: solver name",
|
||||
solver.name() == "Kindred (Newton-Raphson)",
|
||||
f"got '{solver.name()}'",
|
||||
)
|
||||
|
||||
joints = solver.supported_joints()
|
||||
_report(
|
||||
"registry: supported_joints non-empty",
|
||||
len(joints) > 0,
|
||||
f"{len(joints)} joint types",
|
||||
)
|
||||
|
||||
|
||||
# ── Test 2: Preference switching ────────────────────────────────────
|
||||
|
||||
|
||||
def test_preference_switching():
|
||||
"""Verify solver preference controls which backend is used."""
|
||||
doc = _new_doc("PrefTest")
|
||||
try:
|
||||
# Set to kindred
|
||||
_pref.SetString("Solver", "kindred")
|
||||
asm, jg = _make_assembly(doc)
|
||||
|
||||
box1 = _make_box(asm, 0, 0, 0)
|
||||
box2 = _make_box(asm, 50, 0, 0)
|
||||
_ground(box1)
|
||||
_make_joint(jg, 0, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]])
|
||||
|
||||
result = asm.solve()
|
||||
_report(
|
||||
"pref: kindred solve succeeds", result == 0, f"solve() returned {result}"
|
||||
)
|
||||
|
||||
# Switch back to ondsel
|
||||
_pref.SetString("Solver", "ondsel")
|
||||
asm.resetSolver()
|
||||
result2 = asm.solve()
|
||||
_report(
|
||||
"pref: ondsel solve succeeds after switch",
|
||||
result2 == 0,
|
||||
f"solve() returned {result2}",
|
||||
)
|
||||
finally:
|
||||
_cleanup(doc)
|
||||
|
||||
|
||||
# ── Test 3: Fixed joint ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_fixed_joint():
|
||||
"""Two boxes + ground + fixed joint -> placements match."""
|
||||
_pref.SetString("Solver", "kindred")
|
||||
doc = _new_doc("FixedTest")
|
||||
try:
|
||||
asm, jg = _make_assembly(doc)
|
||||
box1 = _make_box(asm, 10, 20, 30)
|
||||
box2 = _make_box(asm, 40, 50, 60)
|
||||
_ground(box2)
|
||||
_make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]])
|
||||
|
||||
same = box1.Placement.isSame(box2.Placement, 1e-6)
|
||||
_report(
|
||||
"fixed: box1 matches box2 placement",
|
||||
same,
|
||||
f"box1={box1.Placement.Base}, box2={box2.Placement.Base}",
|
||||
)
|
||||
finally:
|
||||
_cleanup(doc)
|
||||
|
||||
|
||||
# ── Test 4: Revolute joint + DOF ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_revolute_dof():
|
||||
"""Revolute joint -> solve succeeds, DOF = 1."""
|
||||
_pref.SetString("Solver", "kindred")
|
||||
doc = _new_doc("RevoluteTest")
|
||||
try:
|
||||
asm, jg = _make_assembly(doc)
|
||||
box1 = _make_box(asm, 0, 0, 0)
|
||||
box2 = _make_box(asm, 100, 0, 0)
|
||||
_ground(box1)
|
||||
_make_joint(jg, 1, [box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]])
|
||||
|
||||
result = asm.solve()
|
||||
_report("revolute: solve succeeds", result == 0, f"solve() returned {result}")
|
||||
|
||||
dof = asm.getLastDoF()
|
||||
_report("revolute: DOF = 1", dof == 1, f"DOF = {dof}")
|
||||
finally:
|
||||
_cleanup(doc)
|
||||
|
||||
|
||||
# ── Test 5: No ground ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_ground():
|
||||
"""No grounded parts -> returns -6."""
|
||||
_pref.SetString("Solver", "kindred")
|
||||
doc = _new_doc("NoGroundTest")
|
||||
try:
|
||||
asm, jg = _make_assembly(doc)
|
||||
box1 = _make_box(asm, 0, 0, 0)
|
||||
box2 = _make_box(asm, 50, 0, 0)
|
||||
|
||||
joint = jg.newObject("App::FeaturePython", "Joint")
|
||||
JointObject.Joint(joint, 0)
|
||||
refs = [[box1, ["Face6", "Vertex7"]], [box2, ["Face6", "Vertex7"]]]
|
||||
joint.Proxy.setJointConnectors(joint, refs)
|
||||
|
||||
result = asm.solve()
|
||||
_report("no-ground: returns -6", result == -6, f"solve() returned {result}")
|
||||
finally:
|
||||
_cleanup(doc)
|
||||
|
||||
|
||||
# ── Test 6: Solve stability ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stability():
|
||||
"""Solving twice gives identical placements."""
|
||||
_pref.SetString("Solver", "kindred")
|
||||
doc = _new_doc("StabilityTest")
|
||||
try:
|
||||
asm, jg = _make_assembly(doc)
|
||||
box1 = _make_box(asm, 10, 20, 30)
|
||||
box2 = _make_box(asm, 40, 50, 60)
|
||||
_ground(box2)
|
||||
_make_joint(jg, 0, [box2, ["Face6", "Vertex7"]], [box1, ["Face6", "Vertex7"]])
|
||||
|
||||
asm.solve()
|
||||
plc1 = App.Placement(box1.Placement)
|
||||
asm.solve()
|
||||
plc2 = box1.Placement
|
||||
|
||||
same = plc1.isSame(plc2, 1e-6)
|
||||
_report("stability: two solves identical", same)
|
||||
finally:
|
||||
_cleanup(doc)
|
||||
|
||||
|
||||
# ── Test 7: Standalone solver API ────────────────────────────────────
|
||||
|
||||
|
||||
def test_standalone_api():
|
||||
"""Use kcsolve types directly without FreeCAD Assembly objects."""
|
||||
solver = kcsolve.load("kindred")
|
||||
|
||||
# Two parts: one grounded, one free
|
||||
p1 = kcsolve.Part()
|
||||
p1.id = "base"
|
||||
p1.placement = kcsolve.Transform.identity()
|
||||
p1.grounded = True
|
||||
|
||||
p2 = kcsolve.Part()
|
||||
p2.id = "arm"
|
||||
p2.placement = kcsolve.Transform()
|
||||
p2.placement.position = [100.0, 0.0, 0.0]
|
||||
p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0]
|
||||
p2.grounded = False
|
||||
|
||||
# Fixed joint
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "fix1"
|
||||
c.part_i = "base"
|
||||
c.marker_i = kcsolve.Transform.identity()
|
||||
c.part_j = "arm"
|
||||
c.marker_j = kcsolve.Transform.identity()
|
||||
c.type = kcsolve.BaseJointKind.Fixed
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = [p1, p2]
|
||||
ctx.constraints = [c]
|
||||
|
||||
result = solver.solve(ctx)
|
||||
_report(
|
||||
"standalone: solve status",
|
||||
result.status == kcsolve.SolveStatus.Success,
|
||||
f"status={result.status}",
|
||||
)
|
||||
_report("standalone: DOF = 0", result.dof == 0, f"dof={result.dof}")
|
||||
|
||||
# Check that arm moved to origin
|
||||
for pr in result.placements:
|
||||
if pr.id == "arm":
|
||||
dist = sum(x**2 for x in pr.placement.position) ** 0.5
|
||||
_report("standalone: arm at origin", dist < 1e-6, f"distance={dist:.2e}")
|
||||
break
|
||||
|
||||
|
||||
# ── Test 8: Diagnose API ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_diagnose():
|
||||
"""Diagnose overconstrained system via standalone API."""
|
||||
solver = kcsolve.load("kindred")
|
||||
|
||||
p1 = kcsolve.Part()
|
||||
p1.id = "base"
|
||||
p1.placement = kcsolve.Transform.identity()
|
||||
p1.grounded = True
|
||||
|
||||
p2 = kcsolve.Part()
|
||||
p2.id = "arm"
|
||||
p2.placement = kcsolve.Transform()
|
||||
p2.placement.position = [50.0, 0.0, 0.0]
|
||||
p2.placement.quaternion = [1.0, 0.0, 0.0, 0.0]
|
||||
p2.grounded = False
|
||||
|
||||
# Two fixed joints = overconstrained
|
||||
c1 = kcsolve.Constraint()
|
||||
c1.id = "fix1"
|
||||
c1.part_i = "base"
|
||||
c1.marker_i = kcsolve.Transform.identity()
|
||||
c1.part_j = "arm"
|
||||
c1.marker_j = kcsolve.Transform.identity()
|
||||
c1.type = kcsolve.BaseJointKind.Fixed
|
||||
|
||||
c2 = kcsolve.Constraint()
|
||||
c2.id = "fix2"
|
||||
c2.part_i = "base"
|
||||
c2.marker_i = kcsolve.Transform.identity()
|
||||
c2.part_j = "arm"
|
||||
c2.marker_j = kcsolve.Transform.identity()
|
||||
c2.type = kcsolve.BaseJointKind.Fixed
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = [p1, p2]
|
||||
ctx.constraints = [c1, c2]
|
||||
|
||||
diags = solver.diagnose(ctx)
|
||||
_report(
|
||||
"diagnose: returns diagnostics", len(diags) > 0, f"{len(diags)} diagnostic(s)"
|
||||
)
|
||||
if diags:
|
||||
kinds = [d.kind for d in diags]
|
||||
_report(
|
||||
"diagnose: found redundant",
|
||||
kcsolve.DiagnosticKind.Redundant in kinds,
|
||||
f"kinds={[str(k) for k in kinds]}",
|
||||
)
|
||||
|
||||
|
||||
# ── Run all ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_all():
|
||||
print("\n=== Phase 5 Console Tests ===\n")
|
||||
|
||||
test_solver_registry()
|
||||
test_preference_switching()
|
||||
test_fixed_joint()
|
||||
test_revolute_dof()
|
||||
test_no_ground()
|
||||
test_stability()
|
||||
test_standalone_api()
|
||||
test_diagnose()
|
||||
|
||||
# Restore original preference
|
||||
_pref.SetString("Solver", _orig_solver)
|
||||
|
||||
# Summary
|
||||
passed = sum(1 for _, p in _results if p)
|
||||
total = len(_results)
|
||||
print(f"\n=== {passed}/{total} passed ===\n")
|
||||
if passed < total:
|
||||
failed = [name for name, p in _results if not p]
|
||||
print(f"FAILED: {', '.join(failed)}")
|
||||
|
||||
|
||||
run_all()
|
||||
Reference in New Issue
Block a user