Skip to content

feat(dynamics): add armature support, fix rne() calculation bugs, solid testing#560

Merged
petercorke merged 11 commits into
mainfrom
fix/dynamics-overhaul
Jul 23, 2026
Merged

feat(dynamics): add armature support, fix rne() calculation bugs, solid testing#560
petercorke merged 11 commits into
mainfrom
fix/dynamics-overhaul

Conversation

@petercorke

Copy link
Copy Markdown
Owner

We thank you in advance for your pull request, and for your interest and support of the Robotics Toolbox for Python.

You could help us a lot by:

  • Making your pull request relative to the future branch. This is where we fix issues and merge pull requests prior to pushing out a new release.
  • Including a reference/link to any related issues.
  • Providing a clear description of the problem you are addressing with the pull request, the changes proposed, and their rationale.
    • We appreciate code comments explaining what your added block of code does.
    • If your PR tackles a number of different issues, please submit multiple smaller/simpler PRs that we can individually accept or not. Otherwise we have to ask you to modify your PR and leave some changes out. Unfortunately GH doesn't let us pick and choose.
  • Making your PR as small as possible. Don't include test files, data files, or notebooks that are specific to your project; ensure that notebooks, if of general interest, are saved with output values cleared, and that robot models include only those files required. PyPI has strict size limits on packages, and already RTB is split into a toolbox and data package.

petercorke and others added 11 commits July 21, 2026 11:38
…tion

Phase A of the pure-Python-wheel plan: harden the C/Python cross-
validation tests so a missing compiled extension can never produce a
false pass.

Gates every test_matches_c_path-style test (and the whole TestRNEFallback/
TestEvalFallback classes) behind skipUnless(_C_AVAILABLE) -- previously
these ran unconditionally, comparing the "C path" against a forced-Python
path. When the extension isn't built (confirmed reproducible: this repo's
own stale local dev env had neither _fknm_c nor _frne_c installed all
session), the unpatched "C path" call silently dispatches to Python too,
so the comparison degrades to Python-vs-itself and passes trivially, with
no indication anything was skipped. Verified both regimes locally: with
the extensions built, all 43 tests pass and genuinely exercise C; with
them removed, the same 26 comparison tests show SKIPPED (not PASSED) and
17 remain passing.

Adds a second, independent tier: TestFkineReference/TestJacob0Reference,
hardcoded ground-truth values for Panda FK/jacob0 at a fixed pose,
computed once against the (now-verified-correct) C extension. These run
regardless of _C_AVAILABLE, mirroring the existing TestRNEReference
pattern -- they're what actually proves a C-less build (e.g. the planned
pyodide/wasm wheel) is correct on its own terms, not merely internally
consistent.

Adds a "Verify compiled extensions built" CI step (test-core and the
full test matrix) that asserts _C_AVAILABLE for both fknm and frne right
after install, so a silent build/import regression is a hard CI failure
instead of something that only shows up as tests quietly skipping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nto ne.c

Robot.rne() (the ETS/Featherstone implementation) ignored self.base
entirely for gravity -- fixed to rotate gravity into the root link frame
via the base orientation before negating to the effective upward
acceleration RNE expects, skipping the matmul for an identity base so
symbolic gravity components don't pick up spurious "1.0*" literals.

DHRobot.rne_python()'s rotated-base branch had a related bug: it computed
vd = Rb @ gravity where every other call site (including this function's
own identity-base branch) computes -gravity -- an exact sign flip.

ne.c/frne_nb.cpp never handled base rotation at all -- worked around for
~30 years by DHRobot.rne() pre-rotating (and negating) the gravity vector
in Python before calling C. Moved into the C glue itself: frne_nb.cpp's
frne() now takes self.base.R directly and applies it via the existing
rot_trans_vect_mult C helper, so DHRobot.rne()/_copy_to_cpp() no longer
need to know about this at all (init() no longer takes a gravity param).

Also added base_wrench output to the C path (ne.c's backward recursion
already computed f(0)/n(0), just never returned it) -- base_wrench=True no
longer forces the slow Python fallback -- and fixed rne_python()'s
base_wrench array being allocated with shape (nk, n) instead of (nk, 6) (a
wrench is always 6-dimensional; happened to work by coincidence for 6-DOF
Puma560, crashed for 2-DOF TwoLink). Standardized fext/tip-force
terminology on "wrench applied to end-effector" throughout
(f_tip/n_tip -> f_ee/n_ee in the C maths).

