ferromotion-learn 0.42.0

Differentiable physics-informed learning for ferromotion — reverse-mode autodiff, PINNs, Lagrangian/Hamiltonian nets, Neural ODEs, SINDy, and more, pure-Rust and wasm-clean.
Documentation
//! **Deep Lagrangian Network (DeLaN)** — the middle ground between a black box and hand-derived dynamics.
//! Instead of learning the map `(q, q̇, q̈) → τ` directly, it keeps the *structure* of the manipulator equation
//! `M(q) q̈ + C(q,q̇) q̇ + G(q) = τ` and learns only the physically-meaningful pieces: the mass matrix `M(q)`
//! and the potential `V(q)`. Two structural guarantees come for free:
//!
//! - **`M(q)` is symmetric positive-definite by construction.** The network outputs the entries of a
//!   lower-triangular `L(q)` (diagonal made positive with softplus) and `M = L Lᵀ` — a valid inertia matrix
//!   for any weights, never singular or indefinite.
//! - **The Coriolis/centrifugal term is not learned separately.** `C(q,q̇) q̇` is *computed* from `∂M/∂q` via
//!   the Christoffel symbols — and `∂M/∂q` is obtained exactly by autodiff (a first-order jet in `q` on the
//!   tape), the DeLaN trick. Gravity is `G(q) = ∂V/∂q`, likewise exact.
//!
//! The whole predicted torque is assembled from these and trained to match measured `τ`; because every
//! component is a tape [`Var`], one backward pass gives the exact parameter gradient. Verified against
//! ferromotion-core's own recursive-Newton–Euler dynamics for a 2-link pendulum: the learned inverse dynamics
//! match, and the recovered `M(q)` matches the true mass matrix.

use crate::autodiff::{Tape, Var};

/// A Deep Lagrangian Network for a 2-DOF system: learns `L(q)` (→ `M = LLᵀ`) and `V(q)`.
pub struct Delan {
    sizes: Vec<usize>, // [2, H, H, 4] → outputs (L11_raw, L21, L22_raw, V)
    params: Vec<f64>,
    m: Vec<f64>,
    v: Vec<f64>,
    t: u64,
}

fn splitmix64(state: &mut u64) -> u64 {
    *state = state.wrapping_add(0x9E3779B97F4A7C15);
    let mut z = *state;
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
    z ^ (z >> 31)
}

// First-order jet in (q0, q1): (value, ∂/∂q0, ∂/∂q1), each a tape Var.
type J<'t> = (Var<'t>, Var<'t>, Var<'t>);

fn j_add<'t>(a: J<'t>, b: J<'t>) -> J<'t> {
    (a.0 + b.0, a.1 + b.1, a.2 + b.2)
}
fn j_scale<'t>(w: Var<'t>, a: J<'t>) -> J<'t> {
    (w * a.0, w * a.1, w * a.2)
}
fn j_mul<'t>(a: J<'t>, b: J<'t>) -> J<'t> {
    (a.0 * b.0, a.0 * b.1 + a.1 * b.0, a.0 * b.2 + a.2 * b.0)
}
fn j_tanh<'t>(g: J<'t>) -> J<'t> {
    let f0 = g.0.tanh();
    let fp = f0 * f0 * (-1.0) + 1.0;
    (f0, fp * g.1, fp * g.2)
}
fn j_softplus<'t>(g: J<'t>) -> J<'t> {
    // softplus(x) = ln(1+eˣ); softplus'(x) = σ(x)
    let val = (g.0.exp() + 1.0).ln();
    let s = g.0.sigmoid();
    (val, s * g.1, s * g.2)
}
// ∂(entry)/∂q_k from an entry jet (k = 0 or 1)
fn dqk<'t>(ent: J<'t>, k: usize) -> Var<'t> {
    if k == 0 { ent.1 } else { ent.2 }
}

