Skip to content

Commit 6b28899

Browse files
petercorkeclaude
andcommitted
fix(dynamics): fix three bugs in rne_python()'s modified-DH branch
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>
1 parent 9a800e0 commit 6b28899

3 files changed

Lines changed: 256 additions & 35 deletions

File tree

src/roboticstoolbox/models/DH/TwoLink.py

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
@author: Peter Corke
33
"""
44

5-
from roboticstoolbox import DHRobot, RevoluteDH
5+
from roboticstoolbox import DHRobot, RevoluteDH, RevoluteMDH
66

77
# from math import pi
88
from spatialmath import SE3
@@ -14,7 +14,8 @@ class TwoLink(DHRobot):
1414
Class that models a 2-link robot moving in the vertical plane
1515
1616
:param symbolic: use symbolic constants
17-
:type symbolic: bool
17+
:param mdh: create a model using modified DH parameters, otherwise standard DH parameters are used
18+
:param inertia: include link inertias, otherwise they are set to zero
1819
1920
``TwoLink()`` is a class which models a 2-link planar robot and
2021
describes its kinematic and dynamic characteristics using standard DH
@@ -27,16 +28,17 @@ class TwoLink(DHRobot):
2728
>>> robot = rtb.models.DH.TwoLink()
2829
>>> print(robot)
2930
30-
The parameters values depend on the ``symbolic`` parameter
31+
The parameters values depend on the ``symbolic`` and ``mdh`` parameters
3132
32-
======================================= ================= ==============
33-
Parameters Numeric values Symbolic values
34-
======================================= ================= ==============
35-
link lengths 1, 1 a1, a2
36-
link masses 1, 1 m1, m2
37-
link CoMs in the link frame x-direction -0.5, -0.5 c1, c2
38-
gravitational acceleration 9.8 g
39-
======================================= ================= ==============
33+
=========================================== ================= ==============
34+
Parameters Numeric values Symbolic values
35+
=========================================== ================= ==============
36+
link lengths 1, 1 a1, a2
37+
link masses 1, 1 m1, m2
38+
link CoMs in the link frame x-direction DH -0.5, -0.5 c1, c2
39+
link CoMs in the link frame x-direction MDH 0.5, 0.5 c1, c2
40+
gravitational acceleration 9.8 g
41+
=========================================== ================= ==============
4042
4143
Defined joint configurations are:
4244
@@ -49,44 +51,80 @@ class TwoLink(DHRobot):
4951
5052
- Robot has only 2 DoF.
5153
- Motor inertia is 0.
52-
- Link inertias are 0.
54+
- Link inertias are 0 unless ``inertia`` is True.
5355
- Viscous and Coulomb friction is 0.
5456
5557
:Reference: Based on Fig 3-6 (p73) of Spong and Vidyasagar (1st edition).
5658
5759
.. codeauthor:: Peter Corke
5860
"""
5961

60-
def __init__(self, symbolic=False):
62+
def __init__(self, symbolic:bool=False, mdh:bool=False, inertia:bool=False):
6163

6264
if symbolic:
6365
import spatialmath.base.symbolic as sym
6466

6567
zero = sym.zero()
6668
pi = sym.pi()
67-
a1, a2 = sym.symbol("a1 a2") # type: ignore
68-
m1, m2 = sym.symbol("m1 m2") # type: ignore
69-
c1, c2 = sym.symbol("c1 c2") # type: ignore
69+
a1, a2 = sym.symbol("a1 a2") # link lengths # type: ignore
70+
m1, m2 = sym.symbol("m1 m2") # link masses # type: ignore
71+
c1, c2 = sym.symbol("c1 c2") # link CoMs location relative to link frames# type: ignore
72+
if inertia:
73+
r = sym.symbol("r") # link radius, assumed tubular, for inertia calculation # type: ignore
74+
I1, I2 = _cylinder_inertia_x(m1, r, a1), _cylinder_inertia_x(m2, r, a2) # moments of inertia about CoM # type: ignore
75+
else:
76+
I1, I2 = None, None
7077
g = sym.symbol("g")
7178
else:
7279
from math import pi
7380