New tests: TestRNERotatedBaseFallback/Reference (TwoLink vs C, vs
hardcoded reference values verified against the Lagrangian identity) and
TestBaseWrenchFallback/Reference (Puma560 + TwoLink, with and without an
end-effector wrench).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Found by term-by-term comparison against ne.c's MODIFIED branch, while
exercising an mdh=True + rotated-base case (TwoLink) that had apparently
never been exercised before:

1. Base rotation applied to gravity twice: once (correctly) before the
   recursion loop via vd = Rb @ vd, and again via a redundant
   `if j == 0: if base: Tj = base @ Tj; pstar = base @ pstar` block for
   the first link -- double-counted, not just wrong. Removed the block
   entirely; base rotation only needs applying once.
2. Missing parentheses in the MDH revolute case's linear acceleration
   formula: `Rt @ cross(wd, pstar) + cross(w, cross(w, pstar)) + vd`
   should distribute Rt over the whole sum, per ne.c's MODIFIED-DH branch
   (rot_trans_vect_mult applied to the full bracket) -- the prismatic case
   right below it already had this right.
3. The backward recursion's moment equation used pstar (the *next* link's
   offset) where it should use r (this link's own CoM offset) for the
   this-link-force-to-torque term -- ne.c's equivalent is R_COG(j) x F,
   not PSTAR(j+1) x F.

With all three fixed, rne_python() agrees with the C extension for both DH
conventions, not just standard DH.

Also fixes TwoLink's mdh=True variant, which was copying the standard-DH
a/alpha values directly onto RevoluteMDH links -- not how the DH<->MDH
conversion works (it shifts a/alpha to the previous link index). Adds
inertia=True support (real per-link inertia tensors via a tubular-link
cylinder-inertia helper) so TwoLink can exercise non-trivial dynamics
parameters, not just point masses.

New tests: TestTwoLinkDHMDHEquivalence (TwoLink(mdh=False) and
TwoLink(mdh=True) are the same physical robot -- fkine agreement, rne
agreement between C and rne_python, and confirms rne_python's MDH branch
now agrees with the standard-DH ground truth) and TestTwoLinkActuatorDynamics
(C vs rne_python with non-zero Jm/G/B/Tc and real inertia tensors, for both
DH conventions -- TwoLink is all-zero for these by default, so nothing
before this exercised the actuator-dynamics terms through rne() at all).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Root-caused why Robot.rne() (the separate ETS/Featherstone implementation)
only ever worked for modified DH: its algorithm structurally requires the
joint to be the last element of its own ETS segment (Featherstone's
spatial-vector convention). This holds for Robot/ERobot/URDFRobot (built
via ETS.split()), PoERobot (_update_ets() appends the joint last too), and
-- checked carefully, since it wasn't obvious -- for DHRobot(mdh=True) too:
DHLink._to_ets()'s MDH revolute branch reorders a nonzero d translation to
precede the joint rotation specifically so the joint ET stays last; valid
because a z-rotation and a z-translation about/along the same axis commute.
It does not hold for DHRobot(mdh=False) (standard DH), where the joint
comes before the link's fixed geometry -- structurally incompatible with
the algorithm, not fixable without changing what Robot.rne() fundamentally
assumes.

Rather than teach Robot.rne() a second, DH-aware recursion, it now asserts
on the incompatible case instead of silently returning a wrong answer:
`assert getattr(self, "mdh", True)`. Checked via the mdh attribute rather
than class identity -- an earlier class-name blocklist attempt, and a
considered-and-rejected class-name allowlist, would each have gotten this
wrong (the allowlist specifically would have rejected PoERobot, a fully
compliant type). Design tradeoffs recorded in tech-debt.md, tied to the
pending robot-class-hierarchy redesign since that may make the whole
question moot.

New diagnostic script examples/rne_dh_convention_check.py: a single-link
revolute robot, checked against the Lagrangian-identity ground truth
(independent of both RNE implementations), demonstrating rne_python()
agrees for both DH conventions while Robot.rne() now cleanly rejects
standard DH rather than silently mis-computing it.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Robot.rne()'s inertia accumulation, SpatialInertia(m=link.m, r=link.r),
never passed I=link.I -- the rotational inertia tensor was silently
dropped for every link, on every call, regardless of DH convention. This
affected Robot/ERobot/URDFRobot/PoERobot/DHRobot(mdh=True) alike, not just
the MDH edge case that surfaced it.

Found while numerically verifying the previous commit's mdh-based guard
(not just trusting the structural joint-last argument): a DHRobot(mdh=True)
with nonzero d/alpha/mass and a full inertia tensor, compared against
rne_python(), showed a genuine, non-trivial discrepancy (~0.005) that
turned out to be entirely independent of d/alpha -- present even in the
static (qd=qdd=0) case with G/Jm/B/Tc all zero, pinning it to the rigid-
body inertia term rather than anything MDH-specific or actuator-related.

Not caught earlier because the TwoLink-based Robot.rne()-vs-rne_python()
equivalence tests used default (zero) inertia, and the tests that did set
inertia=True only compared the C path against rne_python(), never
Robot.rne().

New test: TestRobotRneInertiaTensor, using a synthetic MDH robot with
nonzero d, alpha, mass, and a full asymmetric inertia tensor, checked
against rne_python() at static, single, and 20 random poses.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Robot.rne() (the ETS/Featherstone implementation) modeled pure rigid-body
dynamics only -- it never added armature inertia (G, Jm) or friction (B,
Tc), unlike DHRobot.rne_python()/rne() (C), which both add G^2*Jm*qdd and
subtract link.friction() in their backward recursion. Added the same term,
so all three implementations now agree on robots with non-trivial actuator
dynamics, not just rigid-body-only ones.

No dedicated new test: exercised indirectly by every existing Robot.rne()
comparison test on models with default (zero) G/Jm/B/Tc, where the added
term is a no-op (confirmed: full suite unchanged) -- direct coverage with
non-zero actuator parameters comes from examples/rne_speed.py's
correctness check on rtb.models.DH.Panda(), which has real dynamics.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…d-form dynamics

Everything checking RNE correctness so far in this branch is relative:
implementation A compared against implementation B, or against hardcoded
reference numbers computed the same way one of the implementations works.
None of that can catch a bug all three implementations share.

Adds TestTwoLinkAbsoluteGroundTruth: TwoLink's torques checked against an
independent closed-form solution for a planar 2R elbow manipulator (Spong,
Hutchinson, Vidyasagar, "Robot Modeling and Control", Eq 7.87) -- typed in
from the textbook, sharing no code with rne_python()/rne()/Robot.rne().
TwoLink's joint-angle/torque sign convention turns out to be the mirror
image of Spong's own diagram (tau_rtb(q, qd, qdd) == -tau_spong(-q, -qd,
-qdd)) -- empirically calibrated and confirmed exact to machine precision
across several q/qd/qdd combinations, not just the gravity-only case.

