1use crate::dual::Dual;
23use ferromotion_core::gendyn::{GenModel, Real};
24use ferromotion_core::{LinkInertia, Robot};
25
26#[derive(Clone, Copy, Debug)]
28pub struct JointFriction {
29 pub coulomb: f64,
30 pub viscous: f64,
31}
32
33pub const COULOMB_SMOOTH: f64 = 0.05;
36
37#[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#[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
63pub struct CalibReport {
65 pub inertia: Vec<LinkInertia>,
66 pub friction: Vec<JointFriction>,
67 pub rms_before: f64,
69 pub rms_after: f64,
71 pub sensitivity: Vec<(String, f64)>,
74}
75
76#[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
96fn 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#[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, ) -> (GenModel<T>, Vec<(T, T)>) {
120 let mut inertia = base_inertia.to_vec();
121 let mut friction = base_friction.to_vec();
122 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 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
149pub 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 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, ¶ms, 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 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, ¶ms, &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 let (model, fr) = decode::<f64>(robot, init_inertia, init_friction, ¶ms, &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
263pub 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 #[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 #[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 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 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}