use crate::dual::Dual;
use ferromotion_core::gendyn::{cholesky_solve, Real};
pub trait Residual {
fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T>;
}
#[derive(Clone, Copy, Debug)]
pub struct NlsOptions {
pub max_iters: usize,
pub grad_tol: f64,
pub damping0: f64,
}
impl Default for NlsOptions {
fn default() -> Self {
Self { max_iters: 60, grad_tol: 1e-10, damping0: 1e-4 }
}
}
pub struct NlsSolution {
pub theta: Vec<f64>,
pub cost: f64,
pub iters: usize,
jtj: Vec<f64>,
jac: Vec<f64>,
n_res: usize,
}
fn dual_theta(theta: &[f64], seed: usize) -> Vec<Dual> {
theta.iter().enumerate().map(|(i, &v)| if i == seed { Dual::var(v) } else { Dual::constant(v) }).collect()
}
fn consts(v: &[f64]) -> Vec<Dual> {
v.iter().map(|&x| Dual::constant(x)).collect()
}
fn jacobian<R: Residual>(res: &R, theta: &[f64], phi: &[f64]) -> (Vec<f64>, Vec<f64>) {
let n = theta.len();
let phic = consts(phi);
let r0: Vec<f64> = res.eval(&consts(theta), &phic).iter().map(|d| d.re).collect();
let m = r0.len();
let mut jac = vec![0.0; m * n];
for j in 0..n {
let rj = res.eval(&dual_theta(theta, j), &phic);
assert_eq!(rj.len(), m, "residual count must not depend on θ");
for i in 0..m {
jac[i * n + j] = rj[i].eps;
}
}
(r0, jac)
}
pub fn solve<R: Residual>(res: &R, theta0: &[f64], phi: &[f64], opts: NlsOptions) -> NlsSolution {
let n = theta0.len();
let mut theta = theta0.to_vec();
let mut damping = opts.damping0;
let cost_of = |th: &[f64]| -> f64 {
res.eval(&consts(th), &consts(phi)).iter().map(|d| d.re * d.re).sum::<f64>() * 0.5
};
let mut cost = cost_of(&theta);
let mut iters = 0;
for _ in 0..opts.max_iters {
iters += 1;
let (r0, jac) = jacobian(res, &theta, phi);
let m = r0.len();
let mut g = vec![0.0; n];
let mut h = vec![0.0; n * n];
for i in 0..m {
for a in 0..n {
g[a] += jac[i * n + a] * r0[i];
for b in 0..n {
h[a * n + b] += jac[i * n + a] * jac[i * n + b];
}
}
}
if g.iter().map(|v| v.abs()).fold(0.0, f64::max) < opts.grad_tol {
break;
}
let mut stepped = false;
for _ in 0..20 {
let mut hd = h.clone();
for a in 0..n {
hd[a * n + a] += damping * (1.0 + h[a * n + a]);
}
let step = cholesky_solve(&hd, &g, n);
let cand: Vec<f64> = theta.iter().zip(&step).map(|(t, s)| t - s).collect();
let c_new = cost_of(&cand);
if c_new.is_finite() && c_new <= cost {
theta = cand;
cost = c_new;
damping = (damping * 0.3).max(1e-12);
stepped = true;
break;
}
damping *= 10.0;
}
if !stepped {
break;
}
}
let (r0, jac) = jacobian(res, &theta, phi);
let m = r0.len();
let mut jtj = vec![0.0; n * n];
for i in 0..m {
for a in 0..n {
for b in 0..n {
jtj[a * n + b] += jac[i * n + a] * jac[i * n + b];
}
}
}
for a in 0..n {
jtj[a * n + a] += 1e-12;
}
NlsSolution { theta, cost, iters, jtj, jac, n_res: m }
}
impl NlsSolution {
pub fn dtheta_dphi<R: Residual>(&self, res: &R, phi: &[f64], k: usize) -> Vec<f64> {
let n = self.theta.len();
let phid: Vec<Dual> =
phi.iter().enumerate().map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) }).collect();
let rk = res.eval(&consts(&self.theta), &phid);
assert_eq!(rk.len(), self.n_res);
let mut jtr = vec![0.0; n];
for (i, r) in rk.iter().enumerate() {
for a in 0..n {
jtr[a] += self.jac[i * n + a] * r.eps;
}
}
cholesky_solve(&self.jtj, &jtr, n).iter().map(|v| -v).collect()
}
pub fn backward<R: Residual>(&self, res: &R, phi: &[f64], dl_dtheta: &[f64]) -> Vec<f64> {
let n = self.theta.len();
let w = cholesky_solve(&self.jtj, dl_dtheta, n);
let jt_w: Vec<f64> = (0..self.n_res).map(|i| (0..n).map(|a| self.jac[i * n + a] * w[a]).sum()).collect();
(0..phi.len())
.map(|k| {
let phid: Vec<Dual> = phi
.iter()
.enumerate()
.map(|(i, &v)| if i == k { Dual::var(v) } else { Dual::constant(v) })
.collect();
let rk = res.eval(&consts(&self.theta), &phid);
-rk.iter().zip(&jt_w).map(|(r, w)| r.eps * w).sum::<f64>()
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct LinearFit {
a: Vec<f64>, m: usize,
n: usize,
}
impl Residual for LinearFit {
fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
(0..self.m)
.map(|i| {
let mut acc = T::from_f64(0.0) - phi[i];
for j in 0..self.n {
acc = acc + T::from_f64(self.a[i * self.n + j]) * theta[j];
}
acc
})
.collect()
}
}
#[test]
fn linear_problem_matches_the_closed_form_solution_and_sensitivity() {
let (m, n) = (7usize, 3usize);
let a: Vec<f64> = (0..m * n).map(|i| ((i * 7 + 3) % 11) as f64 * 0.3 - 1.0).collect();
let y: Vec<f64> = (0..m).map(|i| (i as f64 * 0.9).sin()).collect();
let prob = LinearFit { a: a.clone(), m, n };
let sol = solve(&prob, &vec![0.0; n], &y, NlsOptions::default());
let mut ata = vec![0.0; n * n];
let mut aty = vec![0.0; n];
for i in 0..m {
for p in 0..n {
aty[p] += a[i * n + p] * y[i];
for q in 0..n {
ata[p * n + q] += a[i * n + p] * a[i * n + q];
}
}
}
let closed = cholesky_solve(&ata, &aty, n);
for (got, want) in sol.theta.iter().zip(&closed) {
assert!((got - want).abs() < 1e-8, "θ*: {got} vs {want}");
}
for k in [0usize, 3, 6] {
let col_want = {
let atk: Vec<f64> = (0..n).map(|p| a[k * n + p]).collect();
cholesky_solve(&ata, &atk, n)
};
let col_got = sol.dtheta_dphi(&prob, &y, k);
for (g, w) in col_got.iter().zip(&col_want) {
assert!((g - w).abs() < 1e-8, "dθ/dy[{k}]: {g} vs {w}");
}
}
}
struct ExpFit {
xs: Vec<f64>,
}
impl Residual for ExpFit {
fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
self.xs
.iter()
.enumerate()
.map(|(i, &x)| {
let bx = theta[1] * T::from_f64(x);
let t = (bx * T::from_f64(0.5)).tanh();
let ez = (T::from_f64(1.0) + t) / (T::from_f64(1.0) - t);
theta[0] * ez - phi[i]
})
.collect()
}
}
#[test]
fn nonlinear_sensitivity_matches_finite_differences_of_resolving() {
let xs: Vec<f64> = (0..9).map(|i| i as f64 * 0.25).collect();
let y: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp()).collect();
let prob = ExpFit { xs: xs.clone() };
let opts = NlsOptions { max_iters: 120, ..Default::default() };
let sol = solve(&prob, &[1.0, 0.1], &y, opts);
assert!(sol.cost < 1e-16, "exact fit must converge to zero residual: cost {}", sol.cost);
let eps = 1e-6;
for k in [0usize, 4, 8] {
let got = sol.dtheta_dphi(&prob, &y, k);
let (mut yp, mut ym) = (y.clone(), y.clone());
yp[k] += eps;
ym[k] -= eps;
let sp = solve(&prob, &sol.theta, &yp, opts);
let sm = solve(&prob, &sol.theta, &ym, opts);
for j in 0..2 {
let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
assert!((got[j] - want).abs() < 1e-5 * want.abs().max(1.0), "dθ{j}/dy{k}: {} vs {want}", got[j]);
}
}
let yn: Vec<f64> = xs.iter().map(|&x| 1.7 * (0.6 * x).exp() + 0.03 * (x * 5.0).sin()).collect();
let soln = solve(&prob, &[1.0, 0.1], &yn, opts);
for k in [0usize, 8] {
let got = soln.dtheta_dphi(&prob, &yn, k);
let (mut yp, mut ym) = (yn.clone(), yn.clone());
yp[k] += eps;
ym[k] -= eps;
let sp = solve(&prob, &soln.theta, &yp, opts);
let sm = solve(&prob, &soln.theta, &ym, opts);
for j in 0..2 {
let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
assert!((got[j] - want).abs() < 5e-3 * want.abs().max(1.0), "GN-approx band dθ{j}/dy{k}: {} vs {want}", got[j]);
}
}
let dl_dtheta: Vec<f64> = sol.theta.iter().map(|t| 2.0 * t).collect();
let gphi = sol.backward(&prob, &y, &dl_dtheta);
for k in [2usize, 6] {
let (mut yp, mut ym) = (y.clone(), y.clone());
yp[k] += eps;
ym[k] -= eps;
let lp: f64 = solve(&prob, &sol.theta, &yp, opts).theta.iter().map(|t| t * t).sum();
let lm: f64 = solve(&prob, &sol.theta, &ym, opts).theta.iter().map(|t| t * t).sum();
let want = (lp - lm) / (2.0 * eps);
assert!((gphi[k] - want).abs() < 1e-4 * want.abs().max(1.0), "dL/dy{k}: {} vs {want}", gphi[k]);
}
}
struct IkResidual {
robot: ferromotion_core::Robot,
inertia: Vec<ferromotion_core::LinkInertia>,
}
impl Residual for IkResidual {
fn eval<T: Real>(&self, theta: &[T], phi: &[T]) -> Vec<T> {
use ferromotion_core::gendyn::V3;
let m = ferromotion_core::gendyn::GenModel::<T>::from_robot(&self.robot, &self.inertia, [0.0, 0.0, 0.0]);
let (r, p) = m.fk_frame(theta);
let tool = r.mul_v(V3::from_f64([0.0, 0.0, 0.25]));
let pt = p.add(tool);
vec![pt.0[0] - phi[0], pt.0[1] - phi[1], pt.0[2] - phi[2]]
}
}
#[test]
fn ik_as_a_differentiable_layer_through_the_real_fk() {
use ferromotion_core::{Iso, Joint, LinkInertia, Robot};
use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
let mk = |z: f64| Iso::from_parts(Translation3::new(0.0, 0.0, z), UnitQuaternion::identity());
let robot = Robot {
joints: vec![
Joint::revolute(mk(0.1), Vector3::z()),
Joint::revolute(mk(0.3), Vector3::y()),
Joint::revolute(mk(0.3), Vector3::y()),
],
ee_offset: Iso::identity(),
};
let inertia = vec![
LinkInertia { mass: 1.0, com: Vector3::zeros(), inertia: Matrix3::identity() * 0.01 };
3
];
let prob = IkResidual { robot, inertia };
let target = [0.25, 0.1, 0.45];
let opts = NlsOptions { max_iters: 200, ..Default::default() };
let sol = solve(&prob, &[0.3, -0.5, 0.8], &target, opts);
assert!(sol.cost < 1e-16, "IK must reach the target: cost {}", sol.cost);
let eps = 1e-6;
for k in 0..3 {
let got = sol.dtheta_dphi(&prob, &target, k);
let (mut tp, mut tm) = (target, target);
tp[k] += eps;
tm[k] -= eps;
let sp = solve(&prob, &sol.theta, &tp, opts);
let sm = solve(&prob, &sol.theta, &tm, opts);
for j in 0..3 {
let want = (sp.theta[j] - sm.theta[j]) / (2.0 * eps);
assert!((got[j] - want).abs() < 1e-4 * want.abs().max(1.0), "dq{j}/dtarget{k}: {} vs {want}", got[j]);
}
}
}
}