Skip to main content

dsfb_robotics/
kinematics.rs

1//! Shared residual helper for kinematic-identification datasets
2//! (KUKA LWR, Franka Panda, DLR Rollin' Justin / LWR-III, UR10).
3//!
4//! All four datasets share the same residual form:
5//!
6//! ```text
7//! r_τ(k) = τ_measured(k) − τ_predicted(q(k), q̇(k), q̈(k); θ̂)
8//! ```
9//!
10//! where `θ̂` is the **published identified parameter vector** for the
11//! specific arm. Each per-dataset adapter embeds its own `θ̂` as a
12//! `const` so the residual is reproducible without an in-tree
13//! identification step.
14//!
15//! This module provides the joint-aggregation primitive that turns a
16//! per-joint torque residual sample into a scalar residual norm that
17//! the core `observe()` pipeline ingests. Phase 2 provides the
18//! function; Phase 3 wires it into each per-dataset adapter with the
19//! appropriate `θ̂`.
20
21use crate::math;
22
23/// Side of the torque-sensing instrument.
24///
25/// KUKA LWR and DLR Rollin' Justin instrument **link-side** torque
26/// directly. Franka Panda and UR10 measure **motor-side** current and
27/// reconstruct torque post-transmission. The side changes the noise
28/// floor and the residual's expected amplitude, but not its structural
29/// grammar — DSFB treats both uniformly once the sample is in
30/// `f64` newton-metres.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub enum TorqueSensorSide {
34    /// Direct link-side torque sensing (e.g. KUKA LWR, DLR LWR-III).
35    Link,
36    /// Motor-side current × torque constant (e.g. Franka Panda, UR10).
37    Motor,
38}
39
40impl TorqueSensorSide {
41    /// Stable label for logging / JSON emission.
42    #[inline]
43    #[must_use]
44    pub const fn label(self) -> &'static str {
45        match self {
46            Self::Link => "LinkSide",
47            Self::Motor => "MotorSide",
48        }
49    }
50}
51
52/// Aggregate a per-joint torque-residual sample into a scalar residual
53/// norm.
54///
55/// Accepts a slice of per-joint `r_τ(k)` values (N-DoF arm → N-length
56/// slice) and returns the 2-norm `‖r_τ(k)‖ = sqrt(Σ r_τ_i²)`.
57///
58/// Returns `None` if the slice is empty or contains only non-finite
59/// samples. Non-finite per-joint samples are **skipped**
60/// (missingness-aware); a sample with *some* finite joints is still
61/// aggregated over the finite ones.
62#[must_use]
63pub fn joint_residual_norm(per_joint_tau_residual: &[f64]) -> Option<f64> {
64    debug_assert!(per_joint_tau_residual.len() <= 32, "per-joint slice fits a typical 7-DoF arm + safety margin");
65    let mut ssq = 0.0_f64;
66    let mut n = 0_usize;
67    for &r in per_joint_tau_residual {
68        if r.is_finite() {
69            debug_assert!(r * r >= 0.0, "squared finite f64 is non-negative");
70            ssq += r * r;
71            n += 1;
72        }
73    }
74    debug_assert!(n <= per_joint_tau_residual.len(), "finite-count cannot exceed slice length");
75    if n == 0 {
76        return None;
77    }
78    debug_assert!(ssq.is_finite() && ssq >= 0.0, "ssq must be a finite non-negative aggregate");
79    math::sqrt_f64(ssq)
80}
81
82/// Compute the residual for one timestep, given measured and predicted
83/// per-joint torques.
84///
85/// Returns `Some(‖τ_meas − τ_pred‖)` when the two slices have matching
86/// length and at least one joint is finite on both sides; `None`
87/// otherwise. This is the small helper that per-dataset adapters
88/// compose into a streaming residual sequence.
89#[must_use]
90pub fn tau_residual_norm(tau_measured: &[f64], tau_predicted: &[f64]) -> Option<f64> {
91    debug_assert!(tau_measured.len() <= 32, "torque slice within typical arm DoF + margin");
92    debug_assert!(tau_predicted.len() <= 32, "torque slice within typical arm DoF + margin");
93    if tau_measured.len() != tau_predicted.len() {
94        return None;
95    }
96    if tau_measured.is_empty() {
97        return None;
98    }
99    debug_assert_eq!(tau_measured.len(), tau_predicted.len(), "lengths matched after early returns");
100    let mut ssq = 0.0_f64;
101    let mut n = 0_usize;
102    let mut i = 0_usize;
103    while i < tau_measured.len() {
104        let m = tau_measured[i];
105        let p = tau_predicted[i];
106        if m.is_finite() && p.is_finite() {
107            let d = m - p;
108            debug_assert!(d.is_finite(), "diff of finite operands is finite");
109            ssq += d * d;
110            n += 1;
111        }
112        i += 1;
113    }
114    debug_assert!(n <= tau_measured.len(), "finite-count bounded by slice length");
115    if n == 0 {
116        return None;
117    }
118    debug_assert!(ssq >= 0.0, "ssq is a sum of non-negative squares");
119    math::sqrt_f64(ssq)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn empty_slice_is_none() {
128        assert!(joint_residual_norm(&[]).is_none());
129    }
130
131    #[test]
132    fn all_nan_slice_is_none() {
133        assert!(joint_residual_norm(&[f64::NAN, f64::NAN]).is_none());
134    }
135
136    #[test]
137    fn single_joint_norm_is_absolute_value() {
138        let r = joint_residual_norm(&[0.3]).expect("finite");
139        assert!((r - 0.3).abs() < 1e-12);
140        let r2 = joint_residual_norm(&[-0.3]).expect("finite");
141        assert!((r2 - 0.3).abs() < 1e-12);
142    }
143
144    #[test]
145    fn three_joint_norm_is_euclidean() {
146        // 3-4-5 triangle.
147        let r = joint_residual_norm(&[3.0, 4.0, 0.0]).expect("finite");
148        assert!((r - 5.0).abs() < 1e-12);
149    }
150
151    #[test]
152    fn tau_residual_respects_lengths() {
153        assert!(tau_residual_norm(&[1.0, 2.0], &[1.0]).is_none());
154        assert!(tau_residual_norm(&[], &[]).is_none());
155    }
156
157    #[test]
158    fn tau_residual_is_difference_norm() {
159        let meas = [1.0, 2.0, 3.0];
160        let pred = [1.0, 2.0, 3.0];
161        let r = tau_residual_norm(&meas, &pred).expect("finite");
162        assert!(r.abs() < 1e-12);
163        let pred_off = [1.3, 2.4, 3.0];
164        let r2 = tau_residual_norm(&meas, &pred_off).expect("finite");
165        // diff = (0.3, 0.4, 0) → 0.5
166        assert!((r2 - 0.5).abs() < 1e-12);
167    }
168
169    #[test]
170    fn tau_residual_skips_non_finite_joints() {
171        let meas = [1.0, f64::NAN, 3.0];
172        let pred = [1.0, 2.0, 3.0];
173        let r = tau_residual_norm(&meas, &pred).expect("at least one finite joint");
174        assert!(r.abs() < 1e-12);
175    }
176
177    #[test]
178    fn torque_side_labels_are_stable() {
179        assert_eq!(TorqueSensorSide::Link.label(), "LinkSide");
180        assert_eq!(TorqueSensorSide::Motor.label(), "MotorSide");
181    }
182}