74-
zero = 0.0
75-
a1 = 1
76-
a2 = 1
77-
m1 = 1
78-
m2 = 1
79-
c1 = -0.5
80-
c2 = -0.5
81-
g = 9.8
8281

83-
links = [
84-
RevoluteDH(a=a1, alpha=zero, m=m1, r=[c1, 0, 0]),
85-
RevoluteDH(a=a2, alpha=zero, m=m2, r=[c2, 0, 0]),
86-
]
82+
if mdh:
83+
# create a modified DH model
84+
if not symbolic:
85+
zero = 0.0
86+
a1 = 1 # length of first link
87+
a2 = 1 # length of second link
88+
m1 = 1 # mass of first link
89+
m2 = 1 # mass of second link
90+
c1 = 0.5 # CoM location of first link relative to first link frame
91+
c2 = 0.5 # CoM location of second link relative to second link frame
92+
r = 0.1 # radius of links, assumed tubular, for inertia calculation
93+
if inertia:
94+
I1, I2 = _cylinder_inertia_x(m1, r, a1), _cylinder_inertia_x(m2, r, a2) # moments of inertia about CoM # type: ignore
95+
else:
96+
I1, I2 = None, None
97+
g = 9.8
98+
99+
links = [
100+
RevoluteMDH(a=0, alpha=zero, m=m1, r=[c1, zero, zero], I=I1),
101+
RevoluteMDH(a=a1, alpha=zero, m=m2, r=[c2, zero, zero], I=I2),
102+
]
103+
tool = SE3.Tx(a2) # the last link is considered as a tool in this case, so the tool is a translation along x by a2
104+
else:
105+
# create a standard DH model
106+
if not symbolic:
107+
zero = 0.0
108+
a1 = 1 # length of first link
109+
a2 = 1 # length of second link
110+
m1 = 1 # mass of first link
111+
m2 = 1 # mass of second link
112+
c1 = -0.5 # CoM location of first link relative to first link frame
113+
c2 = -0.5 # CoM location of second link relative to second link frame
114+
r = 0.1 # radius of links, assumed tubular, for inertia calculation
115+
if inertia:
116+
I1, I2 = _cylinder_inertia_x(m1, r, a1), _cylinder_inertia_x(m2, r, a2) # moments of inertia about CoM # type: ignore
117+
else:
118+
I1, I2 = None, None
119+
g = 9.8
120+
links = [
121+
RevoluteDH(a=a1, alpha=zero, m=m1, r=[c1, 0, 0], I=I1),
122+
RevoluteDH(a=a2, alpha=zero, m=m2, r=[c2, 0, 0], I=I2),
123+
]
124+
tool = None
87125

88126
super().__init__(
89-
links, symbolic=symbolic, name="2 link", keywords=("planar", "dynamics")
127+
links, symbolic=symbolic, name="2 link", tool=tool, keywords=("planar", "dynamics")
90128
)
91129

92130
self.qr = np.array([pi / 6, -pi / 6])
@@ -104,6 +142,12 @@ def __init__(self, symbolic=False):
104142
self.gravity = [0, 0, g]
105143

106144

145+
def _cylinder_inertia_x(m, r, L):
146+
ixx = 0.5 * m * r**2
147+
iyy = (1.0 / 12.0) * m * (3 * r**2 + L**2)
148+
izz = iyy
149+
return np.diag([ixx, iyy, izz])
150+
107151
if __name__ == "__main__": # pragma nocover
108152
robot = TwoLink(symbolic=True)
109153
print(robot)

