1use crate::dual::Dual;
21use ferromotion_core::gendyn::{cholesky_solve, Real};
22
23pub trait Residual {
25 fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T>;
26}
27
28#[derive(Clone, Copy, Debug)]
30pub struct NlsOptions {
31 pub max_iters: usize,
32 pub grad_tol: f64,
33 pub damping0: f64,
34}
35
36impl Default for NlsOptions {
37 fn default() -> Self {
38 Self { max_iters: 60, grad_tol: 1e-10, damping0: 1e-4 }
39 }
40}
41
42pub struct NlsSolution {
44 pub theta: Vec<f64>,
45 pub cost: f64,
46 pub iters: usize,
47 jtj: Vec<f64>,
49 jac: Vec<f64>,
51 n_res: usize,
52}
53
54fn dual_theta(theta: &[f64], seed: usize) -> Vec<Dual> {
55 theta.iter().enumerate().map(|(i, &v)| if i == seed { Dual::var(v) } else { Dual::constant(v) }).collect()
56}
57
58fn consts(v: &[f64]) -> Vec<Dual> {
59 v.iter().map(|&x| Dual::constant(x)).collect()
60}
61
62fn jacobian<R: Residual>(res: &R, theta: &[f64], phi: &[f64]) -> (Vec<f64>, Vec<f64>) {
64 let n = theta.len();
65 let phic = consts(phi);
66 let r0: Vec<f64> = res.eval(&consts(theta), &phic).iter().map(|d| d.re).collect();
67 let m = r0.len();
68 let mut jac = vec![0.0; m * n];
69 for j in 0..n {
70 let rj = res.eval(&dual_theta(theta, j), &phic);
71 assert_eq!(rj.len(), m, "residual count must not depend on θ");
72 for i in 0..m {
73 jac[i * n + j] = rj[i].eps;
74 }
75 }
76 (r0, jac)
77}
78
79pub fn solve<R: Residual>(res: &R, theta0: &[f64], phi: &[f64], opts: NlsOptions) -> NlsSolution {
81 let n = theta0.len();
82 let mut theta = theta0.to_vec();
83 let mut damping = opts.damping0;
84 let cost_of = |th: &[f64]| -> f64 {
85 res.eval(&consts(th), &consts(phi)).iter().map(|d| d.re * d.re).sum::<f64>() * 0.5
86 };
87 let mut cost = cost_of(&theta);
88 let mut iters = 0;
89 for _ in 0..opts.max_iters {
90 iters += 1;
91 let (r0, jac) = jacobian(res, &theta, phi);
92 let m = r0.len();
93 let mut g = vec![0.0; n];
95 let mut h = vec![0.0; n * n];
96 for i in 0..m {
97 for a in 0..n {
98 g[a] += jac[i * n + a] * r0[i];
99 for b in 0..n {
100 h[a * n + b] += jac[i * n + a] * jac[i * n + b];
101 }
102 }
103 }
104 if g.iter().map(|v| v.abs()).fold(0.0, f64::max) < opts.grad_tol {
105 break;
106 }
107 let mut stepped = false;
109 for _ in 0..20 {
110 let mut hd = h.clone();
111 for a in 0..n {
112 hd[a * n + a] += damping * (1.0 + h[a * n + a]);
113 }
114 let step = cholesky_solve(&hd, &g, n);
115 let cand: Vec<f64> = theta.iter().zip(&step).map(|(t, s)| t - s).collect();
116 let c_new = cost_of(&cand);
117 if c_new.is_finite() && c_new <= cost {
118 theta = cand;
119 cost = c_new;
120 damping = (damping * 0.3).max(1e-12);
121 stepped = true;
122 break;
123 }
124 damping *= 10.0;
125 }
126 if !stepped {
127 break;
128 }
129 }
130 let (r0, jac) = jacobian(res, &theta, phi);
131 let m = r0.len();
132 let mut jtj = vec![0.0; n * n];
133 for i in 0..m {
134 for a in 0..n {
135 for b in 0..n {
136 jtj[a * n + b] += jac[i * n + a] * jac[i * n + b];
137 }
138 }
139 }
140 for a in 0..n {
142 jtj[a * n + a] += 1e-12;
143 }
144 NlsSolution { theta, cost, iters, jtj, jac, n_res: m }
145}
146
147impl NlsSolution {
148 pub fn dtheta_dphi<R: Residual>(&self, res: &R, phi: &[f64], k: usize) -> Vec<f64> {
151 let n = self.theta.len();
152 let phid: Vec<Dual> =
153 phi.iter().enumerate().map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) }).collect();
154 let rk = res.eval(&consts(&self.theta), &phid);
155 assert_eq!(rk.len(), self.n_res);
156 let mut jtr = vec![0.0; n];
157 for (i, r) in rk.iter().enumerate() {
158 for a in 0..n {
159 jtr[a] += self.jac[i * n + a] * r.eps;
160 }
161 }
162 cholesky_solve(&self.jtj, &jtr, n).iter().map(|v| -v).collect()
163 }
164
165 pub fn backward<R: Residual>(&self, res: &R, phi: &[f64], dl_dtheta: &[f64]) -> Vec<f64> {
168 let n = self.theta.len();
169 let w = cholesky_solve(&self.jtj, dl_dtheta, n);
171 let jt_w: Vec<f64> = (0..self.n_res).map(|i| (0..n).map(|a| self.jac[i * n + a] * w[a]).sum()).collect();
172 (0..phi.len())
173 .map(|k| {
174 let phid: Vec<Dual> = phi
175 .iter()
176 .enumerate()
177 .map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) })
178 .collect();
179 let rk = res.eval(&consts(&self.theta), &phid);
180 -rk.iter().zip(&jt_w).map(|(r, w)| r.eps * w).sum::<f64>()
181 })
182 .collect()
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 struct LinearFit {
193 a: Vec<f64>, m: usize,
195 n: usize,
196 }
197 impl Residual for LinearFit {
198 fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
199 (0..self.m)
200 .map(|i| {
201 let mut acc = T::from_f64(0.0) - phi[i];
202 for j in 0..self.n {
203 acc = acc + T::from_f64(self.a[i * self.n + j]) * theta[j];
204 }
205 acc
206 })
207 .collect()
208 }
209 }
210
211 #[test]
212 fn linear_problem_matches_the_closed_form_solution_and_sensitivity() {
213 let (m, n) = (7usize, 3usize);
214 let a: Vec<f64> = (0..m * n).map(|i| ((i * 7 + 3) % 11) as f64 * 0.3 - 1.0).collect();
215 let y: Vec<f64> = (0..m).map(|i| (i as f64 * 0.9).sin()).collect();
216 let prob = LinearFit { a: a.clone(), m, n };
217 let sol = solve(&prob, &vec![0.0; n], &y, NlsOptions::default());
218
219 let mut ata = vec![0.0; n * n];
221 let mut aty = vec![0.0; n];
222 for i in 0..m {
223 for p in 0..n {
224 aty[p] += a[i * n + p] * y[i];
225 for q in 0..n {
226 ata[p * n + q] += a[i * n + p] * a[i * n + q];
227 }
228 }
229 }
230 let closed = cholesky_solve(&ata, &aty, n);
231 for (got, want) in sol.theta.iter().zip(&closed) {
232 assert!((got - want).abs() < 1e-8, "θ*: {got} vs {want}");
233 }
234 for k in [0usize, 3, 6] {
236 let col_want = {
237 let atk: Vec<f64> = (0..n).map(|p| a[k * n + p]).collect();
238 cholesky_solve(&ata, &atk, n)
239 };
240 let col_got = sol.dtheta_dphi(&prob, &y, k);
241 for (g, w) in col_got.iter().zip(&col_want) {
242 assert!((g - w).abs() < 1e-8, "dθ/dy[{k}]: {g} vs {w}");
243 }
244 }
245 }
246
247 struct ExpFit {
250 xs: Vec<f64>,
251 }
252 impl Residual for ExpFit {
253 fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
254 self.xs
256 .iter()
257 .enumerate()
258 .map(|(i, &x)| {
259 let bx = theta[1] * T::from_f64(x);
260 let t = (bx * T::from_f64(0.5)).tanh();
262 let ez = (T::from_f64(1.0) + t) / (T::from_f64(1.0) - t);
263 theta[0] * ez - phi[i]
264 })
265 .collect()
266 }
267 }
268
269 #[test]
270 fn nonlinear_sensitivity_matches_finite_differences_of_resolving() {
271 let xs: Vec<f64> = (0..9).map(|i| i as f64 * 0.25).collect();
272 let y: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp()).collect();
275 let prob = ExpFit { xs: xs.clone() };
276 let opts = NlsOptions { max_iters: 120, ..Default::default() };
277 let sol = solve(&prob, &[1.0, 0.1], &y, opts);
278 assert!(sol.cost < 1e-16, "exact fit must converge to zero residual: cost {}", sol.cost);
279
280 let eps = 1e-6;
281 for k in [0usize, 4, 8] {
282 let got = sol.dtheta_dphi(&prob, &y, k);
283 let (mut yp, mut ym) = (y.clone(), y.clone());
284 yp[k] += eps;
285 ym[k] -= eps;
286 let sp = solve(&prob, &sol.theta, &yp, opts);
287 let sm = solve(&prob, &sol.theta, &ym, opts);
288 for j in 0..2 {
289 let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
290 assert!((got[j] - want).abs() < 1e-5 * want.abs().max(1.0), "dθ{j}/dy{k}: {} vs {want}", got[j]);
291 }
292 }
293
294 let yn: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp() + 0.03 * (x * 5.0).sin()).collect();
298 let soln = solve(&prob, &[1.0, 0.1], &yn, opts);
299 for k in [0usize, 8] {
300 let got = soln.dtheta_dphi(&prob, &yn, k);
301 let (mut yp, mut ym) = (yn.clone(), yn.clone());
302 yp[k] += eps;
303 ym[k] -= eps;
304 let sp = solve(&prob, &soln.theta, &yp, opts);
305 let sm = solve(&prob, &soln.theta, &ym, opts);
306 for j in 0..2 {
307 let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
308 assert!((got[j] - want).abs() < 5e-3 * want.abs().max(1.0), "GN-approx band dθ{j}/dy{k}: {} vs {want}", got[j]);
309 }
310 }
311
312 let dl_dtheta: Vec<f64> = sol.theta.iter().map(|t| 2.0 * t).collect();
314 let gphi = sol.backward(&prob, &y, &dl_dtheta);
315 for k in [2usize, 6] {
316 let (mut yp, mut ym) = (y.clone(), y.clone());
317 yp[k] += eps;
318 ym[k] -= eps;
319 let lp: f64 = solve(&prob, &sol.theta, &yp, opts).theta.iter().map(|t| t * t).sum();
320 let lm: f64 = solve(&prob, &sol.theta, &ym, opts).theta.iter().map(|t| t * t).sum();
321 let want = (lp - lm) / (2.0 * eps);
322 assert!((gphi[k] - want).abs() < 1e-4 * want.abs().max(1.0), "dL/dy{k}: {} vs {want}", gphi[k]);
323 }
324 }
325
326 struct IkResidual {
330 robot: ferromotion_core::Robot,
331 inertia: Vec<ferromotion_core::LinkInertia>,
332 }
333 impl Residual for IkResidual {
334 fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
335 use ferromotion_core::gendyn::V3;
336 let m = ferromotion_core::gendyn::GenModel::<T>::from_robot(&self.robot, &self.inertia, [0.0, 0.0, 0.0]);
337 let (r, p) = m.fk_frame(theta);
339 let tool = r.mul_v(V3::from_f64([0.0, 0.0, 0.25]));
340 let pt = p.add(tool);
341 vec![pt.0[0] - phi[0], pt.0[1] - phi[1], pt.0[2] - phi[2]]
342 }
343 }
344
345 #[test]
346 fn ik_as_a_differentiable_layer_through_the_real_fk() {
347 use ferromotion_core::{Iso, Joint, LinkInertia, Robot};
348 use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
349 let mk = |z: f64| Iso::from_parts(Translation3::new(0.0, 0.0, z), UnitQuaternion::identity());
350 let robot = Robot {
351 joints: vec![
352 Joint::revolute(mk(0.1), Vector3::z()),
353 Joint::revolute(mk(0.3), Vector3::y()),
354 Joint::revolute(mk(0.3), Vector3::y()),
355 ],
356 ee_offset: Iso::identity(),
357 };
358 let inertia = vec![
359 LinkInertia { mass: 1.0, com: Vector3::zeros(), inertia: Matrix3::identity() * 0.01 };
360 3
361 ];
362 let prob = IkResidual { robot, inertia };
363 let target = [0.25, 0.1, 0.45];
364 let opts = NlsOptions { max_iters: 200, ..Default::default() };
365 let sol = solve(&prob, &[0.3, -0.5, 0.8], &target, opts);
366 assert!(sol.cost < 1e-16, "IK must reach the target: cost {}", sol.cost);
367
368 let eps = 1e-6;
369 for k in 0..3 {
370 let got = sol.dtheta_dphi(&prob, &target, k);
371 let (mut tp, mut tm) = (target, target);
372 tp[k] += eps;
373 tm[k] -= eps;
374 let sp = solve(&prob, &sol.theta, &tp, opts);
375 let sm = solve(&prob, &sol.theta, &tm, opts);
376 for j in 0..3 {
377 let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
378 assert!((got[j] - want).abs() < 1e-4 * want.abs().max(1.0), "dq{j}/dtarget{k}: {} vs {want}", got[j]);
379 }
380 }
381 }
382}