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))`.
121///
122/// `pub(crate)` so a realtime caller that has already validated its inputs can use the one
123/// formula without the fallible wrapper's error path; there must not be a second copy of it.
124pub(crate) fn gain(sample_time: f64, cutoff_frequency: f64) -> f64 {
125    sample_time / (sample_time + (1.0 / (2.0 * std::f64::consts::PI * cutoff_frequency)))
126}
127
128fn quaternion_from_matrix(m: &Matrix3<f64>) -> Quaternion<f64> {
129    *UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(*m)).quaternion()
130}
131
132/// Port of `Eigen::QuaternionBase::slerp`, which interpolates along the shortest arc.
133fn slerp(from: &Quaternion<f64>, t: f64, to: &Quaternion<f64>) -> Quaternion<f64> {
134    const ONE: f64 = 1.0 - f64::EPSILON;
135    let d = from.dot(to);
136    let abs_d = d.abs();
137    let (scale0, mut scale1) = if abs_d >= ONE {
138        (1.0 - t, t)
139    } else {
140        let theta = abs_d.acos();
141        let sin_theta = theta.sin();
142        (
143            ((1.0 - t) * theta).sin() / sin_theta,
144            (t * theta).sin() / sin_theta,
145        )
146    };
147    if d < 0.0 {
148        scale1 = -scale1;
149    }
150    from * scale0 + to * scale1
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::math_utils::differentiate_one_sample_pose as differentiate_one_sample;
157
158    /// Builds a column-major pose array from a row-major 3x3 linear block and a translation.
159    fn pose(linear_row_major: [f64; 9], translation: [f64; 3]) -> [f64; 16] {
160        let mut out = [0.0; 16];
161        for row in 0..3 {
162            for col in 0..3 {
163                out[col * 4 + row] = linear_row_major[row * 3 + col];
164            }
165        }
166        out[12] = translation[0];
167        out[13] = translation[1];
168        out[14] = translation[2];
169        out[15] = 1.0;
170        out
171    }
172
173    // ---- TEST(LowpassFilter, KeepsValueIfNoChange) ----
174    #[test]
175    fn keeps_value_if_no_change() {
176        for cutoff in [100.0, 500.0, 1000.0] {
177            let filtered = low_pass_filter(0.001, 1.0, 1.0, cutoff).unwrap();
178            assert!((filtered - 1.0).abs() < 1e-6, "cutoff {cutoff}: {filtered}");
179        }
180    }
181
182    // ---- TEST(LowpassFilter, DoesFilter) ----
183    #[test]
184    fn does_filter() {
185        for (cutoff, expected) in [(100.0, 0.3859), (500.0, 0.7585), (900.0, 0.8497)] {
186            let filtered = low_pass_filter(0.001, 1.0, 0.0, cutoff).unwrap();
187            assert!(
188                (filtered - expected).abs() < 1e-4,
189                "cutoff {cutoff}: {filtered} vs {expected}"
190            );
191        }
192    }
193
194    // ---- TEST(CartesianLowpassFilter, CanFixNonOrthonormalRotation) ----
195    #[test]
196    fn can_fix_non_orthonormal_rotation() {
197        // These three poses are all only barely orthonormal, such that the filter will generate a
198        // jerky movement when it does not orthonormalize them before filtering.
199        let pose1 = pose(
200            [
201                0.00462567,
202                0.999974,
203                0.00335239, //
204                0.0145489,
205                -0.00341934,
206                0.999888, //
207                0.999874,
208                -0.00457638,
209                -0.0145646,
210            ],
211            [1.0, 1.0, 1.0],
212        );
213        let pose2 = pose(
214            [
215                0.00463526,
216                0.999984,
217                0.00335239, //
218                0.014549,
219                -0.00341951,
220                0.999888, //
221                0.999883,
222                -0.00458597,
223                -0.0145646,
224            ],
225            [1.0, 1.0, 1.0],
226        );
227        let pose3 = pose(
228            [
229                0.00465436,
230                0.999984,
231                0.00335239, //
232                0.0145489,
233                -0.00341979,
234                0.999888, //
235                0.999883,
236                -0.00460507,
237                -0.0145646,
238            ],
239            [1.0, 1.0, 1.0],
240        );
241
242        let output1 = cartesian_low_pass_filter(0.001, &pose2, &pose1, 100.0).unwrap();
243        let output2 = cartesian_low_pass_filter(0.001, &pose3, &pose2, 100.0).unwrap();
244        let velocity1 = differentiate_one_sample(&output1, &pose1, 0.001);
245        let velocity2 = differentiate_one_sample(&output2, &pose2, 0.001);
246
247        let mut jerk = [0.0; 6];
248        for i in 0..6 {
249            let acceleration1 = velocity1[i] / 0.001;
250            let acceleration2 = (velocity2[i] - velocity1[i]) / 0.001;
251            jerk[i] = (acceleration2 - acceleration1) / 0.001;
252        }
253        let total_jerk = (jerk[3].powi(2) + jerk[4].powi(2) + jerk[5].powi(2)).sqrt();
254        assert!(total_jerk < 1000.0, "total jerk {total_jerk}");
255    }
256
257    // ---- additional tests: the invalid-argument paths ----
258    #[test]
259    fn rejects_invalid_arguments() {
260        let message = |e: FrankaError| match e {
261            FrankaError::InvalidArgument(msg) => msg,
262            other => panic!("expected InvalidArgument, got {other:?}"),
263        };
264        let identity = pose([1., 0., 0., 0., 1., 0., 0., 0., 1.], [0., 0., 0.]);
265
266        for bad_sample_time in [-1e-3, f64::NAN, f64::INFINITY] {
267            assert_eq!(
268                message(low_pass_filter(bad_sample_time, 1.0, 0.0, 100.0).unwrap_err()),
269                "lowpass-filter: sample_time is negative, infinite or NaN."
270            );
271            assert_eq!(
272                message(
273                    cartesian_low_pass_filter(bad_sample_time, &identity, &identity, 100.0)
274                        .unwrap_err()
275                ),
276                "Cartesian lowpass-filter: sample_time is negative, infinite or NaN."
277            );
278        }
279        for bad_cutoff in [0.0, -1.0, f64::NAN, f64::INFINITY] {
280            assert_eq!(
281                message(low_pass_filter(0.001, 1.0, 0.0, bad_cutoff).unwrap_err()),
282                "lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN."
283            );
284            assert_eq!(
285                message(
286                    cartesian_low_pass_filter(0.001, &identity, &identity, bad_cutoff).unwrap_err()
287                ),
288                "Cartesian lowpass-filter: cutoff_frequency is zero, negative, infinite or NaN."
289            );
290        }
291        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
292            assert_eq!(
293                message(low_pass_filter(0.001, bad, 0.0, 100.0).unwrap_err()),
294                "lowpass-filter: current or past input value of the signal to be filtered is \
295                 infinite or NaN."
296            );
297            assert_eq!(
298                message(low_pass_filter(0.001, 1.0, bad, 100.0).unwrap_err()),
299                "lowpass-filter: current or past input value of the signal to be filtered is \
300                 infinite or NaN."
301            );
302            let mut bad_pose = identity;
303            bad_pose[5] = bad;
304            assert_eq!(
305                message(cartesian_low_pass_filter(0.001, &bad_pose, &identity, 100.0).unwrap_err()),
306                "Cartesian lowpass-filter: current or past input value of the signal to be \
307                 filtered is infinite or NaN."
308            );
309            assert_eq!(
310                message(cartesian_low_pass_filter(0.001, &identity, &bad_pose, 100.0).unwrap_err()),
311                "Cartesian lowpass-filter: current or past input value of the signal to be \
312                 filtered is infinite or NaN."
313            );
314        }
315    }
316
317    #[test]
318    fn cartesian_filter_keeps_pose_if_no_change() {
319        let p = pose(
320            [
321                0.0, -1.0, 0.0, //
322                1.0, 0.0, 0.0, //
323                0.0, 0.0, 1.0,
324            ],
325            [0.3, -0.2, 0.5],
326        );
327        let filtered = cartesian_low_pass_filter(0.001, &p, &p, 100.0).unwrap();
328        for i in 0..16 {
329            assert!(
330                (filtered[i] - p[i]).abs() < 1e-12,
331                "element {i}: {} vs {}",
332                filtered[i],
333                p[i]
334            );
335        }
336    }
337
338    #[test]
339    fn constants_match_libfranka() {
340        assert_eq!(MAX_CUTOFF_FREQUENCY, 1000.0);
341        assert_eq!(DEFAULT_CUTOFF_FREQUENCY, 100.0);
342    }
343}