-
Notifications
You must be signed in to change notification settings - Fork 0
Add Lowpass filter to utilities + simple CI #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| - master | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| rust: | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Install stable toolchain | ||
| uses: dtolnay/rust-toolchain@stable | ||
| with: | ||
| components: rustfmt | ||
|
|
||
| - name: Cache cargo artifacts | ||
| uses: Swatinem/rust-cache@v2 | ||
|
|
||
| - name: Check formatting | ||
| run: cargo fmt --all --check | ||
|
|
||
| - name: Build | ||
| run: cargo check --workspace --all-targets | ||
|
|
||
| - name: Test | ||
| run: cargo test --workspace --all-targets |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,3 @@ | ||
| .idea/ | ||
| target/ | ||
|
|
||
| Cargo.lock |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,6 @@ | ||
| [workspace] | ||
| members = ["utilities"] | ||
| resolver = "2" | ||
| resolver = "2" | ||
|
|
||
| [workspace.dependencies] | ||
| serde = { version = "1.0.228", features = ["derive"] } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,4 +3,5 @@ name = "utilities" | |
| version = "0.1.0" | ||
| edition = "2024" | ||
|
|
||
| [dependencies] | ||
| [dependencies] | ||
| serde = { workspace = true } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| pub mod lowpass; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| //! Lowpass filter. | ||
| //! | ||
| //! This module provides a simple first-order lowpass filter (alpha filter) | ||
| //! with constructors based on either a cutoff frequency or a direct alpha | ||
| //! coefficient. | ||
| //! | ||
| //! # Examples | ||
| //! | ||
| //! ```rust | ||
| //! use utilities::lowpass::LowPassFilter; | ||
| //! | ||
| //! // 5 Hz cutoff, 100 Hz sample rate. | ||
| //! let mut filter = LowPassFilter::from_frequency(5.0, 100.0); | ||
| //! | ||
| //! let y0 = filter.update(1.0); | ||
| //! let y1 = filter.update(1.0); | ||
| //! | ||
| //! assert!(y1 >= y0); | ||
| //! assert!(y1 <= 1.0); | ||
| //! ``` | ||
|
|
||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| /// First order lowpass filter, "alpha filter" | ||
| #[derive(Debug, Serialize, Deserialize)] | ||
| pub struct LowPassFilter { | ||
| /// Filter coefficient, 0 -> no update, 1 -> passthrough | ||
| alpha: f64, | ||
| /// Current filtered state | ||
| value: f64, | ||
| } | ||
|
|
||
| impl LowPassFilter { | ||
| /// Create a lowpass filter from cutoff frequency and sample rate | ||
| #[inline] | ||
| pub fn from_frequency(cutoff_hz: f64, sample_rate: f64) -> Self { | ||
| // Reject invalid or effectively-zero parameters | ||
| if sample_rate < 1e-9 || cutoff_hz < 1e-9 { | ||
| return Self::from_alpha(0.0); | ||
| } | ||
|
|
||
| let dt = 1.0 / sample_rate; | ||
| let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz); | ||
| let alpha = dt / (rc + dt); | ||
|
|
||
| let clamped_alpha = alpha.clamp(0.0, 1.0); | ||
| Self { | ||
| alpha: clamped_alpha, | ||
| value: 0.0, | ||
| } | ||
| } | ||
|
|
||
| /// Create a lowpass filter from alpha coefficient | ||
| #[inline] | ||
| pub fn from_alpha(filter_alpha: f64) -> Self { | ||
| let clamped_alpha = filter_alpha.clamp(0.0, 1.0); | ||
| Self { | ||
| alpha: clamped_alpha, | ||
| value: 0.0, | ||
| } | ||
| } | ||
|
|
||
| /// Update lowpass with a new sample | ||
| #[inline] | ||
| pub fn update(&mut self, input: f64) -> f64 { | ||
| let new_value = self.value * (1.0 - self.alpha) + self.alpha * input; | ||
| self.value = new_value; | ||
| self.value | ||
| } | ||
|
|
||
| /// Get the current filtered value | ||
| #[inline] | ||
| pub fn value(&self) -> f64 { | ||
| self.value | ||
| } | ||
|
|
||
| /// Get alpha coefficient | ||
| #[inline] | ||
| pub fn alpha(&self) -> f64 { | ||
| self.alpha | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| const KINDA_SMALL_NUMBER: f64 = 1e-4; | ||
|
|
||
| #[test] | ||
| fn convergence_test() { | ||
| let mut filter = LowPassFilter::from_alpha(0.1); | ||
| for _ in 0..1000 { | ||
| filter.update(3.0); | ||
| } | ||
| // Should converge near 3.0 | ||
|
|
||
| assert!((filter.value() - 3.0).abs() < KINDA_SMALL_NUMBER); | ||
| } | ||
|
|
||
| #[test] | ||
| fn alpha_edge_cases() { | ||
| let mut f = LowPassFilter::from_alpha(0.0); | ||
| assert_eq!(f.update(10.0), 0.0); // no change | ||
|
|
||
| let mut f = LowPassFilter::from_alpha(1.0); | ||
| assert_eq!(f.update(10.0), 10.0); // passthrough | ||
| } | ||
|
|
||
| #[test] | ||
| fn from_frequency_computes_alpha() { | ||
| let cutoff_hz = 5.0; | ||
| let sample_rate = 100.0; | ||
| let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate); | ||
|
|
||
| let dt = 1.0 / sample_rate; | ||
| let rc = 1.0 / (2.0 * std::f64::consts::PI * cutoff_hz); | ||
| let expected_alpha = dt / (rc + dt); | ||
|
|
||
| assert!((filter.alpha() - expected_alpha).abs() < KINDA_SMALL_NUMBER); | ||
| } | ||
|
|
||
| #[test] | ||
| fn from_frequency_clamps_alpha_for_edge_cases() { | ||
| let cases = [(0.0, 100.0), (-10.0, 100.0), (10.0, 0.0), (10.0, -100.0)]; | ||
|
|
||
| for (cutoff_hz, sample_rate) in cases { | ||
| let filter = LowPassFilter::from_frequency(cutoff_hz, sample_rate); | ||
| let alpha = filter.alpha(); | ||
| assert!( | ||
| (0.0..=1.0).contains(&alpha), | ||
| "alpha out of range for cutoff {cutoff_hz}, sample_rate {sample_rate}" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn from_frequency_monotonic_in_cutoff() { | ||
| let sample_rate = 100.0; | ||
| let low = LowPassFilter::from_frequency(1.0, sample_rate); | ||
| let high = LowPassFilter::from_frequency(10.0, sample_rate); | ||
| assert!(high.alpha() > low.alpha()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn step_response_moves_toward_input_without_overshoot() { | ||
| let mut filter = LowPassFilter::from_alpha(0.25); | ||
| let mut last = filter.value(); | ||
| for _ in 0..50 { | ||
| let value = filter.update(1.0); | ||
| assert!(value >= last, "response should be non-decreasing"); | ||
| assert!(value <= 1.0, "response should not overshoot input"); | ||
| last = value; | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn from_frequency_extremes_approximate_alpha_bounds() { | ||
| let sample_rate = 100.0; | ||
| let near_zero = LowPassFilter::from_frequency(1e-9, sample_rate); | ||
| let near_one = LowPassFilter::from_frequency(1e9, sample_rate); | ||
|
|
||
| assert!(near_zero.alpha() < KINDA_SMALL_NUMBER); | ||
| assert!((1.0 - near_one.alpha()) < KINDA_SMALL_NUMBER); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.