impl Delan {
    /// A DeLaN with the given hidden width.
    pub fn new(hidden: usize, seed: u64) -> Self {
        let sizes = vec![2, hidden, hidden, 4];
        let mut state = seed ^ 0x2468_ACE0_1357_9BDF;
        let mut params = Vec::new();
        for l in 0..sizes.len() - 1 {
            let (ind, outd) = (sizes[l], sizes[l + 1]);
            let r = (6.0 / (ind + outd) as f64).sqrt();
            for _ in 0..ind * outd {
                let u = (splitmix64(&mut state) as f64 / u64::MAX as f64) * 2.0 - 1.0;
                params.push(u * r);
            }
            params.extend(std::iter::repeat_n(0.0, outd));
        }
        let n = params.len();
        Delan { sizes, params, m: vec![0.0; n], v: vec![0.0; n], t: 0 }
    }

    // Forward as jets in (q0,q1); returns the 4 raw output jets.
    fn forward_jet<'t>(&self, tape: &'t Tape, pv: &[Var<'t>], q0: f64, q1: f64, zero: Var<'t>, one: Var<'t>) -> Vec<J<'t>> {
        let mut a: Vec<J<'t>> = vec![(tape.constant(q0), one, zero), (tape.constant(q1), zero, one)];
        let mut off = 0;
        let layers = self.sizes.len() - 1;
        for l in 0..layers {
            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
            let mut z: Vec<J<'t>> = Vec::with_capacity(outd);
            for o in 0..outd {
                let mut s: J<'t> = (pv[off + ind * outd + o], zero, zero);
                for (i, &ai) in a.iter().enumerate() {
                    s = j_add(s, j_scale(pv[off + o * ind + i], ai));
                }
                z.push(if l + 1 < layers { j_tanh(s) } else { s });
            }
            off += ind * outd + outd;
            a = z;
        }
        a
    }

    // Build the mass-matrix entry jets (m00, m01, m11) and the potential jet from raw outputs.
    fn assemble<'t>(raw: &[J<'t>]) -> (J<'t>, J<'t>, J<'t>, J<'t>) {
        let l11 = j_softplus(raw[0]); // positive diagonal
        let l21 = raw[1];
        let l22 = j_softplus(raw[2]);
        // M = L Lᵀ with L = [[l11,0],[l21,l22]]
        let m00 = j_mul(l11, l11);
        let m01 = j_mul(l11, l21);
        let m11 = j_add(j_mul(l21, l21), j_mul(l22, l22));
        let vpot = raw[3];
        (m00, m01, m11, vpot)
    }

    /// One Adam step matching the structured predicted torque to measured `τ`. Returns the loss.
    pub fn train_step(&mut self, q: &[[f64; 2]], qd: &[[f64; 2]], qdd: &[[f64; 2]], tau: &[[f64; 2]], lr: f64) -> f64 {
        let tape = Tape::new();
        let pv: Vec<Var> = self.params.iter().map(|&x| tape.var(x)).collect();
        let zero = tape.constant(0.0);
        let one = tape.constant(1.0);
        let mut loss = tape.constant(0.0);

        for s in 0..q.len() {
            let raw = self.forward_jet(&tape, &pv, q[s][0], q[s][1], zero, one);
            let (m00, m01, m11, vpot) = Self::assemble(&raw);
            // dm[i][j][k] = ∂M_ij/∂q_k  (symmetric in i,j)
            let mm = [[m00, m01], [m01, m11]]; // value-jets, symmetric
            let qdv = [qd[s][0], qd[s][1]];
            // Christoffel: Cq̇_k = Σ_ij ½(∂M_ki/∂q_j + ∂M_kj/∂q_i − ∂M_ij/∂q_k) q̇_i q̇_j
            for k in 0..2 {
                let mut cq = tape.constant(0.0);
                for i in 0..2 {
                    for j in 0..2 {
                        let term = dqk(mm[k][i], j) + dqk(mm[k][j], i) - dqk(mm[i][j], k);
                        cq = cq + term * (0.5 * qdv[i] * qdv[j]);
                    }
                }
                // G_k = ∂V/∂q_k
                let g = if k == 0 { vpot.1 } else { vpot.2 };
                // τ_pred_k = Σ_j M_kj q̈_j + Cq̇_k + G_k
                let tau_pred = mm[k][0].0 * qdd[s][0] + mm[k][1].0 * qdd[s][1] + cq + g;
                let e = tau_pred - tau[s][k];
                loss = loss + e * e;
            }
        }
        loss = loss * (1.0 / (q.len() * 2) as f64);
        let grad_g = loss.backward();
        let grad: Vec<f64> = pv.iter().map(|&x| grad_g.wrt(x)).collect();

        self.t += 1;
        let (b1, b2, eps) = (0.9_f64, 0.999_f64, 1e-8);
        let bc1 = 1.0 - b1.powi(self.t as i32);
        let bc2 = 1.0 - b2.powi(self.t as i32);
        for (i, &gi) in grad.iter().enumerate() {
            self.m[i] = b1 * self.m[i] + (1.0 - b1) * gi;
            self.v[i] = b2 * self.v[i] + (1.0 - b2) * gi * gi;
            self.params[i] -= lr * (self.m[i] / bc1) / ((self.v[i] / bc2).sqrt() + eps);
        }
        loss.value()
    }

    /// Train for `epochs` steps; returns the final loss.
    pub fn train(&mut self, q: &[[f64; 2]], qd: &[[f64; 2]], qdd: &[[f64; 2]], tau: &[[f64; 2]], epochs: usize, lr: f64) -> f64 {
        let mut l = f64::INFINITY;
        for _ in 0..epochs {
            l = self.train_step(q, qd, qdd, tau, lr);
        }
        l
    }

    // Plain-f64 forward with q-derivatives: returns for each of the 4 outputs (value, ∂/∂q0, ∂/∂q1).
    fn forward_plain(&self, q0: f64, q1: f64) -> Vec<(f64, f64, f64)> {
        let mut a: Vec<(f64, f64, f64)> = vec![(q0, 1.0, 0.0), (q1, 0.0, 1.0)];
        let mut off = 0;
        let layers = self.sizes.len() - 1;
        for l in 0..layers {
            let (ind, outd) = (self.sizes[l], self.sizes[l + 1]);
            let mut z = Vec::with_capacity(outd);
            for o in 0..outd {
                let (mut v0, mut v1, mut v2) = (self.params[off + ind * outd + o], 0.0, 0.0);
                for (i, ai) in a.iter().enumerate() {
                    let w = self.params[off + o * ind + i];
                    v0 += w * ai.0;
                    v1 += w * ai.1;
                    v2 += w * ai.2;
                }
                if l + 1 < layers {
                    let t = v0.tanh();
                    let fp = 1.0 - t * t;
                    z.push((t, fp * v1, fp * v2));
                } else {
                    z.push((v0, v1, v2));
                }
            }
            off += ind * outd + outd;
            a = z;
        }
        a
    }

    /// The learned mass matrix at `q`, returned as `[m00, m01, m11]` (symmetric).
    pub fn mass(&self, q: &[f64]) -> [f64; 3] {
        let raw = self.forward_plain(q[0], q[1]);
        let l11 = softplus(raw[0].0);
        let l21 = raw[1].0;
        let l22 = softplus(raw[2].0);
        [l11 * l11, l11 * l21, l21 * l21 + l22 * l22]
    }

    /// The learned predicted torque `τ` for a full `(q, q̇, q̈)`.
    pub fn predict_tau(&self, q: &[f64], qd: &[f64], qdd: &[f64]) -> [f64; 2] {
        let raw = self.forward_plain(q[0], q[1]);
        let l11 = softplus(raw[0].0);
        let sp0 = sigmoid(raw[0].0);
        let l21 = raw[1].0;
        let l22 = softplus(raw[2].0);
        let sp2 = sigmoid(raw[2].0);
        // M entries and their q-derivatives
        let m = [l11 * l11, l11 * l21, l21 * l21 + l22 * l22]; // m00,m01,m11
        // ∂l/∂qk
        let dl11 = [sp0 * raw[0].1, sp0 * raw[0].2];
        let dl21 = [raw[1].1, raw[1].2];
        let dl22 = [sp2 * raw[2].1, sp2 * raw[2].2];
        // ∂M/∂qk
        let dm00 = [2.0 * l11 * dl11[0], 2.0 * l11 * dl11[1]];
        let dm01 = [dl11[0] * l21 + l11 * dl21[0], dl11[1] * l21 + l11 * dl21[1]];
        let dm11 = [2.0 * l21 * dl21[0] + 2.0 * l22 * dl22[0], 2.0 * l21 * dl21[1] + 2.0 * l22 * dl22[1]];
        let mm = [[m[0], m[1]], [m[1], m[2]]];
        let dmm = [[dm00, dm01], [dm01, dm11]];
        let g = [raw[3].1, raw[3].2]; // ∂V/∂q
        let mut tau = [0.0f64; 2];
        for k in 0..2 {
            let mut cq = 0.0;
            for i in 0..2 {
                for j in 0..2 {
                    cq += 0.5 * (dmm[k][i][j] + dmm[k][j][i] - dmm[i][j][k]) * qd[i] * qd[j];
                }
            }
            tau[k] = mm[k][0] * qdd[0] + mm[k][1] * qdd[1] + cq + g[k];
        }
        tau
    }
}