src/roboticstoolbox/robot/DHRobot.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,10 +1645,14 @@ def removesmall(x):
16451645
alpha = link.alpha
16461646
if self.mdh:
16471647
pstar = np.r_[link.a, -d * sym.sin(alpha), d * sym.cos(alpha)]
1648-
if j == 0:
1649-
if base:
1650-
Tj = base @ Tj
1651-
pstar = base @ pstar
1648+
# NOT baking base into Tj/pstar here (this block used to,
1649+
# before being removed): base rotation is already applied
1650+
# exactly once, to gravity/vd before this loop starts.
1651+
# Also folding it into Rm[0] here double-counted it --
1652+
# confirmed by tracing TwoLink(mdh=True)'s gravity-only
1653+
# case, where vd ended up rotated by the base twice.
1654+
# ne.c matches this: it only ever rotates gravity by the
1655+
# base (in the nanobind glue), never any per-link R.
16521656
else:
16531657
pstar = np.r_[link.a, d * sym.sin(alpha), d * sym.cos(alpha)]
16541658

@@ -1670,7 +1674,14 @@ def removesmall(x):
16701674
# revolute axis
16711675
w_ = Rt @ w + z0 * qd_k[j]
16721676
wd_ = Rt @ wd + z0 * qdd_k[j] + _cross(Rt @ w, z0 * qd_k[j])
1673-
vd_ = Rt @ _cross(wd, pstar) + _cross(w, _cross(w, pstar)) + vd
1677+
# Rt must distribute over the whole bracket, not just
1678+
# the first term -- matches ne.c's MODIFIED-DH branch,
1679+
# which does rot_trans_vect_mult() (= Rt @ ...) on the
1680+
# full OMEGADOT(j-1)xPSTAR + OMEGA(j-1)x(OMEGA(j-1)xPSTAR)
1681+
# + ACC(j-1) sum. The prismatic case below already has
1682+
# this right; this revolute case was missing the
1683+
# parentheses (and therefore wrong for any MDH robot).
1684+
vd_ = Rt @ (_cross(wd, pstar) + _cross(w, _cross(w, pstar)) + vd)
16741685
else:
16751686
# prismatic axis
16761687
w_ = Rt @ w
@@ -1741,7 +1752,12 @@ def removesmall(x):
17411752
nn_ = (
17421753
R @ nn
17431754
+ _cross(pstar, R @ f)
1744-
+ _cross(pstar, Fm[:, j])
1755+
# this link's own force acts through its own CoM
1756+
# offset r, not pstar (which is the offset to the
1757+
# *next* link's origin) -- matches ne.c's MODIFIED
1758+
# branch: vect_cross(&t2, R_COG(j), &F) uses R_COG(j)
1759+
# (this link's r), not PSTAR(j+1)
1760+
+ _cross(r, Fm[:, j])
17451761
+ Nm[:, j]
17461762
)
17471763
f = f_

tests/test_fknm_fallback.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,167 @@ def test_twolink_with_ee_wrench(self):
742742
)
743743

744744

