Repository navigation
Expand file tree
/
Copy pathRBDReference.py
More file actions
4962 lines (4400 loc) · 227 KB
/
Copy pathRBDReference.py
File metadata and controls
4962 lines (4400 loc) · 227 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import numpy as np
import copy
import sympy as sp
from ._plant import _PlantMixin
from ._energy import _EnergyMixin
from ._centroidal import _CentroidalMixin
from ._regressor import _RegressorMixin
np.set_printoptions(precision=4, suppress=True, linewidth=100)
# Additive reference-oracle mixins (plant/cost/barrier, energy/gravity/Coriolis,
# CoM/centroidal, sysID regressor). Composed onto the existing single-class
# RBDReference; `_HelpersMixin`-style shared state (`self.robot`,
# `_normalize_*`, cross operators, EE pose) lives on the concrete class, so the
# mixins call into it via MRO. This anticipates the D.5 file split without
# performing it (see docs/open-tasks/rbdreference_split_plan.md).
class RBDReference(
_PlantMixin,
_EnergyMixin,
_CentroidalMixin,
_RegressorMixin,
):
def __init__(self, robotObj, use_joint_dynamics=False):
"""Initialize RBDReference with a robot object.
Parameters
----------
robotObj : URDFParser.Robot
The Robot returned by URDFParser().parse(...), not the parser itself.
use_joint_dynamics : bool, optional
When True, the RNEA/ABA value path applies the joint-local
``<dynamics damping>`` / ``<dynamics friction>`` bias
(tau += damping*qd + friction*sign(qd)). DEFAULT False to stay
consistent with bare Pinocchio's `pin.rnea`/`pin.aba`, which IGNORE
`model.damping`/`model.friction` in the value path — making this the
authoritative-oracle-preserving default. The id/fd GRADIENTS follow
this same flag: when True they add the damping diagonal
(d(damping*qd)/dqd) to dc_dqd / qdd_dqd (friction's subgradient is 0).
Returns
-------
None : None
None
"""
self.robot = robotObj # instance of Robot Object class created by URDFparser
self.use_joint_dynamics = use_joint_dynamics
self._spatial_xmat_derivative_func_cache = {}
self._spatial_xmat_second_derivative_func_cache = {}
def _normalize_q_input(self, q):
return self.robot.normalize_floating_base_q_input(q)
def _normalize_v_input(self, vec):
return self.robot.normalize_floating_base_v_input(vec)
def _denormalize_v_output(self, vec):
return self.robot.denormalize_floating_base_v_output(vec)
def _permute_matrix_prefix(self, matrix, permutation, axis):
if permutation is None:
return matrix
matrix = np.asarray(matrix, dtype=np.float64).copy()
prefix = len(permutation)
if axis == 0:
matrix[:prefix, :] = matrix[permutation, :]
else:
matrix[:, :prefix] = matrix[:, permutation]
return matrix
def _denormalize_qv_matrix_output(self, matrix, row_space=None, col_space=None):
matrix = np.asarray(matrix, dtype=np.float64).copy()
if row_space == "v":
matrix = self._permute_matrix_prefix(
matrix,
self.robot.get_floating_base_v_permutation_from_internal(),
axis=0,
)
elif row_space == "q":
matrix = self._permute_matrix_prefix(
matrix,
self.robot.get_floating_base_q_output_permutation_from_internal(),
axis=0,
)
if col_space == "v":
matrix = self._permute_matrix_prefix(
matrix,
self.robot.get_floating_base_v_permutation_from_internal(),
axis=1,
)
elif col_space == "q":
matrix = self._permute_matrix_prefix(
matrix,
self.robot.get_floating_base_q_output_permutation_from_internal(),
axis=1,
)
return matrix
def _denormalize_reduced_q_matrix_output(self, matrix, row_space=None):
return self._denormalize_qv_matrix_output(matrix, row_space=row_space, col_space=None)
def _denormalize_inverse_dynamics_gradient_output(self, dc_dq, dc_dqd):
return np.hstack(
(
self._denormalize_reduced_q_matrix_output(dc_dq, row_space="v"),
self._denormalize_qv_matrix_output(dc_dqd, row_space="v", col_space="v"),
)
)
@staticmethod
def _normalize_xyzw_quaternion(quat):
quat = np.asarray(quat, dtype=np.float64).copy()
quat_norm = np.linalg.norm(quat)
if quat_norm == 0.0:
raise ValueError("Floating-base quaternion norm was zero during normalization.")
return quat / quat_norm
# ----- SO(3) / SE(3) helpers used by the time-integrator step -----
# Quaternion convention: xyzw (matches Pinocchio's free-flyer joint).
# Free-flyer velocity convention: Pinocchio order, v = [v_lin (3); omega (3)],
# interpreted in the LOCAL/body frame. This matches the user-facing
# convention everywhere else in the project (see test_forward_dynamics_*).
@staticmethod
def _quat_mul_xyzw(a, b):
"""Hamilton-product quaternion multiply: returns a ⊗ b in xyzw order."""
ax, ay, az, aw = a
bx, by, bz, bw = b
return np.array([
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
], dtype=np.float64)
@staticmethod
def _quat_exp_from_half_omega(half_omega):
"""Quaternion exponential map (xyzw) from half-angle vector.
For an angular velocity * dt = omega_dt, the SO(3) retract is
`q_new = q ⊗ exp_quat(0.5 * omega_dt)`. This helper takes the
already-halved 3-vector and returns its quaternion exponential.
"""
half_omega = np.asarray(half_omega, dtype=np.float64)
theta = float(np.linalg.norm(half_omega))
if theta < 1e-12:
# Small-angle Taylor: sin(theta)/theta ≈ 1 - theta^2/6
sinc = 1.0 - theta * theta / 6.0
cos_t = 1.0 - 0.5 * theta * theta
else:
sinc = np.sin(theta) / theta
cos_t = np.cos(theta)
vec = sinc * half_omega # (xyz components)
return np.array([vec[0], vec[1], vec[2], cos_t], dtype=np.float64)
@staticmethod
def _rotation_from_quat_xyzw(q):
"""Build a 3x3 rotation matrix from an xyzw quaternion."""
x, y, z, w = q
# Standard quaternion-to-rotation formula (assumes unit quaternion).
xx, yy, zz = x * x, y * y, z * z
xy, xz, yz = x * y, x * z, y * z
wx, wy, wz = w * x, w * y, w * z
return np.array([
[1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)],
[2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)],
[2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)],
], dtype=np.float64)
@staticmethod
def _so3_skew(v):
x, y, z = v
return np.array([
[0.0, -z, y],
[z, 0.0, -x],
[-y, x, 0.0],
], dtype=np.float64)
@staticmethod
def _so3_V_matrix(phi):
"""SE(3)-exp 'V' helper: p_delta = V(phi) @ rho where phi = omega*dt
and rho = v_lin * dt. Same closed-form as Pinocchio's local-frame
free-flyer exponential.
"""
phi = np.asarray(phi, dtype=np.float64)
theta = float(np.linalg.norm(phi))
skew_phi = RBDReference._so3_skew(phi)
if theta < 1e-8:
# Small-angle Taylor:
# V ≈ I + 0.5*[phi]_x + (1/6)*[phi]_x^2
return np.eye(3) + 0.5 * skew_phi + (1.0 / 6.0) * (skew_phi @ skew_phi)
a = (1.0 - np.cos(theta)) / (theta * theta)
b = (theta - np.sin(theta)) / (theta ** 3)
return np.eye(3) + a * skew_phi + b * (skew_phi @ skew_phi)
@staticmethod
def _so3_right_jacobian(phi):
"""SO(3) right Jacobian J_r(phi). Used by SE(3) dIntegrate (free-flyer)."""
phi = np.asarray(phi, dtype=np.float64)
theta = float(np.linalg.norm(phi))
skew_phi = RBDReference._so3_skew(phi)
if theta < 1e-8:
return np.eye(3) - 0.5 * skew_phi + (1.0 / 6.0) * (skew_phi @ skew_phi)
a = (1.0 - np.cos(theta)) / (theta * theta)
b = (theta - np.sin(theta)) / (theta ** 3)
return np.eye(3) - a * skew_phi + b * (skew_phi @ skew_phi)
@staticmethod
def _se3_Q_block(rho, phi):
"""SE(3) right-Jacobian coupling block Q(rho, phi) used by
`pin.dIntegrate(..., ARG1)` for free-flyer joints with body-frame
velocity. Closed-form adapted from Sola, Deray & Atchuthan,
'A micro Lie theory for state estimation in robotics' (2018) eq. (184),
evaluated at (rho, -phi) to match Pinocchio's sign convention. The
SO(3) part of phi appears at odd powers in some terms and even
powers in others — straight substitution gives Pinocchio's form."""
rho = np.asarray(rho, dtype=np.float64)
phi_neg = -np.asarray(phi, dtype=np.float64)
theta = float(np.linalg.norm(phi_neg))
Px = RBDReference._so3_skew(phi_neg)
Rx = RBDReference._so3_skew(rho)
Px2 = Px @ Px
Rx_Px = Rx @ Px
Px_Rx = Px @ Rx
Px_Rx_Px = Px @ Rx @ Px
Px2_Rx = Px2 @ Rx
Rx_Px2 = Rx @ Px2
if theta < 1e-4:
# Small-angle Taylor: closed-form formula loses precision below
# this threshold because the c3 numerator becomes catastrophically
# cancellation-prone (subtracts two near-equal O(theta^3) values).
sola = (0.5 * Rx
+ (1.0 / 6.0) * (Px_Rx + Rx_Px + Px_Rx_Px)
- (1.0 / 24.0) * (Px2_Rx + Rx_Px2 - 3.0 * Px_Rx_Px))
return -sola
c1 = (theta - np.sin(theta)) / (theta ** 3)
c2 = (1.0 - 0.5 * theta * theta - np.cos(theta)) / (theta ** 4)
# 4th-order coefficient. The sign here is negative: matching Barfoot's
# SE(3) Q-block (2θ−3sinθ+θcosθ)/(2θ⁵) requires negating this grouping.
# Verified against pin.dIntegrate(ARG1) to ~1e-14 across increment
# magnitudes; the previous (positive) sign matched only for tiny v_dt
# and diverged as O(|v_dt|) for larger increments.
c3 = -0.5 * (c2 - 3.0 * (theta - np.sin(theta) - (theta ** 3) / 6.0) / (theta ** 5))
sola = (0.5 * Rx
+ c1 * (Px_Rx + Rx_Px + Px_Rx_Px)
- c2 * (Px2_Rx + Rx_Px2 - 3.0 * Px_Rx_Px)
+ c3 * (Px @ Rx_Px2 + Px2 @ Rx_Px))
return -sola
@staticmethod
def _so3_exp(phi):
"""SO(3) exponential: returns the 3x3 rotation matrix R = exp([phi]_x)."""
phi = np.asarray(phi, dtype=np.float64)
theta = float(np.linalg.norm(phi))
Px = RBDReference._so3_skew(phi)
if theta < 1e-8:
return np.eye(3) + Px + 0.5 * (Px @ Px)
sin_t = np.sin(theta) / theta
one_minus_cos = (1.0 - np.cos(theta)) / (theta * theta)
return np.eye(3) + sin_t * Px + one_minus_cos * (Px @ Px)
def _spherical_retract(self, quat_xyzw, omega_dt):
"""SO(3) retract of a spherical joint's unit quaternion:
q_new = q ⊗ exp(½·omega_dt), renormalized. Reuses the floating-base
quaternion primitives (`_quat_exp_from_half_omega`, `_quat_mul_xyzw`)
so there is ONE quaternion-exp implementation. `omega_dt` is the
body-frame angular increment (Pinocchio JointModelSpherical v ordering).
"""
delta_quat = self._quat_exp_from_half_omega(0.5 * np.asarray(omega_dt))
return self._normalize_xyzw_quaternion(
self._quat_mul_xyzw(np.asarray(quat_xyzw, dtype=np.float64), delta_quat)
)
def _joint_retract_specs(self):
"""Yield (jtype, q_slice, v_slice) for each joint that needs a special
(non-vector-add) retract: the floating ROOT (SE(3), 7q/6v prefix) and
every SPHERICAL joint (SO(3), 4q/3v block). All other joints retract by
plain vector add and are handled by the q+v_dt fallback.
"""
specs = []
for joint in self.robot.get_joints_ordered_by_id():
jid = joint.get_id()
jtype = getattr(joint, "jtype", None)
if self.robot.floating_base and jid == 0:
specs.append(("floating",
self._as_index_list(self.robot.get_joint_index_q(jid)),
self._as_index_list(self.robot.get_joint_index_v(jid))))
elif jtype == "spherical" and not getattr(joint, "is_mimic", False):
specs.append(("spherical",
self._as_index_list(self.robot.get_joint_index_q(jid)),
self._as_index_list(self.robot.get_joint_index_v(jid))))
return specs
def integrate(self, q, v_dt):
"""Lie-group retract: q_new = q ⊕ v_dt.
Per-joint dispatch matching Pinocchio's `pin.integrate(model, q, v_dt)`:
- vector-space joints (revolute/prismatic/...): q_new = q + v_dt.
- floating ROOT: the 7q/6v prefix drives an SE(3) exponential update
of the position + xyzw-quaternion.
- SPHERICAL joint: its 4q (unit quaternion) / 3v block drives an SO(3)
quaternion exponential, q_new = q ⊗ exp(½·omega_dt).
Convention (user-facing, matches Pinocchio):
q = [..., pos(3), quat_xyzw(4), ..., joint_q, ...] size nq
v_dt = [..., v_lin*dt(3), omega*dt(3), ..., joint_v*dt, ...] size nv
q_new is in the same convention as q. Quaternion convention: xyzw.
"""
q = np.asarray(q, dtype=np.float64).copy()
v_dt = np.asarray(v_dt, dtype=np.float64)
specs = self._joint_retract_specs()
if not specs:
return q + v_dt
# Start from the plain vector add, then OVERWRITE each manifold block.
q_new = q.copy()
# Vector-add every v-slot into its q-slot; manifold blocks below replace
# their own q-block (the floating quat/pos and each spherical quat).
for joint in self.robot.get_joints_ordered_by_id():
jid = joint.get_id()
if self.robot.floating_base and jid == 0:
continue
if getattr(joint, "jtype", None) == "spherical" and not getattr(joint, "is_mimic", False):
continue
iq = self._as_index_list(self.robot.get_joint_index_q(jid))
iv = self._as_index_list(self.robot.get_joint_index_v(jid))
q_new[iq] = q[iq] + v_dt[iv]
for jtype, iq, iv in specs:
if jtype == "floating":
# SE(3) free-flyer prefix (Pinocchio v_dt order [v_lin; omega]).
rho = v_dt[iv][0:3]
phi = v_dt[iv][3:6]
V = self._so3_V_matrix(phi)
p_delta_local = V @ rho
R_old = self._rotation_from_quat_xyzw(q[iq][3:7])
q_new[iq[0:3]] = q[iq][0:3] + R_old @ p_delta_local
q_new[iq[3:7]] = self._spherical_retract(q[iq][3:7], phi)
else: # spherical
q_new[iq] = self._spherical_retract(q[iq], v_dt[iv])
return q_new
@staticmethod
def _so3_log_from_quat_xyzw(quat_xyzw):
"""SO(3) log: the rotation vector phi with exp(phi) == R(quat).
Canonical branch |phi| <= pi via the w-sign fold (q and -q are the same
rotation; folding to w >= 0 matches Pinocchio `difference` and GLASS
quat_log)."""
x, y, z, w = np.asarray(quat_xyzw, dtype=np.float64)
if w < 0.0:
x, y, z, w = -x, -y, -z, -w
vec_norm = float(np.sqrt(x * x + y * y + z * z))
if vec_norm < 1e-12:
return np.array([2.0 * x, 2.0 * y, 2.0 * z], dtype=np.float64)
angle = 2.0 * np.arctan2(vec_norm, w)
return (angle / vec_norm) * np.array([x, y, z], dtype=np.float64)
def _spherical_difference(self, quat_from, quat_to):
"""Inverse of _spherical_retract: omega_dt with
_spherical_retract(quat_from, omega_dt) == quat_to (up to sign)."""
qf = np.asarray(quat_from, dtype=np.float64)
q_rel = self._quat_mul_xyzw(
np.array([-qf[0], -qf[1], -qf[2], qf[3]]), np.asarray(quat_to, dtype=np.float64))
return self._so3_log_from_quat_xyzw(q_rel)
def difference(self, q_from, q_to):
"""Lie-group boxminus: the tangent v (size nv) with
integrate(q_from, v) == q_to. Matches pin.difference(model, q1, q2):
- vector-space joints: q_to - q_from.
- floating ROOT: SE(3) log of the relative pose — phi from the
relative quaternion (canonical branch), rho = V(phi)^-1 @ R_from^T
@ (p_to - p_from) (the exact inverse of integrate's
p_new = p + R_from @ V(phi) @ rho).
- SPHERICAL joints: SO(3) log of the relative quaternion.
Same user-facing conventions as `integrate` (xyzw quats, floating
tangent order [v_lin; omega])."""
q1 = np.asarray(q_from, dtype=np.float64)
q2 = np.asarray(q_to, dtype=np.float64)
nv = self.robot.get_num_vel()
specs = self._joint_retract_specs()
if not specs:
return q2 - q1
v = np.zeros(nv, dtype=np.float64)
for joint in self.robot.get_joints_ordered_by_id():
jid = joint.get_id()
if self.robot.floating_base and jid == 0:
continue
if getattr(joint, "jtype", None) == "spherical" and not getattr(joint, "is_mimic", False):
continue
if getattr(joint, "is_mimic", False):
continue
iq = self._as_index_list(self.robot.get_joint_index_q(jid))
iv = self._as_index_list(self.robot.get_joint_index_v(jid))
v[iv] = q2[iq] - q1[iq]
for jtype, iq, iv in specs:
if jtype == "floating":
phi = self._spherical_difference(q1[iq][3:7], q2[iq][3:7])
R_from = self._rotation_from_quat_xyzw(q1[iq][3:7])
p_delta_local = R_from.T @ (q2[iq[0:3]] - q1[iq[0:3]])
rho = np.linalg.solve(self._so3_V_matrix(phi), p_delta_local)
v[iv[0:3]] = rho
v[iv[3:6]] = phi
else: # spherical
v[iv] = self._spherical_difference(q1[iq], q2[iq])
return v
def dDifference(self, q_from, q_to, with_respect_to):
"""Tangent-space (nv, nv) Jacobian of `difference(q_from, q_to)`.
`with_respect_to` is 'from' or 'to' (Pinocchio's ARG0 / ARG1). Pure
composition via the retract identity
integrate(q_from, e) == q_to with e = difference(q_from, q_to):
'to' : dIntegrate(q_from, e, 'v')^-1
'from' : -dIntegrate(q_from, e, 'v')^-1 @ dIntegrate(q_from, e, 'q')
Matches pin.dDifference(model, q1, q2, ARG0/ARG1). Fixed base
collapses to +I / -I.
"""
if with_respect_to not in ("from", "to"):
raise ValueError("with_respect_to must be 'from' or 'to'")
e = self.difference(q_from, q_to)
M_inv = np.linalg.inv(self.dIntegrate(q_from, e, "v"))
if with_respect_to == "to":
return M_inv
return -M_inv @ self.dIntegrate(q_from, e, "q")
def dIntegrate(self, q, v_dt, with_respect_to):
"""Return the (nv, nv) Jacobian of `integrate(q, v_dt)` in tangent
space. `with_respect_to` is 'q' or 'v' (Pinocchio's ARG0 / ARG1 — ARG1
is the Jacobian w.r.t. the v_dt argument, not v itself).
Block-diagonal per joint:
- vector-space joints: identity.
- floating ROOT (Pinocchio order [v_lin; omega]):
ARG_q : Ad(exp(-v_dt)) — SE(3) adjoint of the inverse exponential
ARG_v : SE(3) right-Jacobian J_r(v_dt) with the Q(rho,phi) coupling
- SPHERICAL joint (omega-only SO(3) restriction of the free-flyer):
ARG_q : R_inv = exp(-omega_dt)
ARG_v : J_r(omega_dt) (SO(3) right Jacobian)
"""
del q # unused for the closed-form retracts
nv = self.robot.get_num_vel()
J = np.eye(nv)
v_dt = np.asarray(v_dt, dtype=np.float64)
for jtype, iq, iv in self._joint_retract_specs():
if jtype == "floating":
rho = v_dt[iv][0:3]
phi = v_dt[iv][3:6]
blk = self._se3_dintegrate_block(rho, phi, with_respect_to)
ix = np.ix_(iv, iv)
J[ix] = blk
else: # spherical: omega-only SO(3) restriction
phi = v_dt[iv] # omega * dt
ix = np.ix_(iv, iv)
if with_respect_to == "q":
J[ix] = self._so3_exp(-phi)
elif with_respect_to == "v":
J[ix] = self._so3_right_jacobian(phi)
else:
raise ValueError("with_respect_to must be 'q' or 'v'")
return J
def _se3_dintegrate_block(self, rho, phi, with_respect_to):
"""The 6x6 free-flyer dIntegrate block (Pinocchio order [v_lin; omega])."""
blk = np.eye(6)
if with_respect_to == "q":
R_inv = self._so3_exp(-phi)
V_neg = self._so3_V_matrix(-phi)
p_inv = -V_neg @ rho
P_inv_x = self._so3_skew(p_inv)
blk[0:3, 0:3] = R_inv
blk[0:3, 3:6] = P_inv_x @ R_inv
blk[3:6, 0:3] = 0.0
blk[3:6, 3:6] = R_inv
return blk
if with_respect_to == "v":
J_r = self._so3_right_jacobian(phi)
Q = self._se3_Q_block(rho, phi)
blk[0:3, 0:3] = J_r
blk[0:3, 3:6] = Q
blk[3:6, 0:3] = 0.0
blk[3:6, 3:6] = J_r
return blk
raise ValueError("with_respect_to must be 'q' or 'v'")
def d2Integrate(self, q, v_dt, arg1, arg2, fd_step=1e-3):
"""Second-order Lie-group retract derivative: the tangent-space
derivative of the `dIntegrate` Jacobian.
Returns ``H`` of shape ``(nv, nv, nv)`` with
H[i, j, k] = d/dxi_k ( dIntegrate(q, v_dt, arg1)[i, j] )
where ``xi_k`` is a unit tangent perturbation in the ``arg2``
coordinate:
* ``arg2 == 'v'`` -> additive perturbation of the increment,
``v_dt -> v_dt + h * e_k``;
* ``arg2 == 'q'`` -> right group perturbation of the base,
``q -> integrate(q, h * e_k)``.
``arg1, arg2`` each in ``{'q', 'v'}`` (Pinocchio ARG0/ARG1
semantics). This is the building block for the second-order
sensitivity of the time-integration step (e.g. the floating-base
position rows of a `plant_step_hessian`).
Structure for the project manifest (free-flyer + revolute joints,
optional fixed base):
* Fixed-base -> identically zero (`integrate` is affine, its
Jacobian constant).
* The free-flyer `dIntegrate` blocks depend only on ``v_dt`` and
never on the base ``q`` (see `dIntegrate`, which discards
``q``), so every ``arg2 == 'q'`` tensor is exactly zero and the
only nonzero entries live in the 6x6 free-flyer block over
``arg2 == 'v'``.
* Revolute rows/cols are linear in the increment -> zero.
Implementation note: evaluated by 4th-order (Richardson) central
finite differencing of the pinocchio-validated `dIntegrate`
(float64, ~1e-8 accurate). The default ``fd_step`` of 1e-3 is
deliberately *not* tiny: it sits near the roundoff/truncation
optimum for the 4th-order stencil (h ~ eps**(1/5)) and, crucially,
keeps the perturbed increments clear of the ill-conditioned
small-angle regime of `dIntegrate`'s exact (1-cos)/theta^2 and
SE(3) Q-block formulas (cf. the theta < 1e-4 guard in
`_se3_Q_block`), which dominate the error as v_dt -> 0. A
hand-rolled closed form is deferred
until a GPU/codegen consumer for the floating-base position-row
Hessian exists; the only current consumers are float64 oracles,
where exact-to-1e-9 differencing of an already-exact Jacobian is
indistinguishable from a closed form. See
docs/open-tasks/f1_plant_step_hessian_plan.md (floating-q rows).
"""
if arg1 not in ("q", "v") or arg2 not in ("q", "v"):
raise ValueError("arg1 and arg2 must each be 'q' or 'v'")
nv = self.robot.get_num_vel()
H = np.zeros((nv, nv, nv), dtype=np.float64)
if not self.robot.floating_base:
return H
if arg2 == "q":
# The free-flyer dIntegrate blocks are independent of the base
# q, so this derivative is exactly zero.
return H
# ANALYTIC (2026-07-27): closed-form tangent derivative of dIntegrate,
# per-joint. Free-flyer 6x6 block over its own 6 v-directions; each
# SPHERICAL joint's 3x3 SO(3) block over its 3. Structured chain rule
# (coeff'(theta)·phi_k/theta + coeff·d(skew-products)), small-angle Taylor
# below theta=0.2. Validated to ~1e-14 vs an mpmath complex-step ground
# truth across theta in [1e-9, 2]. See docs/open-tasks/plan_phase3_*.
v_dt = np.asarray(v_dt, dtype=np.float64)
for jtype, _iq, iv in self._joint_retract_specs():
if jtype == "floating":
rho = v_dt[iv][0:3]
phi = v_dt[iv][3:6]
for kk in range(6):
blk = self._d2_se3_dblock(rho, phi, arg1, kk) # 6x6
for a in range(6):
for b in range(6):
H[iv[a], iv[b], iv[kk]] = blk[a, b]
else: # spherical: omega-only SO(3) restriction (3x3 over 3 dirs)
phi = v_dt[iv]
for k in range(3):
d3 = (self._d2_se3_dRinv(phi, k) if arg1 == "q"
else self._d2_se3_dJr(phi, k))
for a in range(3):
for b in range(3):
H[iv[a], iv[b], iv[k]] = d3[a, b]
return H
def _d2Integrate_fd(self, q, v_dt, arg1, arg2, fd_step=1e-3):
"""Legacy 4th-order central-FD d2Integrate, retained as a cross-check for
the analytic `d2Integrate`. Same signature/semantics."""
nv = self.robot.get_num_vel()
H = np.zeros((nv, nv, nv), dtype=np.float64)
if not self.robot.floating_base or arg2 == "q":
return H
v_dt = np.asarray(v_dt, dtype=np.float64)
def J_at(delta):
return self.dIntegrate(q, v_dt + delta, arg1)
h = float(fd_step)
for k in range(nv):
e = np.zeros(nv, dtype=np.float64)
e[k] = 1.0
d1 = J_at(h * e) - J_at(-h * e)
d2 = J_at(2.0 * h * e) - J_at(-2.0 * h * e)
H[:, :, k] = (8.0 * d1 - d2) / (12.0 * h)
return H
# ---- Analytic d2Integrate building blocks (SE(3)-exp 2nd derivatives) ----
# Coefficient value/derivative as functions of theta; exact above _D2_TH,
# small-angle Taylor below (odd series for derivatives). See plan doc.
_D2_TH = 0.2
_D2_VAL_SERIES = { # even series in theta^2 (coefficient VALUE)
'a': [1/2, -1/24, 1/720, -1/40320, 1/3628800],
'b': [1/6, -1/120, 1/5040, -1/362880, 1/39916800],
's': [1.0, -1/6, 1/120, -1/5040, 1/362880],
'c2': [-1/24, 1/720, -1/40320, 1/3628800, -1/479001600],
'c3': [1/120, -1/2520, 1/120960, -1/9979200, 1/1245404160],
}
_D2_DER_SERIES = { # odd series (coefficient DERIVATIVE): sum coeff*theta^(2i+1)
'a': [-1/12, 1/180, -1/6720, 1/453600],
'b': [-1/60, 1/1260, -1/60480, 1/4989600],
's': [-1/3, 1/30, -1/840, 1/45360],
'c2': [1/360, -1/10080, 1/604800, -1/59875200],
'c3': [-1/1260, 1/30240, -1/1663200, 1/155675520],
}
@staticmethod
def _d2_coef(name, t):
"""SE(3)-exp coefficient VALUE (a,b/c1,s,c2,c3) at theta=t."""
if name == 'c1':
name = 'b'
if abs(t) >= RBDReference._D2_TH:
if name == 'a': return (1.0 - np.cos(t)) / (t * t)
if name == 'b': return (t - np.sin(t)) / t**3
if name == 's': return np.sin(t) / t
if name == 'c2': return (1.0 - 0.5 * t * t - np.cos(t)) / t**4
if name == 'c3': return -0.5 * ((1.0 - 0.5 * t * t - np.cos(t)) / t**4
- 3.0 * (t - np.sin(t) - t**3 / 6.0) / t**5)
r, p, t2 = 0.0, 1.0, t * t
for c in RBDReference._D2_VAL_SERIES[name]:
r += c * p; p *= t2
return r
@staticmethod
def _d2_coef_der(name, t):
"""SE(3)-exp coefficient DERIVATIVE d(coef)/d(theta) at theta=t."""
if name == 'c1':
name = 'b'
if abs(t) >= RBDReference._D2_TH:
if name == 'a': return (t * np.sin(t) - 2.0 * (1.0 - np.cos(t))) / t**3
if name == 'b': return ((1.0 - np.cos(t)) * t - 3.0 * (t - np.sin(t))) / t**4
if name == 's': return (t * np.cos(t) - np.sin(t)) / (t * t)
if name == 'c2': return (t * np.sin(t) + t * t + 4.0 * np.cos(t) - 4.0) / t**5
if name == 'c3':
c2p = (t * np.sin(t) + t * t + 4.0 * np.cos(t) - 4.0) / t**5
return -0.5 * (c2p - 3.0 * (-4.0 * t - t * np.cos(t)
+ 5.0 * np.sin(t) + t**3 / 3.0) / t**6)
r, p, t2 = 0.0, t, t * t
for c in RBDReference._D2_DER_SERIES[name]:
r += c * p; p *= t2
return r
@staticmethod
def _d2_dcoef_dphi(name, phi, k):
"""d(coef(theta))/d(phi_k) = coef'(theta) * (phi_k / theta)."""
t = float(np.sqrt(phi @ phi))
if t == 0.0:
return 0.0
return RBDReference._d2_coef_der(name, t) * (phi[k] / t)
@staticmethod
def _d2_se3_dJr(phi, k):
"""d Jr(phi) / d phi_k (3x3). Also the spherical ARG_v derivative."""
S = RBDReference._so3_skew(phi); Ek = RBDReference._so3_skew(np.eye(3)[k])
t = float(np.sqrt(phi @ phi))
a = RBDReference._d2_coef('a', t); b = RBDReference._d2_coef('b', t)
return (-RBDReference._d2_dcoef_dphi('a', phi, k) * S - a * Ek
+ RBDReference._d2_dcoef_dphi('b', phi, k) * (S @ S) + b * (Ek @ S + S @ Ek))
@staticmethod
def _d2_se3_dRinv(phi, k):
"""d exp(-phi) / d phi_k (3x3). Also the spherical ARG_q derivative."""
S = RBDReference._so3_skew(phi); Ek = RBDReference._so3_skew(np.eye(3)[k])
t = float(np.sqrt(phi @ phi))
s = RBDReference._d2_coef('s', t); a = RBDReference._d2_coef('a', t)
return (-RBDReference._d2_dcoef_dphi('s', phi, k) * S - s * Ek
+ RBDReference._d2_dcoef_dphi('a', phi, k) * (S @ S) + a * (Ek @ S + S @ Ek))
@staticmethod
def _d2_se3_dQ(rho, phi, kk):
"""d Q(rho,phi) / d w_kk (kk<3 -> rho, else phi). Q = -sola."""
Px = RBDReference._so3_skew(-phi); Rx = RBDReference._so3_skew(rho)
t = float(np.sqrt(phi @ phi)); Px2 = Px @ Px
c1 = RBDReference._d2_coef('c1', t); c2 = RBDReference._d2_coef('c2', t)
c3 = RBDReference._d2_coef('c3', t)
if kk < 3: # d/d rho_kk: Q linear in rho -> substitute Rx -> skew(e_kk)
Ek = RBDReference._so3_skew(np.eye(3)[kk])
A1 = Px @ Ek + Ek @ Px + Px @ Ek @ Px
A2 = Px2 @ Ek + Ek @ Px2 - 3.0 * (Px @ Ek @ Px)
A3 = Px @ Ek @ Px2 + Px2 @ Ek @ Px
return -(0.5 * Ek + c1 * A1 - c2 * A2 + c3 * A3)
k = kk - 3 # d/d phi_k: Px=-Sphi -> dPx=-E_k, c-coeffs via chain
dPx = -RBDReference._so3_skew(np.eye(3)[k]); dPx2 = dPx @ Px + Px @ dPx
A1 = Px @ Rx + Rx @ Px + Px @ Rx @ Px
A2 = Px2 @ Rx + Rx @ Px2 - 3.0 * (Px @ Rx @ Px)
A3 = Px @ Rx @ Px2 + Px2 @ Rx @ Px
dA1 = dPx @ Rx + Rx @ dPx + (dPx @ Rx @ Px + Px @ Rx @ dPx)
dA2 = dPx2 @ Rx + Rx @ dPx2 - 3.0 * (dPx @ Rx @ Px + Px @ Rx @ dPx)
dA3 = (dPx @ Rx @ Px2 + Px @ Rx @ dPx2) + (dPx2 @ Rx @ Px + Px2 @ Rx @ dPx)
dc1 = RBDReference._d2_dcoef_dphi('c1', phi, k)
dc2 = RBDReference._d2_dcoef_dphi('c2', phi, k)
dc3 = RBDReference._d2_dcoef_dphi('c3', phi, k)
return -(dc1 * A1 + c1 * dA1 - dc2 * A2 - c2 * dA2 + dc3 * A3 + c3 * dA3)
@staticmethod
def _d2_se3_doff(rho, phi, kk):
"""d off / d w_kk, off = skew(-Jr@rho) @ exp(-phi) (ARG_q coupling)."""
Jr = np.eye(3) - RBDReference._d2_coef('a', float(np.sqrt(phi @ phi))) * RBDReference._so3_skew(phi) \
+ RBDReference._d2_coef('b', float(np.sqrt(phi @ phi))) * (RBDReference._so3_skew(phi) @ RBDReference._so3_skew(phi))
R = np.eye(3) - RBDReference._d2_coef('s', float(np.sqrt(phi @ phi))) * RBDReference._so3_skew(phi) \
+ RBDReference._d2_coef('a', float(np.sqrt(phi @ phi))) * (RBDReference._so3_skew(phi) @ RBDReference._so3_skew(phi))
if kk < 3: # p_inv=-Jr@rho linear in rho -> dp=-Jr[:,kk], dR=0
return RBDReference._so3_skew(-Jr[:, kk]) @ R
k = kk - 3
p_inv = -Jr @ rho
dp = -RBDReference._d2_se3_dJr(phi, k) @ rho
return RBDReference._so3_skew(dp) @ R + RBDReference._so3_skew(p_inv) @ RBDReference._d2_se3_dRinv(phi, k)
@staticmethod
def _d2_se3_dblock(rho, phi, arg1, kk):
"""The 6x6 d(dIntegrate block)/d w_kk for the free-flyer, arg1 in {q,v}."""
blk = np.zeros((6, 6))
if arg1 == "v":
dJ = RBDReference._d2_se3_dJr(phi, kk - 3) if kk >= 3 else np.zeros((3, 3))
blk[0:3, 0:3] = dJ; blk[3:6, 3:6] = dJ
blk[0:3, 3:6] = RBDReference._d2_se3_dQ(rho, phi, kk)
else: # arg1 == "q"
dR = RBDReference._d2_se3_dRinv(phi, kk - 3) if kk >= 3 else np.zeros((3, 3))
blk[0:3, 0:3] = dR; blk[3:6, 3:6] = dR
blk[0:3, 3:6] = RBDReference._d2_se3_doff(rho, phi, kk)
return blk
# ----- Multi-integrator one-step time-integration -----
@staticmethod
def _integrator_butcher(integrator_type: str):
"""Return (c_list, b_list) for the given integrator.
Each noninitial stage retracts from the original q using the preceding
stage velocity, and updates the original v using the preceding stage
acceleration. b weights BOTH stage velocities and accelerations.
Lengths: len(c_list) = N-1, len(b_list) = N (N = stage count).
"""
if integrator_type == "euler":
return [], [1.0]
if integrator_type in ("semi_implicit_euler", "constant_acceleration"):
return [], [1.0]
if integrator_type == "midpoint":
return [0.5], [0.0, 1.0]
if integrator_type == "trapezoidal": # explicit trapezoidal / Heun
return [1.0], [0.5, 0.5]
if integrator_type == "rk4":
return [0.5, 0.5, 1.0], [1.0 / 6.0, 2.0 / 6.0, 2.0 / 6.0, 1.0 / 6.0]
raise ValueError(f"Unknown integrator_type: {integrator_type}")
def integrator(self, q, qd, u, dt, integrator_type: str = "euler", f_ext=None):
"""One time-integration step: x_{k+1} = integrator(x_k, u_k, dt).
Returns x_kp1 of shape (nq + nv,) — concatenated [q_new, v_new] in the
user-facing q/v convention. Supports 'euler', 'semi_implicit_euler',
'constant_acceleration', 'trapezoidal' (explicit Heun), 'midpoint',
and 'rk4'. For floating-base robots the
q-update uses `self.integrate` (Lie-group retract); for Euclidean
scalar-joint configurations this collapses to `q + dt*v`.
Full-state stages use the preceding stage velocity and acceleration.
Midpoint/Heun have order two and RK4 order four on Euclidean fixed-base
configurations. On rotational manifolds these base-point retractions
generally give only order two, including the scheme named rk4; no
Munthe-Kaas corrections are applied. u and f_ext are constant per step.
`f_ext` (optional, body-major local-frame [angular; linear], one 6-vector
per body) is threaded into every forward-dynamics evaluation so the step
is taken at the f_ext-perturbed operating point (matches GRiD threading
d_f_ext through the integrator's FD inner). None => no external forces.
"""
c_list, b_list = self._integrator_butcher(integrator_type)
q = np.asarray(q, dtype=np.float64)
qd = np.asarray(qd, dtype=np.float64)
u = np.asarray(u, dtype=np.float64)
qdd1 = np.asarray(self.forward_dynamics(q, qd, u, f_ext=f_ext)).reshape(-1)
if integrator_type == "euler":
q_new = self.integrate(q, dt * qd)
v_new = qd + dt * qdd1
return np.concatenate([q_new, v_new])
if integrator_type == "semi_implicit_euler":
v_new = qd + dt * qdd1
q_new = self.integrate(q, dt * v_new)
return np.concatenate([q_new, v_new])
if integrator_type == "constant_acceleration":
# Single-stage constant acceleration: v reads qdd Euler-style; q
# retracts the combined tangent dt*qd + 0.5*dt^2*qdd in ONE step. For
# fixed-base this is q + dt*qd + 0.5*dt^2*qdd; for floating-base
# self.integrate applies the SE(3) Lie retract of the combined tangent.
v_new = qd + dt * qdd1
q_new = self.integrate(q, dt * qd + 0.5 * dt * dt * qdd1)
return np.concatenate([q_new, v_new])
# Full-state stages, each retracted from the original configuration.
N = len(b_list)
qdd_list = [qdd1]
qd_list = [qd]
prev_qdd = qdd1
for stage_idx in range(1, N):
c_prev = c_list[stage_idx - 1]
p_q = self.integrate(q, c_prev * dt * qd_list[-1])
p_qd = qd + c_prev * dt * prev_qdd
stage_qdd = np.asarray(self.forward_dynamics(p_q, p_qd, u, f_ext=f_ext)).reshape(-1)
qdd_list.append(stage_qdd)
qd_list.append(p_qd)
prev_qdd = stage_qdd
accel = sum(b * qdd for b, qdd in zip(b_list, qdd_list))
velocity = sum(b * v for b, v in zip(b_list, qd_list))
q_new = self.integrate(q, dt * velocity)
v_new = qd + dt * accel
return np.concatenate([q_new, v_new])
def integrator_gradient(self, q, qd, u, dt, integrator_type: str = "euler", f_ext=None):
"""Return [A | B] of shape (2*nv, 3*nv) — the Jacobian of the integrator
step in tangent space. Column order is [d/dq | d/dqd | d/du] where
d/dq is the nv-tangent perturbation of q (NOT the nq scalar
perturbation). For fixed-base this matches the historical layout.
`f_ext` (optional, body-major local-frame) is threaded into every FD value
and FD-gradient evaluation, so the gradient is linearized at the
f_ext-perturbed operating point (matches GRiD threading d_f_ext into the
integrator gradient's vaf/ID linearization). None => no external forces.
"""
c_list, b_list = self._integrator_butcher(integrator_type)
if c_list and self.robot.robot_has_spherical():
raise NotImplementedError("Spherical-joint multi-stage integrator gradients are unsupported")
q = np.asarray(q, dtype=np.float64)
qd = np.asarray(qd, dtype=np.float64)
u = np.asarray(u, dtype=np.float64)
nv = self.robot.get_num_vel()
I_n = np.eye(nv)
Z_n = np.zeros((nv, nv))
def fd_grad_at(pq, pqd):
J_qq, J_qv = self.forward_dynamics_gradient(pq, pqd, u, f_ext=f_ext)
return (np.asarray(J_qq, dtype=np.float64),
np.asarray(J_qv, dtype=np.float64),
np.asarray(self.minv(pq), dtype=np.float64))
# dq_block / dv_block: (nv x nv) Jacobians of q_new w.r.t. q and v
# respectively, evaluated at v_dt. For fixed-base both reduce to
# identity (q_block) and dt*I (v_block); for floating-base they
# involve the SO(3) right-Jacobian on the free-flyer block.
def q_top_blocks(v_dt_arg):
dInt_q = self.dIntegrate(q, v_dt_arg, "q")
dInt_v = self.dIntegrate(q, v_dt_arg, "v")
return dInt_q, dInt_v
if integrator_type == "euler":
J_qq, J_qv, Minv = fd_grad_at(q, qd)
dInt_q, dInt_v = q_top_blocks(dt * qd)
top = np.hstack([dInt_q, dt * dInt_v, Z_n])
bottom = np.hstack([dt * J_qq, I_n + dt * J_qv, dt * Minv])
return np.vstack([top, bottom])
if integrator_type == "semi_implicit_euler":
J_qq, J_qv, Minv = fd_grad_at(q, qd)
# v_new = qd + dt*qdd(q, qd, u); q_new = integrate(q, dt*v_new)
# ∂v_new/∂q = dt*J_qq
# ∂v_new/∂qd = I + dt*J_qv
# ∂v_new/∂u = dt*Minv
# ∂q_new/∂q = dInt_q(dt*v_new) + dt*dInt_v(dt*v_new) @ ∂v_new/∂q
# ∂q_new/∂qd = dt*dInt_v(dt*v_new) @ ∂v_new/∂qd
# ∂q_new/∂u = dt*dInt_v(dt*v_new) @ ∂v_new/∂u
qdd_si = np.asarray(self.forward_dynamics(q, qd, u, f_ext=f_ext)).reshape(-1)
v_new = qd + dt * qdd_si
dInt_q, dInt_v = q_top_blocks(dt * v_new)
dvdq = dt * J_qq
dvdv = I_n + dt * J_qv
dvdu = dt * Minv
dt_dInt_v = dt * dInt_v
top = np.hstack([dInt_q + dt_dInt_v @ dvdq,
dt_dInt_v @ dvdv,
dt_dInt_v @ dvdu])
bottom = np.hstack([dvdq, dvdv, dvdu])
return np.vstack([top, bottom])
if integrator_type == "constant_acceleration":
# v_new = qd + dt*qdd(q,qd,u); q_new = integrate(q, dt*qd + dt2h*qdd),
# dt2h = 0.5*dt*dt. Bottom (v) rows match Euler/SI (dt*); the top (q)
# rows weight the FD gradient by dt2h (the +0.5*dt^2*qdd accel term).
# General for both bases: dInt_q/dInt_v collapse to I/dt*I fixed-base,
# and carry the SE(3) free-flyer blocks (at tangent w) floating-base.
J_qq, J_qv, Minv = fd_grad_at(q, qd)
qdd_t = np.asarray(self.forward_dynamics(q, qd, u, f_ext=f_ext)).reshape(-1)
dt2h = 0.5 * dt * dt
w = dt * qd + dt2h * qdd_t
dInt_q, dInt_v = q_top_blocks(w)
# q_new = integrate(q, w): dw/dq = dt2h*J_qq, dw/dqd = dt*I + dt2h*J_qv,
# dw/du = dt2h*Minv. top = dInt_q + dInt_v @ dw/d*.
dwdq = dt2h * J_qq
dwdv = dt * I_n + dt2h * J_qv
dwdu = dt2h * Minv
top = np.hstack([dInt_q + dInt_v @ dwdq,
dInt_v @ dwdv,
dInt_v @ dwdu])
bottom = np.hstack([dt * J_qq, I_n + dt * J_qv, dt * Minv])
return np.vstack([top, bottom])
# Full-state chain rule for Heun / midpoint / RK4.
N = len(b_list)
qdd_list = []
D_qdd_list = []
qd_list = [qd]
E_q = np.hstack([I_n, Z_n, Z_n])
E_v = np.hstack([Z_n, I_n, Z_n])
D_qd_list = [E_v]
# Stage 1: FD at the original (q, qd).
qdd_list.append(np.asarray(self.forward_dynamics(q, qd, u, f_ext=f_ext)).reshape(-1))
J_qq, J_qv, Minv = fd_grad_at(q, qd)
D_qdd_list.append(np.hstack([J_qq, J_qv, Minv])) # (nv, 3*nv)
# Both the velocity used in the retract and the stage acceleration
# depend on earlier stages; retain both chains.
for stage_idx in range(1, N):
c_prev = c_list[stage_idx - 1]
prev_qdd = qdd_list[-1]
v_dt_stage = c_prev * dt * qd_list[-1]
p_q = self.integrate(q, v_dt_stage)
p_qd = qd + c_prev * dt * prev_qdd
stage_qdd = np.asarray(self.forward_dynamics(p_q, p_qd, u, f_ext=f_ext)).reshape(-1)
qdd_list.append(stage_qdd)
J_qq_i, J_qv_i, Minv_i = fd_grad_at(p_q, p_qd)
dInt_q_stage, dInt_v_stage = q_top_blocks(v_dt_stage)
dp_q_block = dInt_q_stage @ E_q + c_prev * dt * dInt_v_stage @ D_qd_list[-1]
# ∂p_i.qd / ∂(q, v, u) = [0, I, 0] + c_prev*dt * D_qdd_{i-1}
dp_qd_block = E_v + c_prev * dt * D_qdd_list[-1]
# Compose: D_qdd_i = J_qq_i @ dp_q_block + J_qv_i @ dp_qd_block + Minv_i @ [0|0|I]
d_u_block = np.hstack([Z_n, Z_n, Minv_i])
D_qdd_list.append(J_qq_i @ dp_q_block + J_qv_i @ dp_qd_block + d_u_block)
qd_list.append(p_qd)
D_qd_list.append(dp_qd_block)
sum_b_D = sum(b * D for b, D in zip(b_list, D_qdd_list))
velocity = sum(b * v for b, v in zip(b_list, qd_list))
D_velocity = sum(b * D for b, D in zip(b_list, D_qd_list))
dInt_q_final, dInt_v_final = q_top_blocks(dt * velocity)
top = dInt_q_final @ E_q + dt * dInt_v_final @ D_velocity
bottom = E_v + dt * sum_b_D
return np.vstack([top, bottom])
@staticmethod
def _quat_xyzw_from_rotation_matrix(rot, reference_quat=None):
rot = np.asarray(rot, dtype=np.float64)
trace = np.trace(rot)
if trace > 0.0:
s = np.sqrt(trace + 1.0) * 2.0
w = 0.25 * s
x = (rot[2, 1] - rot[1, 2]) / s
y = (rot[0, 2] - rot[2, 0]) / s
z = (rot[1, 0] - rot[0, 1]) / s
else:
diag = np.diag(rot)
if diag[0] > diag[1] and diag[0] > diag[2]:
s = np.sqrt(1.0 + rot[0, 0] - rot[1, 1] - rot[2, 2]) * 2.0
w = (rot[2, 1] - rot[1, 2]) / s
x = 0.25 * s
y = (rot[0, 1] + rot[1, 0]) / s
z = (rot[0, 2] + rot[2, 0]) / s
elif diag[1] > diag[2]:
s = np.sqrt(1.0 + rot[1, 1] - rot[0, 0] - rot[2, 2]) * 2.0
w = (rot[0, 2] - rot[2, 0]) / s
x = (rot[0, 1] + rot[1, 0]) / s
y = 0.25 * s
z = (rot[1, 2] + rot[2, 1]) / s
else:
s = np.sqrt(1.0 + rot[2, 2] - rot[0, 0] - rot[1, 1]) * 2.0
w = (rot[1, 0] - rot[0, 1]) / s
x = (rot[0, 2] + rot[2, 0]) / s
y = (rot[1, 2] + rot[2, 1]) / s
z = 0.25 * s
quat = RBDReference._normalize_xyzw_quaternion(np.array([x, y, z, w], dtype=np.float64))
if reference_quat is not None:
reference_quat = RBDReference._normalize_xyzw_quaternion(reference_quat)
if np.dot(quat, reference_quat) < 0.0:
quat = -quat
return quat
def cross_operator(self, v):
"""Compute the 6x6 spatial cross product matrix for a velocity vector.
Parameters
----------
v : numpy.ndarray
6D spatial velocity vector.
Returns
-------
v_cross : numpy.ndarray
6x6 spatial cross product matrix.
"""
# for any vector v, computes the operator v x
# vec x = [wx 0]
# [vox wx]
#(crm in spatial_v2_extended)
v_cross = np.array([0, -v[2], v[1], 0, 0, 0,
v[2], 0, -v[0], 0, 0, 0,
-v[1], v[0], 0, 0, 0, 0,
0, -v[5], v[4], 0, -v[2], v[1],
v[5], 0, -v[3], v[2], 0, -v[0],
-v[4], v[3], 0, -v[1], v[0], 0]
).reshape(6,6)
return(v_cross)
def dual_cross_operator(self, v):
"""Compute the 6x6 spatial dual cross product matrix.
Parameters