Skip to content

fix(solver): give each dyad its own joint type, stop returning NaN - #42

Merged
HugoFara merged 1 commit into
mainfrom
fix/solver-dyad-types
Sep 4, 2026
Merged

HugoFara merged 1 commit into
mainfrom
fix/solver-dyad-types

Conversation

@HugoFara

@HugoFara HugoFara commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #40.

The bug

linkage_to_solver_data() typed every dyad as JOINT_REVOLUTE and read distance1 / distance2 off it:

elif is_dyad(part):
    joint_types[i] = JOINT_REVOLUTE
    ...
    d1 = getattr(part, "distance1", 0.0)
    d2 = getattr(part, "distance2", 0.0)

is_dyad() matches seven classes; only RRRDyad has those attributes. A FixedDyad has distance and angle, so both getattr calls fell through to 0.0 and the solver intersected two zero-radius circles. They never meet, so the joint — and everything solved downstream of it — came out NaN.

Nothing raised. Callers got a full-shaped array of NaN where positions should be, while step() computed the same mechanism correctly.

Before / after

P = FixedDyad(anchor1=crank.output, anchor2=C, distance=1.4, angle=-0.37)
before after
step_fast() P [nan, nan] [2.3512, 0.2476]
P joint type 2 (JOINT_REVOLUTE) 3 (JOINT_FIXED)
P constraints [0.0, 0.0] [1.4, -0.37]

step() gives (2.3512, 0.2476) — the two paths now agree.

The fix

JOINT_FIXED and JOINT_PRISMATIC were already defined in solver/types.py and handled in solver/simulation.py, and update_solver_constraints() even had a correct FixedDyad branch. The primary builder simply never emitted those types, so all of it was unreachable.

The conversion now dispatches on the actual dyad class through one table:

Dyad Joint type Parents Constraints
RRRDyad, BinaryDyad JOINT_REVOLUTE anchor1, anchor2 distance1, distance2
FixedDyad JOINT_FIXED anchor1, anchor2 distance, angle
RRPDyad JOINT_PRISMATIC revolute_anchor, line_anchor1, line_anchor2 distance

update_solver_constraints() reads the same table, so the two cannot drift apart again.

PPDyad needs four parents against MAX_PARENTS of three, and the cam followers have no solver representation at all. Rather than being mistyped into a wrong answer, they now raise:

NotImplementedError: The numba solver cannot represent PPDyad. Use Linkage.step()
instead of step_fast() for this mechanism. Supported dyads: BinaryDyad, FixedDyad,
RRPDyad, RRRDyad.

RRPDyad was broken the same way and works now too — that one was never reported.

Verification

step() against step_fast(), twelve iterations, atol=0:

Mechanism finite max |step − step_fast|
RRRDyad four-bar yes 0.0
FixedDyad coupler point yes 0.0
RRPDyad crank-slider yes 0.0

Both paths call the same solve_* functions, so anything but an exact match would mean the conversion handed the solver different numbers.

14 new tests: 8 in tests/simulation/test_step_fast_agreement.py (agreement and no-NaN per dyad type, plus the rejection and its message), 6 extending the bridge tests. Full suite 2477 passed, 5 skipped with all extras. Lint and mypy --strict clean on 134 files.

How it went unnoticed: the only step()/step_fast() agreement test covered the Mechanism path, and the bridge test named test_with_fixed_dyad asserted an array shape and nothing else.

Effect on #29

With the solver fixed, routing _verify_coupler_path through step_fast() now works and returns real solutions:

Precision points step() step_fast()
README 343 ms 169 ms 2.0x
benchmark 1718 ms 593 ms 2.9x

2.9x is essentially the Amdahl ceiling for a stage that was 67% of runtime.

One caveat for whoever picks up #29, found while measuring: on the benchmark's points the two produce different sets of ten solutions. step() raises UnbuildableError on a candidate that cannot assemble and the caller treats that as rejection, whereas step_fast() returns NaN rows instead. A straight swap changes which candidates are accepted, so the rejection path needs handling deliberately. That is out of scope here.

Known gap left open

_mechanism_to_solver_data() — the separate Mechanism path — still treats PrismaticJoint as JOINT_STATIC, with a comment saying prismatic kinematics "are not yet wired". That is a documented limitation rather than a silent mistype, and it is untouched here.

linkage_to_solver_data() typed every dyad as JOINT_REVOLUTE and read
distance1/distance2 off it. Only RRRDyad has those attributes. A
FixedDyad has distance/angle, so both getattr calls fell through to
their 0.0 default and the solver intersected two zero-radius circles,
which never meet -- the joint, and everything solved downstream of it,
came out NaN.

Nothing raised. Callers got a full-shaped array of NaN where positions
should be, while step() computed the same mechanism correctly. A
FixedDyad is how a traced point is rigidly attached to a coupler link,
so this covered most mechanisms anyone would want to simulate fast,
including everything pylinkage.synthesis emits.

JOINT_FIXED and JOINT_PRISMATIC were already defined in solver/types.py
and handled in solver/simulation.py, and update_solver_constraints() even
had a correct FixedDyad branch. The primary builder just never emitted
those types, so all of it was unreachable.

The conversion now dispatches on the actual dyad class through a single
table shared with update_solver_constraints(), so the two cannot drift.
Dyads the solver cannot represent -- PPDyad, which needs four parents
against MAX_PARENTS of three, and the cam followers -- raise
NotImplementedError naming step() as the alternative, rather than being
mistyped into a wrong answer.

step() and step_fast() now agree to atol=0 on RRRDyad, FixedDyad and
RRPDyad mechanisms, tested per type. The gap that let this through was
that the only agreement test covered the Mechanism path, and the bridge
test for FixedDyad asserted an array shape and nothing else.

Closes #40.
@HugoFara
HugoFara merged commit e64a816 into main Sep 4, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

step_fast() silently returns NaN for every dyad except RRRDyad

1 participant