JAlcocerTech E-books

The Computational Machinery: 2D MBSD in Python

Top-level map: Field Guide

Theory chapter: The Physics of 2D Multibody Systems — the mathematical framework this chapter implements.

This is the other half of the physics story: how each concept from the theory chapter is actually assembled in code, which constraint and force primitives are available, which example scripts exercise which features, and where to find each piece in the repository.

This is a reference guide, not a tutorial — jump to the relevant section when you need to know “what’s available” or “where does X live”.


1. How a mechanism is represented

The core data structure is MBody in 2D-Kinematics/mbody.py, which holds five lists (plus gravity, time, and driving angular velocity):

class MBody:
    bodies                 : List[Body]
    rev_joints             : List[RevJoint]
    prism_joints           : List[PrismJoint]
    punto_linea_joints     : List[PuntoLineaJoint]
    leva_joints            : List[LevaJoint]
    gear_joints            : List[GearJoint]
    user_constraints       : List[UserConstraint]
    g                      : np.ndarray(2)          # gravity vector
    w                      : float                  # user-defined angular velocity
    t                      : float                  # current simulation time

Each Body(name, mass, inertia, rG) is a rigid body. Body 0 is always ground (mass = 0, inertia = 0) and is implicitly pinned at the origin by the first three constraint equations.

The generalised-coordinate vector q\mathbf{q} is packed as:

q=[x0,y0,θ0,x1,y1,θ1,,xn1,yn1,θn1,(extra coords: 2 per LevaJoint)]\mathbf{q} = [\, x_0,\, y_0,\, \theta_0,\, x_1,\, y_1,\, \theta_1,\, \ldots,\, x_{n-1},\, y_{n-1},\, \theta_{n-1},\, (\text{extra coords: 2 per LevaJoint})\,]

Total size: ncoord = 3*n_bodies + 2*n_leva_joints.

2. Available constraints (all pre-built)

NameClassEqs addedExtra coordsFile
Revolute (pin)RevJoint(i, j, ri, rj)20mbody.py
Prismatic (slider)PrismJoint(i, j, ri, rj, hi)20mbody.py
Point-on-linePuntoLineaJoint(i, j, ri, rj, hi)10mbody.py
CAM (Leva)LevaJoint(i, j, geom_i, geom_j, ri, rj)32mbody.py
GearGearJoint(i, j, tau)10mbody.py
User-definedUserConstraint(constraint_fn, jacobian_fn, dt_jacobian_fn, dt_constraint_fn, dtdt_constraint_fn, count)count0mbody.py

Plus three implicit equations that pin the ground body (x0=y0=θ0=0x_0 = y_0 = \theta_0 = 0).

Quick reference per joint

  • RevJoint(i, j, ri, rj) — two points ri on body i and rj on body j are forced to coincide globally. Simulates a pin.
  • PrismJoint(i, j, ri, rj, hi) — bodies have the same angle and the point ri stays on a line through rj oriented perpendicular to hi. Simulates a slider.
  • PuntoLineaJoint(i, j, ri, rj, hi) — point rj (on body j) lies on a line through ri on body i in direction perpendicular to hi. A single-equation point-on-line constraint.
  • LevaJoint(i, j, geom_i, geom_j, ri, rj) — surfaces geom_i and geom_j (subclasses of Surface with .position(s) and .tangent(s)) stay in tangent contact. Adds two extra coordinates (si,sj)(s_i, s_j) for the surface parameters at the contact points.
  • GearJoint(i, j, tau) — enforces θiτθj=0\theta_i - \tau \cdot \theta_j = 0 (a single scalar equation). Fixed transmission ratio.
  • UserConstraint(...) — arbitrary scalar equations; you supply five callbacks: the constraint function, its position Jacobian, its time derivative, its /t\partial/\partial t, and its 2/t2\partial^2/\partial t^2. Used for driving motions (θcrank=ωt\theta_\text{crank} = \omega \cdot t), locking a coordinate (q[8] = 0), coupling (pin-slot), etc.

Constraint assembly logic in 2D-Kinematics/constraints.py builds the full C(q,t)C(\mathbf{q}, t) vector; Jacobians in 2D-Kinematics/jacobians.py; time derivatives in 2D-Kinematics/derivatives.py.

3. Force primitives

All force computations live in 2D-Dynamics/forces.py. The available generators:

NameFunctionClass (where applicable)What it adds
Mass matrixmass_matrix(mb, q)M(q)\mathbf{M}(\mathbf{q}), block-diagonal with Steiner-shifted inertia
Gravitygravity_forces(mb, q, g=None)migm_i \cdot g force on each body, plus torque from offset CG
Spring-damperspring_damper_forces(mb, q, v, springs)Spring(i, j, k, c, l0, ri, rj)linear spring between two body-fixed points, optional damping
Ground contact (simple)ground_contact_forces(mb, q, v, contacts)GroundContact(body, contact_radius, y_ground, K_hertz, C_damp, mu, v_lim, y_ground_fn)Hertz penalty contact between a circular wheel and a y(x)y(x) terrain
Surface-to-surface contactsurface_contact_forces(mb, q, v, surface_contacts)SurfaceContact(body_i, body_j, geom_i, geom_j, K_hertz, C_damp, mu, v_lim, ...)Full surface-to-surface penalty contact with nonlinear tangent-contact point finding (matches MATLAB MaxDistancia_2D)
Centrifugal (pseudo)centrifugal_forces(mb, q, omega, alpha)Pseudo-forces in a rotating reference frame (rarely used)
User forcesQ_user_fn(t, q, v) -> ndarrayArbitrary applied generalised forces passed as a callback

Energy helpers (also in forces.py):

  • kinetic_energy(M, v)
  • potential_energy(mb, q, g=None, springs=None)
  • total_energy(mb, q, v, M, g=None, springs=None)

4. The saddle-point solver

The heart of the dynamics: solve_acceleration_with_lagrange(mb, q, v, t, M, Q_total) in 2D-Kinematics/solver.py.

Given M\mathbf{M}, Qtotal\mathbf{Q}_\text{total}, the current (q,v)(\mathbf{q}, \mathbf{v}), and implicitly the constraint Jacobian Cq\mathbf{C}_q and time derivatives, it assembles the block system

[MCqTCq0][aλ]=[Qtotal(C˙qv+Ctt)]\begin{bmatrix} \mathbf{M} & \mathbf{C}_q^T \\ \mathbf{C}_q & \mathbf{0} \end{bmatrix} \begin{bmatrix} \mathbf{a} \\ \boldsymbol{\lambda} \end{bmatrix} = \begin{bmatrix} \mathbf{Q}_\text{total} \\ -(\dot{\mathbf{C}}_q \cdot \mathbf{v} + C_{tt}) \end{bmatrix}

and calls np.linalg.solve. Returns (a,λ)(\mathbf{a}, \boldsymbol{\lambda}).

This is called from every time step of forward or inverse dynamics.

4.1 Position / velocity / acceleration kinematic solvers

When the mechanism is fully constrained (nDOF=0n_\text{DOF} = 0), the same solver.py module provides:

  • newton_raphson(mb, q0, t, tol=1e-10, max_iter=50) — Newton–Raphson on C(q,t)=0C(\mathbf{q}, t) = 0 starting from q0, returns q\mathbf{q} satisfying C<tol\|C\| < \text{tol}.
  • solve_position(mb, q0, t) — wrapper around Newton–Raphson.
  • solve_velocity(mb, q, t) — one linear solve Cqv=Ct\mathbf{C}_q \cdot \mathbf{v} = -C_t.
  • solve_acceleration(mb, q, v, t) — one linear solve Cqa=γ\mathbf{C}_q \cdot \mathbf{a} = \boldsymbol{\gamma}, purely kinematic (no mass matrix, no forces).

For underconstrained systems (free DOFs), all three solvers fall back to lstsq for the minimum-norm solution.

5. Dynamics entry points

All in 2D-Dynamics/dynamics_solver.py.

Forward dynamics — solve_dynamics_scipy(...)

State is y=[q,v]\mathbf{y} = [\mathbf{q}, \mathbf{v}] of size 2ncoord2 \cdot \text{ncoord}. The function wraps scipy.integrate.solve_ivp with an RHS that:

  1. Optionally projects q\mathbf{q} back onto the constraint manifold via solve_position.
  2. Assembles Qtotal\mathbf{Q}_\text{total} via assemble_system().
  3. Solves the saddle-point system for (a,λ)(\mathbf{a}, \boldsymbol{\lambda}).
  4. Returns dy/dt=[v,a]d\mathbf{y}/dt = [\mathbf{v}, \mathbf{a}] to scipy.

Tight default tolerances (rtol=1e-9, atol=1e-11) to get crisp energy-conservation properties on rigid-body systems. For penalty-contact problems (stiff ODE), looser tolerances are used and projection can be enabled for LevaJoints.

Inverse dynamics — solve_inverse_dynamics(...)