fn softplus(x: f64) -> f64 {
    (x.exp() + 1.0).ln()
}
fn sigmoid(x: f64) -> f64 {
    1.0 / (1.0 + (-x).exp())
}

#[cfg(test)]
mod tests {
    use super::*;
    use ferromotion_core::{from_urdf_full, inverse_dynamics, mass_matrix, LinkInertia, Robot};
    use nalgebra::Vector3;

    const URDF: &str = r#"<robot name="dpend">
      <link name="base"/>
      <link name="l1"><inertial><origin xyz="0.5 0 0" rpy="0 0 0"/><mass value="1.0"/>
        <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.083" iyz="0" izz="0.083"/></inertial></link>
      <link name="l2"><inertial><origin xyz="0.5 0 0" rpy="0 0 0"/><mass value="1.0"/>
        <inertia ixx="0.01" ixy="0" ixz="0" iyy="0.083" iyz="0" izz="0.083"/></inertial></link>
      <link name="tool"/>
      <joint name="j1" type="revolute"><parent link="base"/><child link="l1"/><origin xyz="0 0 0" rpy="0 0 0"/>
        <axis xyz="0 1 0"/><limit lower="-6.3" upper="6.3" effort="100" velocity="100"/></joint>
      <joint name="j2" type="revolute"><parent link="l1"/><child link="l2"/><origin xyz="1 0 0" rpy="0 0 0"/>
        <axis xyz="0 1 0"/><limit lower="-6.3" upper="6.3" effort="100" velocity="100"/></joint>
      <joint name="jt" type="fixed"><parent link="l2"/><child link="tool"/><origin xyz="1 0 0" rpy="0 0 0"/></joint>
    </robot>"#;

