Skip to main content

franka/
lowpass_filter.rs

1//! First-order low-pass filters for control signals.
2//!
3//! Port of libfranka 0.21.2 `include/franka/lowpass_filter.h` and `src/lowpass_filter.cpp`.
4
5use nalgebra::{Matrix3, Matrix4, Quaternion, Rotation3, UnitQuaternion};
6
7use crate::error::{FrankaError, FrankaResult};
8use crate::math_utils::{linear_of, orthonormalized_rotation, translation_of};
9
10/// Maximum cutoff frequency. Port of `franka::kMaxCutoffFrequency`.
11pub const MAX_CUTOFF_FREQUENCY: f64 = 1000.0;
12/// Default cutoff frequency. Port of `franka::kDefaultCutoffFrequency`.
13pub const DEFAULT_CUTOFF_FREQUENCY: f64 = 100.0;
14
15/// Applies a first-order low-pass filter.
16///
17/// Port of `franka::lowpassFilter`.
18///
19/// * `sample_time` – sample time constant
20/// * `y` – current value of the signal to be filtered
21/// * `y_last` – value of the signal in the previous time step
22/// * `cutoff_frequency` – cutoff frequency of the low-pass filter
23///
24/// # Errors
25/// [`FrankaError::InvalidArgument`] if `sample_time` is negative, infinite or NaN, if
26/// `cutoff_frequency` is zero, negative, infinite or NaN, or if either signal value is infinite
27/// or NaN.
28pub fn low_pass_filter(
29    sample_time: f64,
30    y: f64,
31    y_last: f64,
32    cutoff_frequency: f64,
33) -> FrankaResult<f64> {
34    if sample_time < 0.0 || !sample_time.is_finite() {
35        return Err(FrankaError::InvalidArgument(
36            "lowpass-filter: sample_time is negative, infinite or NaN.".to_string(),
37        ));
38    }
39    if cutoff_frequency <= 0.0 || !cutoff_frequency.is_finite() {
40        return Err(FrankaError::InvalidArgument(
41            "lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN.".to_string(),
42        ));
43    }
44    if !y.is_finite() || !y_last.is_finite() {
45        return Err(FrankaError::InvalidArgument(
46            "lowpass-filter: current or past input value of the signal to be filtered is infinite \
47             or NaN."
48                .to_string(),
49        ));
50    }
51    let gain = gain(sample_time, cutoff_frequency);
52    Ok(gain * y + (1.0 - gain) * y_last)
53}
54
55/// Applies a first-order low-pass filter to the translation and spherical linear interpolation
56/// to the rotation of a transformation matrix which represents a Cartesian motion.
57///
58/// Port of `franka::cartesianLowpassFilter`. `y`, `y_last` and the return value are column-major
59/// 4x4 homogeneous transformation matrices.
60///
61/// # Errors
62/// [`FrankaError::InvalidArgument`] if `sample_time` is negative, infinite or NaN, if
63/// `cutoff_frequency` is zero, negative, infinite or NaN, or if an element of either matrix is
64/// infinite or NaN.
65pub fn cartesian_low_pass_filter(
66    sample_time: f64,
67    y: &[f64; 16],
68    y_last: &[f64; 16],
69    cutoff_frequency: f64,
70) -> FrankaResult<[f64; 16]> {
71    if sample_time < 0.0 || !sample_time.is_finite() {
72        return Err(FrankaError::InvalidArgument(
73            "Cartesian lowpass-filter: sample_time is negative, infinite or NaN.".to_string(),
74        ));
75    }
76    if cutoff_frequency <= 0.0 || !cutoff_frequency.is_finite() {
77        return Err(FrankaError::InvalidArgument(
78            "Cartesian lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN."
79                .to_string(),
80        ));
81    }
82    for i in 0..16 {
83        if !y[i].is_finite() || !y_last[i].is_finite() {
84            return Err(FrankaError::InvalidArgument(
85                "Cartesian lowpass-filter: current or past input value of the signal to be \
86                 filtered is infinite or NaN."
87                    .to_string(),
88            ));
89        }
90    }
91
92    let transform = Matrix4::from_column_slice(y);
93    let transform_last = Matrix4::from_column_slice(y_last);
94    // Eigen's Affine3d::rotation() orthonormalizes the linear part (polar decomposition); this is
95    // what lets the filter smooth barely-orthonormal input poses.
96    let orientation = quaternion_from_matrix(&orthonormalized_rotation(&linear_of(&transform)));
97    let orientation_last =
98        quaternion_from_matrix(&orthonormalized_rotation(&linear_of(&transform_last)));
99
100    let gain = gain(sample_time, cutoff_frequency);
101    let translation =
102        gain * translation_of(&transform) + (1.0 - gain) * translation_of(&transform_last);
103    let orientation = slerp(&orientation_last, gain, &orientation);
104
105    let rotation = UnitQuaternion::new_normalize(orientation).to_rotation_matrix();
106
107    let mut filtered_values = *y;
108    let rotation = rotation.matrix();
109    for col in 0..3 {
110        for row in 0..3 {
111            filtered_values[col * 4 + row] = rotation[(row, col)];
112        }
113    }
114    filtered_values[12] = translation[0];
115    filtered_values[13] = translation[1];
116    filtered_values[14] = translation[2];
117    Ok(filtered_values)
118}
119
120/// Filter gain, shared by both filters: `dt / (dt + 1 / (2 pi f_c))`.
121fn gain(sample_time: f64, cutoff_frequency: f64) -> f64 {
122    sample_time / (sample_time + (1.0 / (2.0 * std::f64::consts::PI * cutoff_frequency)))
123}
124
125fn quaternion_from_matrix(m: &Matrix3<f64>) -> Quaternion<f64> {
126    *UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(*m)).quaternion()
127}
128
129/// Port of `Eigen::QuaternionBase::slerp`, which interpolates along the shortest arc.
130fn slerp(from: &Quaternion<f64>, t: f64, to: &Quaternion<f64>) -> Quaternion<f64> {
131    const ONE: f64 = 1.0 - f64::EPSILON;
132    let d = from.dot(to);
133    let abs_d = d.abs();
134    let (scale0, mut scale1) = if abs_d >= ONE {
135        (1.0 - t, t)
136    } else {
137        let theta = abs_d.acos();
138        let sin_theta = theta.sin();
139        (
140            ((1.0 - t) * theta).sin() / sin_theta,
141            (t * theta).sin() / sin_theta,
142        )
143    };
144    if d < 0.0 {
145        scale1 = -scale1;
146    }
147    from * scale0 + to * scale1
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::math_utils::differentiate_one_sample_pose as differentiate_one_sample;
154
155    /// Builds a column-major pose array from a row-major 3x3 linear block and a translation.
156    fn pose(linear_row_major: [f64; 9], translation: [f64; 3]) -> [f64; 16] {
157        let mut out = [0.0; 16];
158        for row in 0..3 {
159            for col in 0..3 {
160                out[col * 4 + row] = linear_row_major[row * 3 + col];
161            }
162        }
163        out[12] = translation[0];
164        out[13] = translation[1];
165        out[14] = translation[2];
166        out[15] = 1.0;
167        out
168    }
169
170    // ---- TEST(LowpassFilter, KeepsValueIfNoChange) ----
171    #[test]
172    fn keeps_value_if_no_change() {
173        for cutoff in [100.0, 500.0, 1000.0] {
174            let filtered = low_pass_filter(0.001, 1.0, 1.0, cutoff).unwrap();
175            assert!((filtered - 1.0).abs() < 1e-6, "cutoff {cutoff}: {filtered}");
176        }
177    }
178
179    // ---- TEST(LowpassFilter, DoesFilter) ----
180    #[test]
181    fn does_filter() {
182        for (cutoff, expected) in [(100.0, 0.3859), (500.0, 0.7585), (900.0, 0.8497)] {
183            let filtered = low_pass_filter(0.001, 1.0, 0.0, cutoff).unwrap();
184            assert!(
185                (filtered - expected).abs() < 1e-4,
186                "cutoff {cutoff}: {filtered} vs {expected}"
187            );
188        }
189    }
190
191    // ---- TEST(CartesianLowpassFilter, CanFixNonOrthonormalRotation) ----
192    #[test]
193    fn can_fix_non_orthonormal_rotation() {
194        // These three poses are all only barely orthonormal, such that the filter will generate a
195        // jerky movement when it does not orthonormalize them before filtering.
196        let pose1 = pose(
197            [
198                0.00462567,
199                0.999974,
200                0.00335239, //
201                0.0145489,
202                -0.00341934,
203                0.999888, //
204                0.999874,
205                -0.00457638,
206                -0.0145646,
207            ],
208            [1.0, 1.0, 1.0],
209        );
210        let pose2 = pose(
211            [
212                0.00463526,
213                0.999984,
214                0.00335239, //
215                0.014549,
216                -0.00341951,
217                0.999888, //
218                0.999883,
219                -0.00458597,
220                -0.0145646,
221            ],
222            [1.0, 1.0, 1.0],
223        );
224        let pose3 = pose(
225            [
226                0.00465436,
227                0.999984,
228                0.00335239, //
229                0.0145489,
230                -0.00341979,
231                0.999888, //
232                0.999883,
233                -0.00460507,
234                -0.0145646,
235            ],
236            [1.0, 1.0, 1.0],
237        );
238
239        let output1 = cartesian_low_pass_filter(0.001, &pose2, &pose1, 100.0).unwrap();
240        let output2 = cartesian_low_pass_filter(0.001, &pose3, &pose2, 100.0).unwrap();
241        let velocity1 = differentiate_one_sample(&output1, &pose1, 0.001);
242        let velocity2 = differentiate_one_sample(&output2, &pose2, 0.001);
243
244        let mut jerk = [0.0; 6];
245        for i in 0..6 {
246            let acceleration1 = velocity1[i] / 0.001;
247            let acceleration2 = (velocity2[i] - velocity1[i]) / 0.001;
248            jerk[i] = (acceleration2 - acceleration1) / 0.001;
249        }
250        let total_jerk = (jerk[3].powi(2) + jerk[4].powi(2) + jerk[5].powi(2)).sqrt();
251        assert!(total_jerk < 1000.0, "total jerk {total_jerk}");
252    }
253
254    // ---- additional tests: the invalid-argument paths ----
255    #[test]
256    fn rejects_invalid_arguments() {
257        let message = |e: FrankaError| match e {
258            FrankaError::InvalidArgument(msg) => msg,
259            other => panic!("expected InvalidArgument, got {other:?}"),
260        };
261        let identity = pose([1., 0., 0., 0., 1., 0., 0., 0., 1.], [0., 0., 0.]);
262
263        for bad_sample_time in [-1e-3, f64::NAN, f64::INFINITY] {
264            assert_eq!(
265                message(low_pass_filter(bad_sample_time, 1.0, 0.0, 100.0).unwrap_err()),
266                "lowpass-filter: sample_time is negative, infinite or NaN."
267            );
268            assert_eq!(
269                message(
270                    cartesian_low_pass_filter(bad_sample_time, &identity, &identity, 100.0)
271                        .unwrap_err()
272                ),
273                "Cartesian lowpass-filter: sample_time is negative, infinite or NaN."
274            );
275        }
276        for bad_cutoff in [0.0, -1.0, f64::NAN, f64::INFINITY] {
277            assert_eq!(
278                message(low_pass_filter(0.001, 1.0, 0.0, bad_cutoff).unwrap_err()),
279                "lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN."
280            );
281            assert_eq!(
282                message(
283                    cartesian_low_pass_filter(0.001, &identity, &identity, bad_cutoff).unwrap_err()
284                ),
285                "Cartesian lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN."
286            );
287        }
288        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
289            assert_eq!(
290                message(low_pass_filter(0.001, bad, 0.0, 100.0).unwrap_err()),
291                "lowpass-filter: current or past input value of the signal to be filtered is \
292                 infinite or NaN."
293            );
294            assert_eq!(
295                message(low_pass_filter(0.001, 1.0, bad, 100.0).unwrap_err()),
296                "lowpass-filter: current or past input value of the signal to be filtered is \
297                 infinite or NaN."
298            );
299            let mut bad_pose = identity;
300            bad_pose[5] = bad;
301            assert_eq!(
302                message(cartesian_low_pass_filter(0.001, &bad_pose, &identity, 100.0).unwrap_err()),
303                "Cartesian lowpass-filter: current or past input value of the signal to be \
304                 filtered is infinite or NaN."
305            );
306            assert_eq!(
307                message(cartesian_low_pass_filter(0.001, &identity, &bad_pose, 100.0).unwrap_err()),
308                "Cartesian lowpass-filter: current or past input value of the signal to be \
309                 filtered is infinite or NaN."
310            );
311        }
312    }
313
314    #[test]
315    fn cartesian_filter_keeps_pose_if_no_change() {
316        let p = pose(
317            [
318                0.0, -1.0, 0.0, //
319                1.0, 0.0, 0.0, //
320                0.0, 0.0, 1.0,
321            ],
322            [0.3, -0.2, 0.5],
323        );
324        let filtered = cartesian_low_pass_filter(0.001, &p, &p, 100.0).unwrap();
325        for i in 0..16 {
326            assert!(
327                (filtered[i] - p[i]).abs() < 1e-12,
328                "element {i}: {} vs {}",
329                filtered[i],
330                p[i]
331            );
332        }
333    }
334
335    #[test]
336    fn constants_match_libfranka() {
337        assert_eq!(MAX_CUTOFF_FREQUENCY, 1000.0);
338        assert_eq!(DEFAULT_CUTOFF_FREQUENCY, 100.0);
339    }
340}