Requires nDOF=0n_\text{DOF} = 0 (user constraints drive all remaining freedoms). At each time in tspan:

  1. Position solve (Newton–Raphson).
  2. Velocity solve (one linear solve).
  3. Saddle-point solve → returns both a\mathbf{a} and λ\boldsymbol{\lambda}.

No time integration. Returns arrays q_all, v_all, a_all, lambda_all, Q_total_all over the time grid. The Lagrange multiplier time histories λ(t)\boldsymbol{\lambda}(t) are the “bearing reactions / driving torque / guide normals” we extract in every inverse-dynamics chapter.

6. Example scripts — by category

All live in 2D-Dynamics/examples/ (or 2D-mbsd/ for the kinematics-only examples).

Kinematic-only (driven at constant angular velocity)

Forward dynamics

Inverse dynamics and vibration analysis

Multi-cylinder engine analysis

In 2D-Dynamics/examples/multi-cylinder-nograv/:

  • _common.py — shared helpers: run_single_cylinder, superpose_time, phasor_sum, phasor_moment_sum, fft_amplitude, harmonic_bin
  • i4_analysis.py — inline-4 phasor analysis
  • boxer4_analysis.py — flat/boxer-4 with sign-flipped opposed pairs
  • rocking_couples.py — moment sums across I4/boxer/straight-3 layouts

7. Physics concept → code map

Physics conceptWhere it lives in code
Generalised coordinates q\mathbf{q}Packed vector in MBody (ordering in constraints.py)
Body mass matrixmass_matrix() in forces.py
Constraint equations C(q,t)C(\mathbf{q},t)constraints(mb, q, t) in constraints.py
Constraint Jacobian Cq\mathbf{C}_qjacobian(mb, q, t) in jacobians.py
dCq/dtd\mathbf{C}_q/dtdt_jacobian(mb, q, v, t) in derivatives.py
2C/t2\partial^2 C/\partial t^2dtdt_constraints(mb, q, v, t) in derivatives.py
Gravity forcegravity_forces()
Spring / damperspring_damper_forces() + Spring class
Penalty contact (ground)ground_contact_forces() + GroundContact
Penalty contact (surface-surface)surface_contact_forces() + SurfaceContact
Qtotal\mathbf{Q}_\text{total} assemblyassemble_system() in dynamics_solver.py
Saddle-point solvesolve_acceleration_with_lagrange() in solver.py
Forward dynamicssolve_dynamics_scipy()
Inverse dynamicssolve_inverse_dynamics()
Position solvenewton_raphson() / solve_position()
Velocity solvesolve_velocity()
Kinetic energykinetic_energy(M, v)
Potential energypotential_energy(mb, q, g=None, springs=None)
Phasor multi-cylinder superpositionphasor_sum() + superpose_time() in multi-cylinder-nograv/_common.py
Rocking-couple moment sumphasor_moment_sum() same file

8. Test coverage

All live in 2D-Test/.

  • test_kinematics.py — position/velocity/acceleration solvers
  • test_constraints.py — each joint type (revolute, prismatic, point-on-line, CAM, gear, user) gets its own integration
  • test_physical_behavior.py — end-to-end correctness on known mechanisms
  • test_dynamics.py — 13 tests covering mass matrix properties, gravity, damped oscillator, ground contact, energy conservation, pendulum period, etc.

All 4 modules + 13 dynamics tests green as of the latest run.

9. FAQ — implementation gotchas

Complements the conceptual FAQ in the physics chapter. These are implementation-level questions that come up when reading or extending the code.

Q: Why do I need the γ (gamma) term in the saddle-point RHS (§4)?

The γ=(C˙qv+2C/t2)\boldsymbol{\gamma} = -(\dot{\mathbf{C}}_q \cdot \mathbf{v} + \partial^2 C/\partial t^2) term captures the inertia of the constraints themselves — the bookkeeping that comes from having differentiated C(q,t)=0C(\mathbf{q}, t) = 0 twice in time. Without it the acceleration solve would only be valid for a frozen instant; the moment any body moves, the Jacobian Cq\mathbf{C}_q changes, and the constraint equation that the solver is actually enforcing (Cqa=γ\mathbf{C}_q \cdot \mathbf{a} = \boldsymbol{\gamma}) would no longer reduce C(q,t)C(\mathbf{q}, t) to zero.