    fn gen_data(robot: &Robot, inertia: &[LinkInertia], n: usize) -> (Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[f64; 2]>) {
        let mut s = 987u64;
        let mut rnd = |lo: f64, hi: f64| {
            let u = splitmix64(&mut s) as f64 / u64::MAX as f64;
            lo + u * (hi - lo)
        };
        let g = Vector3::new(0.0, 0.0, -9.81);
        let (mut q, mut qd, mut qdd, mut tau) = (vec![], vec![], vec![], vec![]);
        for _ in 0..n {
            let qi = [rnd(-2.0, 2.0), rnd(-2.0, 2.0)];
            let qdi = [rnd(-1.5, 1.5), rnd(-1.5, 1.5)];
            let qddi = [rnd(-2.0, 2.0), rnd(-2.0, 2.0)];
            let t = inverse_dynamics(robot, inertia, &qi, &qdi, &qddi, g);
            q.push(qi);
            qd.push(qdi);
            qdd.push(qddi);
            tau.push([t[0], t[1]]);
        }
        (q, qd, qdd, tau)
    }

    fn rmse(delan: &Delan, tq: &[[f64; 2]], tqd: &[[f64; 2]], tqdd: &[[f64; 2]], ttau: &[[f64; 2]]) -> f64 {
        let mut err = 0.0;
        for i in 0..tq.len() {
            let p = delan.predict_tau(&tq[i], &tqd[i], &tqdd[i]);
            err += (p[0] - ttau[i][0]).powi(2) + (p[1] - ttau[i][1]).powi(2);
        }
        (err / (tq.len() * 2) as f64).sqrt()
    }

