Skip to main content

phyz_rigid/
aba.rs

1//! Articulated Body Algorithm (ABA) — O(n) forward dynamics.
2//!
3//! Given (q, v, tau), compute qdd (joint accelerations).
4//! Three passes over the kinematic tree:
5//! 1. Forward pass: compute velocities, bias forces
6//! 2. Backward pass: compute articulated inertias, bias forces
7//! 3. Forward pass: compute accelerations
8//!
9//! Supports single-DOF (revolute, prismatic) and multi-DOF (spherical, free) joints.
10
11use phyz_math::SpatialTransform;
12use phyz_math::{DVec, Mat6, SpatialMat, SpatialVec, Vec3, Vec6};
13use phyz_model::{JointType, Model, State};
14
15/// Compute joint velocity contribution S * qd for any joint type.
16fn joint_velocity(joint: &phyz_model::Joint, qd: &[f64]) -> SpatialVec {
17    match joint.joint_type {
18        JointType::Revolute | JointType::Hinge => {
19            SpatialVec::new(joint.axis * qd[0], Vec3::zeros())
20        }
21        JointType::Prismatic | JointType::Slide => {
22            SpatialVec::new(Vec3::zeros(), joint.axis * qd[0])
23        }
24        JointType::Spherical | JointType::Ball => {
25            SpatialVec::new(Vec3::new(qd[0], qd[1], qd[2]), Vec3::zeros())
26        }
27        JointType::Free => SpatialVec::new(
28            Vec3::new(qd[0], qd[1], qd[2]),
29            Vec3::new(qd[3], qd[4], qd[5]),
30        ),
31        JointType::Fixed => SpatialVec::zero(),
32    }
33}
34
35/// Run the Articulated Body Algorithm.
36///
37/// Returns generalized accelerations `qdd` of dimension `model.nv`.
38/// Accepts optional external forces per body (in body frame).
39pub fn aba(model: &Model, state: &State) -> DVec {
40    aba_with_external_forces(model, state, None)
41}
42
43/// Run ABA with optional external spatial forces applied to each body.
44pub fn aba_with_external_forces(
45    model: &Model,
46    state: &State,
47    external_forces: Option<&[SpatialVec]>,
48) -> DVec {
49    let nb = model.nbodies();
50    let mut qdd = DVec::zeros(model.nv);
51
52    // Per-body storage
53    let mut x_tree = vec![SpatialTransform::identity(); nb];
54    let mut vel = vec![SpatialVec::zero(); nb];
55    let mut c_bias = vec![SpatialVec::zero(); nb];
56    let mut p_a = vec![SpatialVec::zero(); nb]; // articulated bias force
57    let mut i_a = vec![SpatialMat::zero(); nb]; // articulated body inertia
58
59    // Gravity as spatial acceleration (base acceleration trick)
60    let a0 = SpatialVec::new(Vec3::zeros(), -model.gravity);
61
62    // ── Pass 1: Forward — velocities and bias ──
63    for i in 0..nb {
64        let body = &model.bodies[i];
65        let joint = &model.joints[body.joint_idx];
66        let q_idx = model.q_offsets[body.joint_idx];
67        let v_idx = model.v_offsets[body.joint_idx];
68        let ndof = joint.ndof();
69
70        // Compute tree transform
71        let x_joint = if ndof == 0 {
72            SpatialTransform::identity()
73        } else {
74            let q_slice = &state.q.as_slice()[q_idx..q_idx + ndof];
75            joint.joint_transform_slice(q_slice)
76        };
77        x_tree[i] = x_joint.compose(&joint.parent_to_joint);
78
79        let v_joint = joint_velocity(joint, &state.v.as_slice()[v_idx..v_idx + ndof]);
80
81        if body.parent < 0 {
82            vel[i] = v_joint;
83            c_bias[i] = SpatialVec::zero();
84        } else {
85            let pi = body.parent as usize;
86            let v_parent = x_tree[i].apply_motion(&vel[pi]);
87            vel[i] = v_parent + v_joint;
88            c_bias[i] = vel[i].cross_motion(&v_joint);
89        }
90
91        // Initialize articulated inertia with rigid body inertia
92        i_a[i] = body.inertia.to_matrix();
93        // Bias force: v ×* (I*v) (gyroscopic)
94        p_a[i] = vel[i].cross_force(&i_a[i].mul_vec(&vel[i]));
95
96        // Subtract external forces from bias (external forces reduce the bias)
97        if let Some(ext) = external_forces {
98            p_a[i] = p_a[i] - ext[i];
99        }
100    }
101
102    // ── Pass 2: Backward — articulated inertias and forces ──
103    for i in (0..nb).rev() {
104        let body = &model.bodies[i];
105        let joint = &model.joints[body.joint_idx];
106        let v_idx = model.v_offsets[body.joint_idx];
107        let ndof = joint.ndof();
108
109        if ndof == 0 {
110            // Fixed joint: just propagate to parent
111            if body.parent >= 0 {
112                let pi = body.parent as usize;
113                let x_mot = x_tree[i].to_motion_matrix();
114                let ia_parent = SpatialMat::from_mat6(x_mot.transpose() * i_a[i].data * x_mot);
115                let p_parent = x_tree[i].inv_apply_force(&p_a[i]);
116                i_a[pi] = i_a[pi] + ia_parent;
117                p_a[pi] = p_a[pi] + p_parent;
118            }
119            continue;
120        }
121
122        if ndof == 1 {
123            // Single-DOF: scalar operations (faster path)
124            let s_i = joint.motion_subspace();
125            let phyz_i = state.ctrl[v_idx] - joint.damping * state.v[v_idx];
126
127            let ia = &i_a[i];
128            let u_i = phyz_i - s_i.dot(&p_a[i]);
129            let d_i = s_i.dot(&ia.mul_vec(&s_i));
130
131            if d_i.abs() < 1e-20 {
132                continue;
133            }
134
135            let u_inv_d = u_i / d_i;
136            let ia_s = ia.mul_vec(&s_i);
137
138            if body.parent >= 0 {
139                let pi = body.parent as usize;
140                let outer = outer_product_6(&ia_s, &ia.mul_vec(&s_i));
141                let ia_new = SpatialMat::from_mat6(ia.data - outer.data / d_i);
142                let p_new = p_a[i] + ia_new.mul_vec(&c_bias[i]) + ia_s * u_inv_d;
143
144                let x_mot = x_tree[i].to_motion_matrix();
145                let ia_parent = SpatialMat::from_mat6(x_mot.transpose() * ia_new.data * x_mot);
146                let p_parent = x_tree[i].inv_apply_force(&p_new);
147
148                i_a[pi] = i_a[pi] + ia_parent;
149                p_a[pi] = p_a[pi] + p_parent;
150            }
151        } else {
152            // Multi-DOF: matrix operations
153            let s_mat = joint.motion_subspace_matrix(); // 6 x ndof
154            let ia = &i_a[i];
155
156            // tau vector for this joint
157            let mut phyz_vec = nalgebra::DVector::<f64>::zeros(ndof);
158            for k in 0..ndof {
159                phyz_vec[k] = state.ctrl[v_idx + k] - joint.damping * state.v[v_idx + k];
160            }
161
162            // U = I_a * S  (6 x ndof)
163            let ia_mat = &ia.data;
164            let u_mat = ia_mat * &s_mat; // 6 x ndof
165
166            // D = S^T * I_a * S  (ndof x ndof)
167            let d_mat = s_mat.transpose() * &u_mat; // ndof x ndof
168
169            // u_vec = tau - S^T * p_a  (ndof x 1)
170            let pa_vec = nalgebra::DVector::from_column_slice(p_a[i].data.as_slice());
171            let u_vec = &phyz_vec - &(s_mat.transpose() * &pa_vec); // ndof x 1
172
173            // D_inv
174            let d_inv = match nalgebra::DMatrix::from_iterator(
175                ndof,
176                ndof,
177                d_mat.iter().cloned(),
178            )
179            .try_inverse()
180            {
181                Some(inv) => inv,
182                None => continue,
183            };
184
185            if body.parent >= 0 {
186                let pi = body.parent as usize;
187
188                // I_a^A = I_a - U * D^{-1} * U^T
189                let u_dinv = &u_mat * &d_inv; // 6 x ndof
190                let ia_new_data = ia_mat - &u_dinv * u_mat.transpose(); // 6 x 6
191                let ia_new = SpatialMat::from_mat6(Mat6::from_iterator(ia_new_data.iter().cloned()));
192
193                // p_a^A = p_a + I_a^A * c + U * D^{-1} * u
194                let dinv_u = &d_inv * &u_vec; // ndof x 1
195                let u_dinv_u = &u_mat * &dinv_u; // 6 x 1
196                let c_vec = c_bias[i].data;
197                let p_new_data = p_a[i].data + ia_new.data * c_vec + Vec6::from_iterator(u_dinv_u.iter().cloned());
198                let p_new = SpatialVec { data: p_new_data };
199
200                let x_mot = x_tree[i].to_motion_matrix();
201                let ia_parent = SpatialMat::from_mat6(x_mot.transpose() * ia_new.data * x_mot);
202                let p_parent = x_tree[i].inv_apply_force(&p_new);
203
204                i_a[pi] = i_a[pi] + ia_parent;
205                p_a[pi] = p_a[pi] + p_parent;
206            }
207        }
208    }
209
210    // ── Pass 3: Forward — accelerations ──
211    let mut acc = vec![SpatialVec::zero(); nb];
212
213    for i in 0..nb {
214        let body = &model.bodies[i];
215        let joint = &model.joints[body.joint_idx];
216        let v_idx = model.v_offsets[body.joint_idx];
217        let ndof = joint.ndof();
218
219        let a_parent = if body.parent < 0 {
220            x_tree[i].apply_motion(&a0)
221        } else {
222            let pi = body.parent as usize;
223            x_tree[i].apply_motion(&acc[pi])
224        };
225
226        if ndof == 0 {
227            acc[i] = a_parent + c_bias[i];
228            continue;
229        }
230
231        if ndof == 1 {
232            let s_i = joint.motion_subspace();
233            let ia = &i_a[i];
234            let d_i = s_i.dot(&ia.mul_vec(&s_i));
235
236            if d_i.abs() < 1e-20 {
237                acc[i] = a_parent + c_bias[i];
238                continue;
239            }
240
241            let phyz_i = state.ctrl[v_idx] - joint.damping * state.v[v_idx];
242            let u_i = phyz_i - s_i.dot(&p_a[i]);
243            let qdd_i = (u_i - ia.mul_vec(&(a_parent + c_bias[i])).dot(&s_i)) / d_i;
244            qdd[v_idx] = qdd_i;
245            acc[i] = a_parent + c_bias[i] + s_i * qdd_i;
246        } else {
247            let s_mat = joint.motion_subspace_matrix();
248            let ia = &i_a[i];
249            let ia_mat = &ia.data;
250
251            let mut phyz_vec = nalgebra::DVector::<f64>::zeros(ndof);
252            for k in 0..ndof {
253                phyz_vec[k] = state.ctrl[v_idx + k] - joint.damping * state.v[v_idx + k];
254            }
255
256            let u_mat = ia_mat * &s_mat;
257            let d_mat = s_mat.transpose() * &u_mat;
258
259            let d_inv = match nalgebra::DMatrix::from_iterator(
260                ndof,
261                ndof,
262                d_mat.iter().cloned(),
263            )
264            .try_inverse()
265            {
266                Some(inv) => inv,
267                None => {
268                    acc[i] = a_parent + c_bias[i];
269                    continue;
270                }
271            };
272
273            let pa_vec = nalgebra::DVector::from_column_slice(p_a[i].data.as_slice());
274            let u_vec = &phyz_vec - &(s_mat.transpose() * &pa_vec);
275
276            let a_total = a_parent + c_bias[i];
277            let a_vec = nalgebra::DVector::from_column_slice(a_total.data.as_slice());
278            let st_ia_a = s_mat.transpose() * (ia_mat * &a_vec);
279
280            let qdd_vec = &d_inv * (&u_vec - &st_ia_a);
281
282            for k in 0..ndof {
283                qdd[v_idx + k] = qdd_vec[k];
284            }
285
286            // a_i = a_parent + c + S * qdd
287            let s_qdd = &s_mat * &qdd_vec;
288            acc[i] = a_total + SpatialVec {
289                data: Vec6::from_iterator(s_qdd.iter().cloned()),
290            };
291        }
292    }
293
294    qdd
295}
296
297/// Outer product of two 6D spatial vectors.
298fn outer_product_6(a: &SpatialVec, b: &SpatialVec) -> SpatialMat {
299    SpatialMat::from_mat6(a.data * b.data.transpose())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use phyz_math::{GRAVITY, SpatialInertia};
306    use phyz_model::ModelBuilder;
307
308    fn make_double_pendulum() -> phyz_model::Model {
309        let length = 1.0;
310        let mass = 1.0;
311        ModelBuilder::new()
312            .gravity(Vec3::new(0.0, -GRAVITY, 0.0))
313            .dt(0.001)
314            .add_revolute_body(
315                "link1",
316                -1,
317                SpatialTransform::identity(),
318                SpatialInertia::new(
319                    mass,
320                    Vec3::new(0.0, -length / 2.0, 0.0),
321                    phyz_math::Mat3::from_diagonal(&Vec3::new(
322                        mass * length * length / 12.0,
323                        0.0,
324                        mass * length * length / 12.0,
325                    )),
326                ),
327            )
328            .add_revolute_body(
329                "link2",
330                0,
331                SpatialTransform::translation(Vec3::new(0.0, -length, 0.0)),
332                SpatialInertia::new(
333                    mass,
334                    Vec3::new(0.0, -length / 2.0, 0.0),
335                    phyz_math::Mat3::from_diagonal(&Vec3::new(
336                        mass * length * length / 12.0,
337                        0.0,
338                        mass * length * length / 12.0,
339                    )),
340                ),
341            )
342            .build()
343    }
344
345    #[test]
346    fn test_double_pendulum_equilibrium() {
347        let model = make_double_pendulum();
348        let state = model.default_state();
349        let qdd = aba(&model, &state);
350        assert!(qdd[0].abs() < 1e-10, "qdd[0] = {} at equilibrium", qdd[0]);
351        assert!(qdd[1].abs() < 1e-10, "qdd[1] = {} at equilibrium", qdd[1]);
352    }
353
354    #[test]
355    fn test_aba_rnea_consistency() {
356        let model = make_double_pendulum();
357        let mut state = model.default_state();
358        state.q[0] = 0.3;
359        state.q[1] = 0.2;
360        state.v[0] = 0.1;
361        state.v[1] = -0.1;
362
363        let qdd = aba(&model, &state);
364        let tau = crate::rnea(&model, &state, &qdd);
365
366        // ABA with ctrl=0 should give qdd such that RNEA(qdd) = 0
367        assert!(tau[0].abs() < 1e-10, "RNEA tau[0] = {}", tau[0]);
368        assert!(tau[1].abs() < 1e-10, "RNEA tau[1] = {}", tau[1]);
369    }
370
371    #[test]
372    fn test_single_pendulum_aba() {
373        let length = 1.0;
374        let mass = 1.0;
375
376        let model = ModelBuilder::new()
377            .gravity(Vec3::new(0.0, -GRAVITY, 0.0))
378            .add_revolute_body(
379                "link1",
380                -1,
381                SpatialTransform::identity(),
382                SpatialInertia::new(
383                    mass,
384                    Vec3::new(0.0, -length / 2.0, 0.0),
385                    phyz_math::Mat3::from_diagonal(&Vec3::new(
386                        mass * length * length / 12.0,
387                        0.0,
388                        mass * length * length / 12.0,
389                    )),
390                ),
391            )
392            .build();
393
394        let mut state = model.default_state();
395        state.q[0] = std::f64::consts::FRAC_PI_2;
396
397        let qdd = aba(&model, &state);
398
399        let i_total = mass * length * length / 3.0;
400        let expected_qdd = -(mass * GRAVITY * length / 2.0) / i_total;
401
402        assert!(
403            (qdd[0] - expected_qdd).abs() < 1e-6,
404            "qdd = {}, expected = {}",
405            qdd[0],
406            expected_qdd
407        );
408    }
409
410    #[test]
411    fn test_free_joint_freefall() {
412        // A free body in gravity should accelerate downward at g
413        let model = ModelBuilder::new()
414            .gravity(Vec3::new(0.0, 0.0, -GRAVITY))
415            .add_free_body(
416                "ball",
417                -1,
418                SpatialTransform::identity(),
419                SpatialInertia::sphere(1.0, 0.1),
420            )
421            .build();
422
423        let state = model.default_state();
424        let qdd = aba(&model, &state);
425
426        // Free joint DOFs: [wx, wy, wz, vx, vy, vz] mapping to q = [x, y, z, wx, wy, wz]
427        // Accelerations: angular should be 0, linear z should be -g
428        assert!(qdd[0].abs() < 1e-10, "ang_x accel = {}", qdd[0]);
429        assert!(qdd[1].abs() < 1e-10, "ang_y accel = {}", qdd[1]);
430        assert!(qdd[2].abs() < 1e-10, "ang_z accel = {}", qdd[2]);
431        assert!(qdd[3].abs() < 1e-10, "lin_x accel = {}", qdd[3]);
432        assert!(qdd[4].abs() < 1e-10, "lin_y accel = {}", qdd[4]);
433        assert!(
434            (qdd[5] - (-GRAVITY)).abs() < 1e-6,
435            "lin_z accel = {}, expected = {}",
436            qdd[5],
437            -GRAVITY
438        );
439    }
440}