Two distinct contributions live inside γ\boldsymbol{\gamma}, with different physical meanings:

  • C˙qv\dot{\mathbf{C}}_q \cdot \mathbf{v} — the “convective” term. Cq\mathbf{C}_q is a function of the configuration q\mathbf{q}, so as the bodies move it evolves: new orientation angles rotate body-fixed vectors into new world directions, and so on. This term captures the fact that “which direction the constraint pushes” changes with position. Shows up in every joint whose Jacobian isn’t literally constant — which is basically every joint with a rotation in it. Computed analytically per joint in 2D-Kinematics/derivatives.py::dt_jacobian().

  • 2C/t2\partial^2 C/\partial t^2 — the “explicit time” term. Non-zero only for user-supplied constraints that depend explicitly on tt (driving motions like θcrankωt=0\theta_\text{crank} - \omega \cdot t = 0). For every joint constraint in constraints.py this is zero because joints don’t move on their own; only user constraints inject time-dependence. Computed per user constraint in the user-supplied dtdt_constraint_fn callback, plus the joint-internal calls in derivatives.py::dtdt_constraints().

Concrete failure mode if you forgot γ\boldsymbol{\gamma}: run any simulation with moving revolute joints and the system will immediately violate its own constraints. A pin that should connect two bodies will start letting them drift apart at a rate proportional to C˙qv\dot{\mathbf{C}}_q \cdot \mathbf{v}, because the integrator is being told “just keep the Jacobian-projected acceleration zero” rather than “keep the actual constraint satisfied”. Energy-conservation tests would fail almost immediately. This is why the solver always calls dt_jacobian and dtdt_constraints before assembling the saddle-point RHS.

Q: Why does solve_dynamics_scipy() keep λ\boldsymbol{\lambda} hidden by default, but solve_inverse_dynamics() returns it?

solve_dynamics_scipy()’s primary output is the trajectory (q,v)(\mathbf{q}, \mathbf{v}) over time — the motion. The Lagrange multipliers λ(t)\boldsymbol{\lambda}(t) exist at every RHS evaluation (they’re computed inside solve_acceleration_with_lagrange) but scipy’s ODE interface only cares about the state derivatives, so λ\boldsymbol{\lambda} is discarded after each call. If you want the bearing reactions from a forward-dynamics run, you have to post-process: re-run solve_acceleration_with_lagrange at each saved (q(t),v(t))(\mathbf{q}(t), \mathbf{v}(t)) pair and collect λ(t)\boldsymbol{\lambda}(t) manually.

solve_inverse_dynamics(), by contrast, is designed around λ\boldsymbol{\lambda} — the whole point is to compute constraint reaction forces for a given prescribed motion. So it returns lambda_all as a first-class output.

Q: My LevaJoint dynamics simulation runs painfully slowly. Why?

The LevaJoint constraint (surface-to-surface cam contact) isn’t expressed in closed form — the two extra surface-parameter coordinates (si,sj)(s_i, s_j) per joint need to be found by a nonlinear optimisation at every position-solve call. That’s scipy.optimize.fsolve inside _find_closest_surface_params() (for force-based SurfaceContact) or inside the leva constraint function (for bilateral LevaJoint). Each RHS evaluation in scipy.integrate.solve_ivp therefore runs a small nonlinear solve per joint. Typical slowdown: ~10× compared to a mechanism with only revolute/prismatic joints.

Workarounds are documented in the Phase-3 chapters of DYNAMICS_VALIDATION_RESULTS.md; the short version is “use looser tolerances and fewer output points unless you really need the bilateral-contact fidelity”.

Q: What should I do if my simulation errors with LinAlgError: Singular matrix?

Three usual suspects, in order of likelihood:

  1. Over-constrained system. You’ve added a constraint that is redundant with or contradicts an existing one. The constraint Jacobian then has rank less than n_restr, so the block matrix in the saddle-point system is singular. Fix: check that your n_restr and n_coord make sense (see nrestr / ncoord properties of MBody) and that you haven’t accidentally declared the same joint twice.
  2. Degenerate configuration. The constraint Jacobian is full-rank generically but drops rank at specific configurations (e.g. a four-bar locked at a dead-centre). Symptom: the simulation works almost everywhere but fails at particular crank angles. Fix: perturb the initial condition, or add a small damping/Baumgarte term to regularise.
  3. Initial condition off the constraint manifold. If q0\mathbf{q}_0 does not satisfy C(q0,0)=0C(\mathbf{q}_0, 0) = 0 and position projection is disabled, the first linear solve may encounter a badly-conditioned system. Fix: run solve_position(mb, q0_guess, 0) to project onto the manifold before handing it to the dynamics.

Q: Does the simulator handle “pure rolling without slip” as a hard constraint, or only as a friction-penalty approximation?