Covers rne_python() (both DH conventions), rne() (C, both conventions),
and Robot.rne() (mdh=True only, per the guard added earlier).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ll dtype selection

Two related gaps in symbolic-model handling (rne.md issues 3-4):

1. self.symbolic was purely a construction-time flag the caller had to
   remember to pass (symbolic=True) -- forgetting it left self.symbolic
   False even when the link parameters genuinely contained SymPy content,
   which broke rne_python()'s own float64 array allocation (it's supposed
   to be the always-works fallback). Fixed: BaseRobot.__init__ now scans
   each link's DH/dynamics parameters (a, alpha, theta, d, m, r, I, Jm, G,
   B, Tc) for non-numeric content in its existing per-link geometry-flag
   loop, and ORs the result into self._symbolic. symbolic= is kept as an
   override, not the source of truth.

2. rne_python() only checked self.symbolic (model-level) to pick its
   internal dtype, not whether *this call's* Q/QD/QDD were symbolic -- a
   numeric model called with symbolic q (e.g. differentiating tau
   symbolically w.r.t. q for a concrete, numeric-mass robot) still crashed
   with float64 allocation. Fixed via a local symbolic_call variable
   (self.symbolic or any of Q/QD/QDD symbolic), used for both the dtype
   choice and the Coulomb-friction guard.

Both verified directly: a DHRobot built with a symbolic `a` but no
symbolic=True now correctly reports robot.symbolic == True, and a
symbolic-q call against TwoLink() (a fully numeric model) now returns a
symbolic expression instead of crashing.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…h trajectories into one C call