745+
# ---------------------------------------------------------------------------
746+
# TwoLink(mdh=True) vs TwoLink(mdh=False): two different DH parameterizations
747+
# of the *same physical robot* -- this is what actually caught the bug where
748+
# TwoLink's mdh=True variant just copied the standard-DH `a`/`alpha` values
749+
# onto RevoluteMDH links, which is not how the DH<->MDH conversion works (it
750+
# shifts `a`/`alpha` to the previous link index). A single hand-checked pose
751+
# (qn = [pi/6, -pi/6]) happened to agree by coincidence -- qn is symmetric
752+
# (q2 = -q1) -- while every other pose diverged by up to ~0.7 in fkine.
753+
# Random poses, not just qn, are the point of this test.
754+
#
755+
# This pairing also gives a genuinely independent cross-check of the DH/MDH
756+
# convention finding (issue 6, rne.md). Originally: rne_python() trusted
757+
# only for standard DH, Robot.rne() only for modified DH. Exercising
758+
# rne_python() on this MDH+rotated-base pair (apparently never hit before)
759+
# found and fixed three real bugs in its MDH branch (see
760+
# test_mdh_rne_python_now_agrees) -- rne_python() is now correct for both
761+
# conventions. Robot.rne() (the separate ETS/Featherstone implementation) is
762+
# joint-last-compliant, and thus correct, for mdh=True DHRobot instances
763+
# (test_robot_rne_on_mdh_variant_matches_standard_dh_rne_python); for
764+
# mdh=False it now asserts rather than silently returning a wrong answer
765+
# (test_robot_rne_rejects_standard_dh) -- see rne.md/tech-debt.md.
766+
# ---------------------------------------------------------------------------
767+
768+
class TestTwoLinkDHMDHEquivalence(unittest.TestCase):
769+
"""TwoLink(mdh=False) and TwoLink(mdh=True) are the same physical robot."""
770+
771+
def setUp(self):
772+
self.std = _twolink()
773+
self.mdh = TwoLink(mdh=True)
774+
self.poses = [
775+
np.array([0.3, 0.5]),
776+
np.array([1.2, -0.7]),
777+
np.array([0.2, 0.3]),
778+
]
779+
780+
def test_fkine_agrees(self):
781+
for q in self.poses:
782+
nt.assert_array_almost_equal(
783+
self.std.fkine(q).A, self.mdh.fkine(q).A, decimal=10
784+
)
785+
786+
def test_fkine_agrees_random_poses(self):
787+
rng = np.random.default_rng(0)
788+
for _ in range(50):
789+
q = rng.uniform(-np.pi, np.pi, 2)
790+
nt.assert_array_almost_equal(
791+
self.std.fkine(q).A, self.mdh.fkine(q).A, decimal=10
792+
)
793+
794+
def test_rne_agrees_between_parameterizations(self):
795+
"""rne() (the single ne.c implementation, dispatched per-robot via
796+
its own dhtype: STANDARD vs MODIFIED) gives matching torques for
797+
both parameterizations of the same physical robot -- evidence that
798+
ne.c's two dhtype branches are mutually consistent, not that
799+
there are two separate C implementations to compare."""
800+
z = np.zeros(2)
801+
for q in self.poses:
802+
tau_std_py = self.std.rne_python(q, z, z)
803+
tau_std = self.std.rne(q, z, z)
804+
tau_mdh = self.mdh.rne(q, z, z)
805+
nt.assert_array_almost_equal(tau_std_py, tau_std, decimal=4)
806+
nt.assert_array_almost_equal(tau_std, tau_mdh, decimal=4)
807+
808+
def test_robot_rne_on_mdh_variant_matches_standard_dh_rne_python(self):
809+
"""The core rne.md finding, checked against a real matched pair
810+
rather than the synthetic 1-link case: Robot.rne() (trusted for
811+
MDH) applied to the MDH parameterization must agree with
812+
rne_python() (trusted for standard DH) applied to the standard-DH
813+
parameterization of the same physical robot."""
814+
from roboticstoolbox.robot.Robot import Robot as RobotBase
815+
816+
z = np.zeros(2)
817+
for q in self.poses:
818+
tau_std_py = self.std.rne_python(q, z, z)
819+
tau_mdh_base = RobotBase.rne(self.mdh, q, z, z)
820+
nt.assert_array_almost_equal(tau_std_py, tau_mdh_base, decimal=4)
821+
822+
def test_mdh_rne_python_now_agrees(self):
823+
"""rne_python() on the MDH parameterization -- exercising this
824+
(mdh=True + a non-identity base) found three real bugs, none of
825+
them issue 6:
826+
827+
1. Base rotation applied to gravity twice: once (correctly)
828+
before the recursion loop, and again via Tj = base @ Tj for
829+
the first link -- double-counted, not just wrong.
830+
2. Missing parentheses in the MDH revolute case's linear
831+
acceleration formula: `Rt @ cross(wd, pstar) + cross(w,
832+
cross(w, pstar)) + vd` should distribute Rt over the whole
833+
sum, per ne.c's MODIFIED-DH branch (rot_trans_vect_mult
834+
applied to the full bracket) -- the prismatic case right
835+
below it already had this right.
836+
3. The backward recursion's moment equation used `pstar` (the
837+
*next* link's offset) where it should use `r` (this link's
838+
own CoM offset) for the this-link-force-to-torque term --
839+
ne.c's equivalent is `R_COG(j) x F`, not `PSTAR(j+1) x F`.
840+
841+
With all three fixed, rne_python() is now correct for MDH too,
842+
not just standard DH -- see test_robot_rne_rejects_standard_dh
843+
for the one implementation (Robot.rne(), the ETS/Featherstone
844+
one, unrelated code) that still can't handle standard DH (by
845+
design, guarded, not silently wrong).
846+
"""
847+
z = np.zeros(2)
848+
for q in self.poses:
849+
truth = self.std.rne_python(q, z, z)
850+
mdh_py = self.mdh.rne_python(q, z, z)
851+
nt.assert_array_almost_equal(mdh_py, truth, decimal=4)
852+
853+
854+
# ---------------------------------------------------------------------------
855+
# Actuator dynamics (Jm, G, B, Tc) and non-zero link inertia: C vs
856+
# rne_python() consistency on TwoLink, for both DH conventions. TwoLink is
857+
# zero for all of these by default (per its own docstring: "Motor inertia
858+
# is 0 ... Viscous and Coulomb friction is 0", link inertias also 0 unless
859+
# inertia=True) -- so nothing before this exercised the actuator-dynamics
860+
# terms (ne.c:485-491 / Link.friction()) or a non-zero inertia tensor
861+
# through rne() at all.
862+
# ---------------------------------------------------------------------------
863+
864+
@unittest.skipUnless(_FRNE_C_AVAILABLE, _NO_FRNE_C)
865+
class TestTwoLinkActuatorDynamics(unittest.TestCase):
866+
"""rne() (C) vs rne_python() with non-zero Jm/G/B/Tc and link inertia."""
867+
868+
def _with_actuator_dynamics(self, robot):
869+
for i, link in enumerate(robot.links):
870+
link.Jm = 0.05 * (i + 1)
871+
link.G = 100.0 * (i + 1)
872+
link.B = 0.01 * (i + 1)
873+
link.Tc = [0.3 * (i + 1), -0.2 * (i + 1)]
874+
return robot
875+
876+
def setUp(self):
877+
self.std = self._with_actuator_dynamics(TwoLink(mdh=False, inertia=True))
878+
self.mdh = self._with_actuator_dynamics(TwoLink(mdh=True, inertia=True))
879+
# non-zero qd/qdd: needed to actually exercise B/Tc (velocity-
880+
# dependent) and Jm (acceleration-dependent) -- the gravity-only
881+
# (qd=qdd=0) tests elsewhere in this file wouldn't touch them
882+
self.q = np.array([0.3, 0.5])
883+
self.qd = np.array([0.4, -0.6])
884+
self.qdd = np.array([0.2, 0.7])
885+
886+
def test_std_dh_c_matches_python(self):
887+
tau_c = self.std.rne(self.q, self.qd, self.qdd)
888+
tau_py = self.std.rne_python(self.q, self.qd, self.qdd)
889+
nt.assert_array_almost_equal(tau_c, tau_py, decimal=4)
890+
891+
def test_mdh_c_matches_python(self):
892+
tau_c = self.mdh.rne(self.q, self.qd, self.qdd)
893+
tau_py = self.mdh.rne_python(self.q, self.qd, self.qdd)
894+
nt.assert_array_almost_equal(tau_c, tau_py, decimal=4)
895+
896+
def test_inertia_and_actuator_dynamics_actually_matter(self):
897+
"""Sanity check the comparisons above aren't vacuous: non-zero
898+
inertia/Jm/G/B/Tc must actually change the result relative to the
899+
all-zero-by-default TwoLink()."""
900+
bare = TwoLink(mdh=False)
901+
tau_bare = bare.rne(self.q, self.qd, self.qdd)
902+
tau_with = self.std.rne(self.q, self.qd, self.qdd)
903+
self.assertGreater(np.abs(tau_with - tau_bare).max(), 0.1)
904+
905+
745906
# ---------------------------------------------------------------------------
746907
# Path verification via timing
747908
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)