Skip to content

Commit d887421

Browse files
authored
Merge pull request #1 from asynchronics/lowpass_filter
Add Lowpass filter to utilities + simple CI
2 parents fd43b07 + 0f4b388 commit d887421

6 files changed

Lines changed: 206 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
- master
8+
pull_request:
9+
10+
jobs:
11+
rust:
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
18+
- name: Install stable toolchain
19+
uses: dtolnay/rust-toolchain@stable
20+
with:
21+
components: rustfmt
22+
23+
- name: Cache cargo artifacts
24+
uses: Swatinem/rust-cache@v2
25+
26+
- name: Check formatting
27+
run: cargo fmt --all --check
28+
29+
- name: Build
30+
run: cargo check --workspace --all-targets
31+
32+
- name: Test
33+
run: cargo test --workspace --all-targets

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
.idea/
22
target/
3-
3+
Cargo.lock

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[workspace]
22
members = ["utilities"]
3-
resolver = "2"
3+
resolver = "2"
4+
5+
[workspace.dependencies]
6+
serde = { version = "1.0.228", features = ["derive"] }

utilities/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ name = "utilities"
33
version = "0.1.0"
44
edition = "2024"
55

6-
[dependencies]
6+
[dependencies]
7+
serde = { workspace = true }

utilities/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod lowpass;

utilities/src/lowpass.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//! Lowpass filter.
2+
//!
3+
//! This module provides a simple first-order lowpass filter (alpha filter)
4+
//! with constructors based on either a cutoff frequency or a direct alpha
5+
//! coefficient.
6+
//!
7+
//! # Examples
8+
//!
9+
//! ```rust
10+
//! use utilities::lowpass::LowPassFilter;
11+
//!
12+
//! // 5 Hz cutoff, 100 Hz sample rate.
13+
//! let mut filter = LowPassFilter::from_frequency(5.0, 100.0);
14+
//!
15+
//! let y0 = filter.update(1.0);
16+
//! let y1 = filter.update(1.0);
17+
//!
18+
//! assert!(y1 >= y0);
19+
//! assert!(y1 <= 1.0);
20+
//! ```
21+
22+
use serde::{Deserialize, Serialize};
23+
24+
/// First order lowpass filter, "alpha filter"
25+
#[derive(Debug, Serialize, Deserialize)]
26+
pub struct LowPassFilter {
27+
/// Filter coefficient, 0 -> no update, 1 -> passthrough
28+
alpha: f64,
29+
/// Current filtered state
30+
value: f64,
31+
}
32+
33+
impl LowPassFilter {
34+
/// Create a lowpass filter from cutoff frequency and sample rate
35+
#[inline]
36+
pub fn from_frequency(cutoff_hz: f64, sample_rate: f64) -> Self {
37+
// Reject invalid or effectively-zero parameters
38+
if sample_rate < 1e-9 || cutoff_hz < 1e-9 {
39+
return Self::from_alpha(0.0);
40+
}
41+
42+
let dt = 1.0 / sample_rate;
43+
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz);
44+
let alpha = dt / (rc + dt);
45+
46+
let clamped_alpha = alpha.clamp(0.0, 1.0);
47+
Self {
48+
alpha: clamped_alpha,
49+
value: 0.0,
50+
}
51+
}
52+
53+
/// Create a lowpass filter from alpha coefficient
54+
#[inline]
55+
pub fn from_alpha(filter_alpha: f64) -> Self {
56+
let clamped_alpha = filter_alpha.clamp(0.0, 1.0);
57+
Self {
58+
alpha: clamped_alpha,
59+
value: 0.0,
60+
}
61+
}
62+
63+
/// Update lowpass with a new sample
64+
#[inline]
65+
pub fn update(&mut self, input: f64) -> f64 {
66+
let new_value = self.value * (1.0 - self.alpha) + self.alpha * input;
67+
self.value = new_value;
68+
self.value
69+
}
70+
71+
/// Get the current filtered value
72+
#[inline]
73+
pub fn value(&self) -> f64 {
74+
self.value
75+
}
76+
77+
/// Get alpha coefficient
78+
#[inline]
79+
pub fn alpha(&self) -> f64 {
80+
self.alpha
81+
}
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
const KINDA_SMALL_NUMBER: f64 = 1e-4;
88+
89+
#[test]
90+
fn convergence_test() {
91+
let mut filter = LowPassFilter::from_alpha(0.1);
92+
for _ in 0..1000 {
93+
filter.update(3.0);
94+
}
95+
// Should converge near 3.0
96+
97+
assert!((filter.value() - 3.0).abs() < KINDA_SMALL_NUMBER);
98+
}
99+
100+
#[test]
101+
fn alpha_edge_cases() {
102+
let mut f = LowPassFilter::from_alpha(0.0);
103+
assert_eq!(f.update(10.0), 0.0); // no change
104+
105+
let mut f = LowPassFilter::from_alpha(1.0);
106+
assert_eq!(f.update(10.0), 10.0); // passthrough
107+
}
108+
109+
#[test]
110+
fn from_frequency_computes_alpha() {
111+
let cutoff_hz = 5.0;
112+
let sample_rate = 100.0;
113+
let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate);
114+
115+
let dt = 1.0 / sample_rate;
116+
let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz);
117+
let expected_alpha = dt / (rc + dt);
118+
119+
assert!((filter.alpha() - expected_alpha).abs() < KINDA_SMALL_NUMBER);
120+
}
121+
122+
#[test]
123+
fn from_frequency_clamps_alpha_for_edge_cases() {
124+
let cases = [(0.0, 100.0), (-10.0, 100.0), (10.0, 0.0), (10.0, -100.0)];
125+
126+
for (cutoff_hz, sample_rate) in cases {
127+
let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate);
128+
let alpha = filter.alpha();
129+
assert!(
130+
(0.0..=1.0).contains(&alpha),
131+
"alpha out of range for cutoff {cutoff_hz}, sample_rate {sample_rate}"
132+
);
133+
}
134+
}
135+
136+
#[test]
137+
fn from_frequency_monotonic_in_cutoff() {
138+
let sample_rate = 100.0;
139+
let low = LowPassFilter::from_frequency(1.0, sample_rate);
140+
let high = LowPassFilter::from_frequency(10.0, sample_rate);
141+
assert!(high.alpha() > low.alpha());
142+
}
143+
144+
#[test]
145+
fn step_response_moves_toward_input_without_overshoot() {
146+
let mut filter = LowPassFilter::from_alpha(0.25);
147+
let mut last = filter.value();
148+
for _ in 0..50 {
149+
let value = filter.update(1.0);
150+
assert!(value >= last, "response should be non-decreasing");
151+
assert!(value <= 1.0, "response should not overshoot input");
152+
last = value;
153+
}
154+
}
155+
156+
#[test]
157+
fn from_frequency_extremes_approximate_alpha_bounds() {
158+
let sample_rate = 100.0;
159+
let near_zero = LowPassFilter::from_frequency(1e-9, sample_rate);
160+
let near_one = LowPassFilter::from_frequency(1e9, sample_rate);
161+
162+
assert!(near_zero.alpha() < KINDA_SMALL_NUMBER);
163+
assert!((1.0 - near_one.alpha()) < KINDA_SMALL_NUMBER);
164+
}
165+
}

0 commit comments

Comments
 (0)