Three closely-related changes to DHRobot.rne()'s dispatch logic, all
touching the same method body:

- Symbolic-aware dispatch: routes to rne_python() if self.symbolic or any
  of q/qd/qdd is symbolic, mirroring fknm._is_symbolic's idiom, instead of
  only discovering the C path can't handle it via a crash inside
  _copy_to_cpp()/frne() (rne.md issues 1/2).
- try/except safety net around the C dispatch path: falls back to
  rne_python() on (TypeError, ValueError) so any *other* unanticipated
  incompatibility with the C extension's argument marshalling degrades
  gracefully instead of propagating a raw exception.
- Batch trajectories into a single C call (rne.md issue 5/plan step 7):
  frne_nb.cpp's frne() binding now takes (trajn, n) q/qd/qdd arrays and
  loops over the whole trajectory inside C++ (gravity/base-rotation setup
  done once, not per row) instead of DHRobot.rne() looping in Python and
  calling frne() once per row. Confirmed via examples/rne_compare.py's
  per-row C-call counter: 1 call for a 1000-row trajectory (was 1000).
  Measured speedup (examples/rne_speed.py, rtb.models.DH.Panda(), 1000
  distinct random poses): C ~0.6ms total (~0.6us/row) vs rne_python()
  ~289ms (~474x) and Robot.rne() ~446ms (~731x).

New tests: TestRneTrajectoryVaryingRows uses deliberately distinct (not
tiled/repeated) rows per trajectory -- a uniform trajectory can't
distinguish "row i is computed correctly" from "row i is silently some
other row's result", which matters specifically because the C-side change
introduces new row-indexing arithmetic (i*nj offsets into flat buffers).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ne.md

- Fixed rne_python()'s docstring calling itself rne_dh throughout (stale
  name from before a rename) and deleted a dead commented-out symbolic-
  simplification block at the end of the function.
- Fixed BaseRobot.gravity's docstring: a robot.name -> robot.gravity
  copy-paste typo, and removed a factually wrong note ("if the z-axis is
  upward... this should be a positive number") that contradicted the
  actual [0,0,-9.81] convention -- standardized wording to match the
  gravity-parameter docstrings fixed elsewhere this branch.
- Added a module docstring to Dynamics.py explaining the DynamicsMixin /
  RobotProto architecture: rne() is a per-class primitive (differently
  implemented by Robot vs DHRobot), and all derived dynamics quantities
  (accel, gravload, coriolis, etc.) are generic, built purely on top of
  primitives -- context for why Robot.rne()/DHRobot.rne_python() were
  deliberately kept as independent implementations rather than unified.
- Fixed blocks/arm.py's gravity parameter: docstring claimed `float` but
  the actual runtime value is always a 3-vector passed straight through to
  Robot.rne()/gravload() -- fixed the type hint and 3 constructors that
  weren't calling getvector(gravity, 3) on it.
- tech-debt.md: recorded two things noticed opportunistically while here
  but out of scope to fix now -- blocks/ has essentially no type hints,
  and examples/ik_speed.py doesn't run (stale `import fknm`, predates this
  branch entirely).
- rne.md: the full investigation log for this branch, written up as it
  went -- root causes, fixes, and the plan for each of the issues fixed in
  the preceding commits.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@petercorke petercorke changed the title Fix/dynamics overhaul feat(dynamics): add armature support, fix rne() calculation bugs, solid testing Jul 23, 2026
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 117 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (40c4882) to head (8ff9391).

Files with missing lines Patch % Lines
src/roboticstoolbox/models/DH/TwoLink.py 0.00% 45 Missing ⚠️
src/roboticstoolbox/robot/DHRobot.py 0.00% 44 Missing ⚠️
src/roboticstoolbox/robot/BaseRobot.py 0.00% 12 Missing ⚠️
src/roboticstoolbox/robot/Robot.py 0.00% 9 Missing ⚠️
src/roboticstoolbox/robot/frne.py 0.00% 4 Missing ⚠️
src/roboticstoolbox/blocks/arm.py 0.00% 3 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff          @@
##            main    #560   +/-   ##
=====================================
  Coverage   0.00%   0.00%           
=====================================
  Files        142     142           
  Lines      13729   13788   +59     
=====================================
- Misses     13729   13788   +59     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@petercorke
petercorke merged commit 0bb9645 into main Jul 23, 2026
18 of 19 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.

1 participant