Penalty-friction only, by design. Rolling without slip is mathematically a non-holonomic constraint — vwheel,x+Rωwheel=0v_\text{wheel,x} + R \cdot \omega_\text{wheel} = 0 at the velocity level, not derivable as the time-derivative of any position equation. Our simulator has no non-holonomic constraint machinery: all joint classes in mbody.py, the assembly in constraints.py, and the saddle-point solver itself are built around holonomic C(q,t)=0C(\mathbf{q}, t) = 0 expressions and their time derivatives.

What we do have is the regularised Coulomb friction Ft=μFntanh(vslip/vlim)F_t = -\mu \cdot F_n \cdot \tanh(v_\text{slip} / v_\text{lim}) attached to GroundContact and SurfaceContact. This is a force, not a constraint — it lives in Qtotal\mathbf{Q}_\text{total}. When μFn\mu \cdot F_n is large enough to overcome whatever would cause slip, the friction term drives vslip0v_\text{slip} \to 0 and “rolling” emerges naturally from the dynamics. In the terrain-wheel example, slip settles to ~22 mm/s (0.4% of forward velocity) after a ~15 ms spin-up transient — numerically close to pure rolling, but not mathematically exact.

Why the penalty approach is actually the right choice here, not just a shortcut:

ScenarioHard rolling constraintPenalty friction (ours)
Tyre on dry road, well-bondedExactExcellent approximation (slip ≈ 0)
Tyre on ice / wet roadMust switch to friction model manuallyHandles seamlessly — μFn\mu \cdot F_n just gets smaller, slip grows
Burnout / wheelspin under high torqueConstraint is violated → simulation fails or needs a complementarity solverFriction saturates at μFn\mu \cdot F_n and the wheel correctly spins faster than vx/R-v_x/R
Tyre slowly sliding down a slopeImpossible — the constraint forbids itNatural; static friction holds until tangential force exceeds μFn\mu \cdot F_n, then slides
Transition between rolling and slidingHard switch; usually needs LCP (linear complementarity) machinerySmooth — controlled by the tanh\tanh regularisation

Real tyres do slip, and modern tyre models (Pacejka “Magic Formula”, brush model, etc.) are all force-based — they compute friction as a function of slip ratio with a more elaborate saturation curve than tanh\tanh. Our approach is the simplest member of that family; swapping tanh(vslip/vlim)\tanh(v_\text{slip}/v_\text{lim}) for a Magic-Formula curve would be a drop-in replacement with the rest of the framework unchanged.

When you’d actually need hard rolling. Three genuine use cases:

  1. Robot-kinematics control synthesis. Designing a path-follower for a differential-drive robot, where its dynamics are defined to roll without slip. You want exactness so the inverse-kinematics solve doesn’t have a residual slip to compensate for.
  2. Classical textbook problems. A disc rolling down an incline with v=Rωv = R \cdot \omega imposed exactly. You want the analytical answer, not a simulated-with-regularisation answer.
  3. Tight-tolerance kinematic-accuracy applications. Gear meshes genuinely should never slip — mechanically interlocked. We already handle this case with the holonomic GearJoint (θiτθj=0\theta_i - \tau \cdot \theta_j = 0 as a position-level equation), which is the right formulation because gear teeth physically can’t slip (up to tooth breakage).

For vehicles, engines, walking mechanisms, anything where real physics includes the possibility of slip, the penalty approach is both simpler to implement and more faithful to reality.

What it would take to add hard non-holonomic constraints. If the need arises:

  • A new constraint class in mbody.py analogous to UserConstraint, but expressing a velocity-level equation of the form D(q,t)v=0D(\mathbf{q}, t) \cdot \mathbf{v} = 0 (takes q,v,t\mathbf{q}, \mathbf{v}, t, returns a scalar).
  • A second Jacobian-assembly pass that builds the velocity-level Jacobian matrix D(q,t)D(\mathbf{q}, t) — separate from the holonomic-constraint Jacobian Cq\mathbf{C}_q.
  • Extend the saddle-point block by adding one more row-column pair per non-holonomic constraint, with its own Lagrange multipliers μnh\boldsymbol{\mu}_\text{nh} alongside the existing λ\boldsymbol{\lambda}. Structurally the block system generalises cleanly; you just grow both axes.
  • Integration is unchanged — it’s still a DAE, just with an extra algebraic-equation type.

Probably 100 lines of code, spread across mbody.py, constraints.py (or a new nh_constraints.py), and the saddle-point assembly in solver.py. It hasn’t been added because no current use case needs it — but the extension is well-defined if it ever does.

10. Where to go next