    #[test]
    fn delan_learns_structured_dynamics_and_keeps_mass_matrix_spd() {
        // Fast default test: the structured model trains (RMSE falls far below its untrained value) and the
        // learned M(q) is symmetric-positive-definite at every configuration — the Cholesky guarantee, which
        // holds for ANY weights, trained or not.
        let (robot, inertia) = from_urdf_full(URDF, "base", "tool").unwrap();
        let (q, qd, qdd, tau) = gen_data(&robot, &inertia, 80);
        let (tq, tqd, tqdd, ttau) = gen_data(&robot, &inertia, 30);
        let mut delan = Delan::new(20, 5);
        let before = rmse(&delan, &tq, &tqd, &tqdd, &ttau);
        delan.train(&q, &qd, &qdd, &tau, 1500, 4e-3);
        let after = rmse(&delan, &tq, &tqd, &tqdd, &ttau);
        assert!(after < before * 0.3, "structured dynamics should train: rmse {before} → {after}");

        // SPD at several configurations (the structural guarantee)
        for &qc in &[[0.0, 0.0], [0.3, 0.7], [-1.2, 0.9], [2.0, -1.5]] {
            let m = delan.mass(&qc);
            let det = m[0] * m[2] - m[1] * m[1];
            assert!(m[0] > 0.0 && m[2] > 0.0 && det > 0.0, "M must be SPD at {qc:?}: {m:?}");
        }
    }

    #[test]
    #[ignore = "slow (~30s): recovers M(q) to within ~0.05 of ferromotion's true mass matrix; run explicitly"]
    fn delan_recovers_the_true_mass_matrix() {
        // The strong claim, verified with enough data + training: the learned M(q) matches ferromotion's own
        // recursive-Newton–Euler mass matrix.
        let (robot, inertia) = from_urdf_full(URDF, "base", "tool").unwrap();
        let (q, qd, qdd, tau) = gen_data(&robot, &inertia, 200);
        let mut delan = Delan::new(24, 5);
        delan.train(&q, &qd, &qdd, &tau, 8000, 3e-3);
        let qc = [0.3, 0.7];
        let m_true = mass_matrix(&robot, &inertia, &qc);
        let m_hat = delan.mass(&qc);
        assert!((m_hat[0] - m_true[(0, 0)]).abs() < 0.15, "M00: {} vs {}", m_hat[0], m_true[(0, 0)]);
        assert!((m_hat[1] - m_true[(0, 1)]).abs() < 0.15, "M01: {} vs {}", m_hat[1], m_true[(0, 1)]);
        assert!((m_hat[2] - m_true[(1, 1)]).abs() < 0.15, "M11: {} vs {}", m_hat[2], m_true[(1, 1)]);
    }
}