Skip to main content

ferromotion_learn/
calib.rs

1//! **Gradient-based real-to-sim calibration** — fit a robot's inertial parameters and joint
2//! friction to recorded trajectories, by exact gradients through the dynamics.
3//!
4//! This is the single most validated use of differentiable dynamics in 2026 practice: given
5//! samples `(q, q̇, q̈, τ)` from hardware (or a higher-fidelity simulator), find the model that
6//! predicts the measured torques — the inverse-dynamics regression formulation of system
7//! identification, extended beyond the classical linear-regressor form ([`ferromotion_core`'s
8//! `sysid`]) to jointly fit **nonlinear friction** and arbitrary parameter subsets.
9//!
10//! Machinery: [`ferromotion_core::gendyn`]'s generic-scalar RNEA instantiated at [`Dual`] — each
11//! fitted parameter is seeded through the *model* (chain rule through the log-reparameterization
12//! included), so every gradient is exact to machine precision. Positivity (masses, inertia
13//! diagonals, friction coefficients) holds **by construction**: those parameters are optimized in
14//! log space. Coulomb friction is smoothed (`tanh(q̇/w)`) so the model stays differentiable at rest.
15//!
16//! Honesty about identifiability: for a fixed-base chain only the *base parameters* are
17//! recoverable — the fit recovers the dynamics **predictively**, not a unique φ (same caveat as
18//! classical linear ID). The report therefore carries a per-parameter Gauss–Newton sensitivity:
19//! parameters with near-zero sensitivity are unidentifiable *from this data* and their fitted
20//! values should not be read as physical truth. Pure Rust, wasm-clean.
21
22use crate::dual::Dual;
23use ferromotion_core::gendyn::{GenModel, Real};
24use ferromotion_core::{LinkInertia, Robot};
25
26/// Smoothed joint friction: `τ_f = coulomb·tanh(q̇/W) + viscous·q̇`.
27#[derive(Clone, Copy, Debug)]
28pub struct JointFriction {
29    pub coulomb: f64,
30    pub viscous: f64,
31}
32
33/// Smoothing width of the Coulomb `tanh` (rad/s) — small enough to look like `sign` at working
34/// speeds, large enough to keep gradients finite through zero crossings.
35pub const COULOMB_SMOOTH: f64 = 0.05;
36
37/// One recorded sample: joint state, acceleration, and measured torques.
38#[derive(Clone, Debug)]
39pub struct CalibSample {
40    pub q: Vec<f64>,
41    pub qd: Vec<f64>,
42    pub qdd: Vec<f64>,
43    pub tau: Vec<f64>,
44}
45
46/// Which parameter groups to fit (everything else stays at its initial value).
47#[derive(Clone, Copy, Debug)]
48pub struct CalibSpec {
49    pub fit_mass: bool,
50    pub fit_com: bool,
51    pub fit_inertia_diag: bool,
52    pub fit_friction: bool,
53    pub iters: usize,
54    pub lr: f64,
55}
56
57impl Default for CalibSpec {
58    fn default() -> Self {
59        Self { fit_mass: true, fit_com: true, fit_inertia_diag: true, fit_friction: true, iters: 300, lr: 0.03 }
60    }
61}
62
63/// The calibration result: fitted model, fit quality, and the identifiability signal.
64pub struct CalibReport {
65    pub inertia: Vec<LinkInertia>,
66    pub friction: Vec<JointFriction>,
67    /// RMS torque error of the INITIAL model over the samples.
68    pub rms_before: f64,
69    /// RMS torque error of the fitted model.
70    pub rms_after: f64,
71    /// Per fitted parameter `(name, Gauss–Newton sensitivity)` — `sqrt(mean (∂τ̂/∂θ)²)`. Near-zero
72    /// sensitivity means the data does not constrain that parameter (unidentifiable here).
73    pub sensitivity: Vec<(String, f64)>,
74}
75
76// One fitted parameter's location in the model.
77#[derive(Clone, Copy)]
78enum Param {
79    LogMass(usize),
80    Com(usize, usize),
81    LogInertiaDiag(usize, usize),
82    LogCoulomb(usize),
83    LogViscous(usize),
84}
85
86fn param_name(p: Param) -> String {
87    match p {
88        Param::LogMass(l) => format!("link{l}.mass"),
89        Param::Com(l, k) => format!("link{l}.com[{k}]"),
90        Param::LogInertiaDiag(l, k) => format!("link{l}.I[{k}{k}]"),
91        Param::LogCoulomb(j) => format!("joint{j}.coulomb"),
92        Param::LogViscous(j) => format!("joint{j}.viscous"),
93    }
94}
95
96/// Predicted torques of the generic model + friction at one sample.
97fn tau_pred<T: Real>(model: &GenModel<T>, friction: &[(T, T)], q: &[T], qd: &[T], qdd: &[T]) -> Vec<T> {
98    let w_inv = T::from_f64(1.0 / COULOMB_SMOOTH);
99    let mut tau = model.rnea(q, qd, qdd);
100    for (j, t) in tau.iter_mut().enumerate() {
101        let (c, v) = friction[j];
102        *t = *t + c * (qd[j] * w_inv).tanh() + v * qd[j];
103    }
104    tau
105}
106
107// Decode θ into a model over T, seeding the dual derivative on parameter `seed` with the chain
108// rule of the log-reparameterization (d exp(θ)/dθ = exp(θ)).
109#[allow(clippy::too_many_arguments)]
110fn decode<T: Real>(
111    robot: &Robot,
112    base_inertia: &[LinkInertia],
113    base_friction: &[JointFriction],
114    params: &[Param],
115    theta: &[f64],
116    gravity: [f64; 3],
117    seed: Option<usize>,
118    mk: impl Fn(f64, f64) -> T, // (value, dvalue/dθ_seed or 0) -> T
119) -> (GenModel<T>, Vec<(T, T)>) {
120    let mut inertia = base_inertia.to_vec();
121    let mut friction = base_friction.to_vec();
122    // apply θ values (f64 side)
123    for (i, p) in params.iter().enumerate() {
124        match *p {
125            Param::LogMass(l) => inertia[l].mass = theta[i].exp(),
126            Param::Com(l, k) => inertia[l].com[k] = theta[i],
127            Param::LogInertiaDiag(l, k) => inertia[l].inertia[(k, k)] = theta[i].exp(),
128            Param::LogCoulomb(j) => friction[j].coulomb = theta[i].exp(),
129            Param::LogViscous(j) => friction[j].viscous = theta[i].exp(),
130        }
131    }
132    let mut model = GenModel::<T>::from_robot(robot, &inertia, gravity);
133    let mut fr: Vec<(T, T)> = friction.iter().map(|f| (mk(f.coulomb, 0.0), mk(f.viscous, 0.0))).collect();
134    // seed the derivative
135    if let Some(s) = seed {
136        match params[s] {
137            Param::LogMass(l) => model.links[l].mass = mk(inertia[l].mass, inertia[l].mass),
138            Param::Com(l, k) => model.links[l].com.0[k] = mk(inertia[l].com[k], 1.0),
139            Param::LogInertiaDiag(l, k) => {
140                model.links[l].inertia.0[k][k] = mk(inertia[l].inertia[(k, k)], inertia[l].inertia[(k, k)])
141            }
142            Param::LogCoulomb(j) => fr[j].0 = mk(friction[j].coulomb, friction[j].coulomb),
143            Param::LogViscous(j) => fr[j].1 = mk(friction[j].viscous, friction[j].viscous),
144        }
145    }
146    (model, fr)
147}
148
149/// Fit the selected parameter groups to the samples by Adam over exact dual-number gradients.
150pub fn calibrate(
151    robot: &Robot,
152    init_inertia: &[LinkInertia],
153    init_friction: &[JointFriction],
154    samples: &[CalibSample],
155    gravity: [f64; 3],
156    spec: CalibSpec,
157) -> CalibReport {
158    let n = robot.dof();
159    // build the fitted-parameter list + initial θ
160    let mut params = Vec::new();
161    let mut theta = Vec::new();
162    for l in 0..n {
163        if spec.fit_mass {
164            params.push(Param::LogMass(l));
165            theta.push(init_inertia[l].mass.max(1e-6).ln());
166        }
167        if spec.fit_com {
168            for k in 0..3 {
169                params.push(Param::Com(l, k));
170                theta.push(init_inertia[l].com[k]);
171            }
172        }
173        if spec.fit_inertia_diag {
174            for k in 0..3 {
175                params.push(Param::LogInertiaDiag(l, k));
176                theta.push(init_inertia[l].inertia[(k, k)].max(1e-9).ln());
177            }
178        }
179    }
180    for j in 0..n {
181        if spec.fit_friction {
182            params.push(Param::LogCoulomb(j));
183            theta.push(init_friction[j].coulomb.max(1e-6).ln());
184            params.push(Param::LogViscous(j));
185            theta.push(init_friction[j].viscous.max(1e-6).ln());
186        }
187    }
188    let np = params.len();
189
190    let rms_of = |theta: &[f64]| -> f64 {
191        let (model, fr) = decode::<f64>(robot, init_inertia, init_friction, &params, theta, gravity, None, |v, _| v);
192        let mut se = 0.0;
193        let mut cnt = 0.0;
194        for s in samples {
195            let pred = tau_pred(&model, &fr, &s.q, &s.qd, &s.qdd);
196            for j in 0..n {
197                se += (pred[j] - s.tau[j]).powi(2);
198                cnt += 1.0;
199            }
200        }
201        (se / cnt).sqrt()
202    };
203    let rms_before = rms_of(&theta);
204
205    // Adam over exact forward-mode gradients: one dual pass per fitted parameter per iteration.
206    let (mut m1, mut m2) = (vec![0.0; np], vec![0.0; np]);
207    let (b1, b2, eps) = (0.9, 0.999, 1e-8);
208    let mut sens = vec![0.0; np];
209    for it in 0..spec.iters {
210        let mut grad = vec![0.0; np];
211        if it + 1 == spec.iters {
212            sens.iter_mut().for_each(|s| *s = 0.0);
213        }
214        for (p, g_out) in grad.iter_mut().enumerate() {
215            let (model, fr) = decode::<Dual>(robot, init_inertia, init_friction, &params, &theta, gravity, Some(p), |v, d| Dual { re: v, eps: d });
216            let mut g = 0.0;
217            let mut cnt = 0.0;
218            for s in samples {
219                let qc: Vec<Dual> = s.q.iter().map(|&x| Dual::constant(x)).collect();
220                let qdc: Vec<Dual> = s.qd.iter().map(|&x| Dual::constant(x)).collect();
221                let qddc: Vec<Dual> = s.qdd.iter().map(|&x| Dual::constant(x)).collect();
222                let pred = tau_pred(&model, &fr, &qc, &qdc, &qddc);
223                for j in 0..n {
224                    g += (pred[j].re - s.tau[j]) * pred[j].eps;
225                    if it + 1 == spec.iters {
226                        sens[p] += pred[j].eps * pred[j].eps;
227                    }
228                    cnt += 1.0;
229                }
230            }
231            *g_out = g / cnt;
232            if it + 1 == spec.iters {
233                sens[p] = (sens[p] / cnt).sqrt();
234            }
235        }
236        let t = (it + 1) as f64;
237        for p in 0..np {
238            m1[p] = b1 * m1[p] + (1.0 - b1) * grad[p];
239            m2[p] = b2 * m2[p] + (1.0 - b2) * grad[p] * grad[p];
240            let mh = m1[p] / (1.0 - b1.powf(t));
241            let vh = m2[p] / (1.0 - b2.powf(t));
242            theta[p] -= spec.lr * mh / (vh.sqrt() + eps);
243        }
244    }
245    let rms_after = rms_of(&theta);
246
247    // decode the final model back to plain structures
248    let (model, fr) = decode::<f64>(robot, init_inertia, init_friction, &params, &theta, gravity, None, |v, _| v);
249    let inertia: Vec<LinkInertia> = model
250        .links
251        .iter()
252        .map(|l| LinkInertia {
253            mass: l.mass,
254            com: nalgebra::Vector3::new(l.com.0[0], l.com.0[1], l.com.0[2]),
255            inertia: nalgebra::Matrix3::from_fn(|r, c| l.inertia.0[r][c]),
256        })
257        .collect();
258    let friction: Vec<JointFriction> = fr.iter().map(|&(c, v)| JointFriction { coulomb: c, viscous: v }).collect();
259    let sensitivity = params.iter().zip(&sens).map(|(p, &s)| (param_name(*p), s)).collect();
260    CalibReport { inertia, friction, rms_before, rms_after, sensitivity }
261}
262
263/// Deterministic excitation trajectories + torques from a ground-truth model — the synthetic data
264/// generator for tests and labs (sums of incommensurate sinusoids per joint; torques from the
265/// reference dynamics plus smoothed friction, i.e. exactly the model class being fitted).
266pub fn excite(
267    robot: &Robot,
268    inertia: &[LinkInertia],
269    friction: &[JointFriction],
270    gravity: [f64; 3],
271    n_samples: usize,
272    phase_seed: f64,
273) -> Vec<CalibSample> {
274    let n = robot.dof();
275    let g = nalgebra::Vector3::new(gravity[0], gravity[1], gravity[2]);
276    (0..n_samples)
277        .map(|s| {
278            let t = s as f64 * 0.03;
279            let mut q = vec![0.0; n];
280            let mut qd = vec![0.0; n];
281            let mut qdd = vec![0.0; n];
282            for j in 0..n {
283                let (a1, w1) = (0.6 + 0.1 * j as f64, 1.3 + 0.7 * j as f64);
284                let (a2, w2) = (0.25, 2.9 + 0.4 * j as f64);
285                let ph = phase_seed + j as f64 * 1.7;
286                q[j] = a1 * (w1 * t + ph).sin() + a2 * (w2 * t).cos();
287                qd[j] = a1 * w1 * (w1 * t + ph).cos() - a2 * w2 * (w2 * t).sin();
288                qdd[j] = -a1 * w1 * w1 * (w1 * t + ph).sin() - a2 * w2 * w2 * (w2 * t).cos();
289            }
290            let mut tau = ferromotion_core::inverse_dynamics(robot, inertia, &q, &qd, &qdd, g);
291            for j in 0..n {
292                tau[j] += friction[j].coulomb * (qd[j] / COULOMB_SMOOTH).tanh() + friction[j].viscous * qd[j];
293            }
294            CalibSample { q, qd, qdd, tau }
295        })
296        .collect()
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use ferromotion_core::{Iso, Joint};
303    use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
304
305    fn test_robot() -> (Robot, Vec<LinkInertia>, Vec<JointFriction>) {
306        let mk = |xyz: [f64; 3], rpy: [f64; 3]| {
307            Iso::from_parts(
308                Translation3::new(xyz[0], xyz[1], xyz[2]),
309                UnitQuaternion::from_euler_angles(rpy[0], rpy[1], rpy[2]),
310            )
311        };
312        let joints = vec![
313            Joint::revolute(mk([0.0, 0.0, 0.3], [0.0, 0.0, 0.4]), Vector3::z()),
314            Joint::revolute(mk([0.1, 0.0, 0.2], [0.3, 0.0, 0.0]), Vector3::y()),
315            Joint::revolute(mk([0.2, 0.0, 0.1], [0.0, 0.0, -0.3]), Vector3::y()),
316        ];
317        let inertia: Vec<LinkInertia> = (0..3)
318            .map(|i| {
319                let f = i as f64;
320                LinkInertia {
321                    mass: 2.0 + 0.5 * f,
322                    com: Vector3::new(0.03 * f, -0.02, 0.08 + 0.02 * f),
323                    inertia: Matrix3::new(
324                        0.03 + 0.01 * f, 0.0, 0.0,
325                        0.0, 0.04 + 0.005 * f, 0.0,
326                        0.0, 0.0, 0.035,
327                    ),
328                }
329            })
330            .collect();
331        let friction = vec![
332            JointFriction { coulomb: 0.4, viscous: 0.15 },
333            JointFriction { coulomb: 0.25, viscous: 0.3 },
334            JointFriction { coulomb: 0.5, viscous: 0.1 },
335        ];
336        (Robot { joints, ee_offset: Iso::identity() }, inertia, friction)
337    }
338
339    /// Fit the identifiable subset (masses + friction, everything else at truth) from a badly
340    /// perturbed start: parameters must RECOVER near-exactly, and the torque RMS must collapse.
341    #[test]
342    fn masses_and_friction_recover_from_perturbed_start() {
343        let (robot, true_inertia, true_friction) = test_robot();
344        let g = [0.0, 0.0, -9.81];
345        let samples = excite(&robot, &true_inertia, &true_friction, g, 240, 0.0);
346
347        let mut init_inertia = true_inertia.clone();
348        for (i, li) in init_inertia.iter_mut().enumerate() {
349            li.mass *= 1.0 + 0.3 * if i % 2 == 0 { 1.0 } else { -1.0 };
350        }
351        let init_friction: Vec<JointFriction> =
352            true_friction.iter().map(|f| JointFriction { coulomb: f.coulomb * 1.6, viscous: f.viscous * 0.5 }).collect();
353
354        let spec = CalibSpec { fit_mass: true, fit_com: false, fit_inertia_diag: false, fit_friction: true, iters: 800, lr: 0.05 };
355        let rep = calibrate(&robot, &init_inertia, &init_friction, &samples, g, spec);
356        assert!(rep.rms_after < rep.rms_before / 100.0, "rms must collapse: {} → {}", rep.rms_before, rep.rms_after);
357        for (l, (got, want)) in rep.inertia.iter().zip(&true_inertia).enumerate() {
358            let rel = (got.mass - want.mass).abs() / want.mass;
359            assert!(rel < 0.02, "link {l} mass: {} vs {} (rel {rel})", got.mass, want.mass);
360        }
361        for (j, (got, want)) in rep.friction.iter().zip(&true_friction).enumerate() {
362            assert!((got.coulomb - want.coulomb).abs() / want.coulomb < 0.05, "joint {j} coulomb");
363            assert!((got.viscous - want.viscous).abs() / want.viscous < 0.05, "joint {j} viscous");
364        }
365        eprintln!("identifiable-subset fit: rms {:.4} → {:.6}", rep.rms_before, rep.rms_after);
366    }
367
368    /// Full fit (mass + COM + inertia diagonal + friction) from a perturbed start: the fitted model
369    /// must PREDICT — held-out excitation torques near-perfectly — even where individual parameters
370    /// are only identifiable in combination (the honest sysid criterion).
371    #[test]
372    fn full_fit_predicts_held_out_trajectories() {
373        let (robot, true_inertia, true_friction) = test_robot();
374        let g = [0.0, 0.0, -9.81];
375        let train = excite(&robot, &true_inertia, &true_friction, g, 150, 0.0);
376        let heldout = excite(&robot, &true_inertia, &true_friction, g, 80, 2.1);
377
378        let mut init_inertia = true_inertia.clone();
379        for (i, li) in init_inertia.iter_mut().enumerate() {
380            let s = if i % 2 == 0 { 1.0 } else { -1.0 };
381            li.mass *= 1.0 + 0.25 * s;
382            li.com *= 1.0 - 0.2 * s;
383            li.inertia *= 1.0 + 0.3 * s;
384        }
385        let init_friction: Vec<JointFriction> =
386            true_friction.iter().map(|f| JointFriction { coulomb: f.coulomb * 0.6, viscous: f.viscous * 1.5 }).collect();
387
388        let spec = CalibSpec { iters: 500, lr: 0.04, ..Default::default() };
389        let rep = calibrate(&robot, &init_inertia, &init_friction, &train, g, spec);
390
391        // held-out predictive check with the fitted model
392        let rms_heldout = {
393            let mut se = 0.0;
394            let mut cnt = 0.0;
395            let model = GenModel::<f64>::from_robot(&robot, &rep.inertia, g);
396            let fr: Vec<(f64, f64)> = rep.friction.iter().map(|f| (f.coulomb, f.viscous)).collect();
397            for s in &heldout {
398                let pred = tau_pred(&model, &fr, &s.q, &s.qd, &s.qdd);
399                for j in 0..robot.dof() {
400                    se += (pred[j] - s.tau[j]).powi(2);
401                    cnt += 1.0;
402                }
403            }
404            (se / cnt).sqrt()
405        };
406        assert!(rep.rms_after < rep.rms_before / 50.0, "train rms: {} → {}", rep.rms_before, rep.rms_after);
407        assert!(rms_heldout < rep.rms_before / 50.0, "held-out rms {rms_heldout} vs initial {}", rep.rms_before);
408        // the identifiability signal exists and is finite for every fitted parameter
409        assert_eq!(rep.sensitivity.len(), 3 * 7 + 3 * 2);
410        assert!(rep.sensitivity.iter().all(|(_, s)| s.is_finite()));
411        eprintln!("full fit: train rms {:.4} → {:.6}; held-out {:.6}", rep.rms_before, rep.rms_after, rms_heldout);
412    }
413}