Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
.idea/
target/

Cargo.lock
5 changes: 4 additions & 1 deletion Cargo.toml
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"] }
3 changes: 2 additions & 1 deletion utilities/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ name = "utilities"
version = "0.1.0"
edition = "2024"

[dependencies]
[dependencies]
serde = { workspace = true }
1 change: 1 addition & 0 deletions utilities/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod lowpass;
165 changes: 165 additions & 0 deletions utilities/src/lowpass.rs
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);
Comment thread
wiktorspl marked this conversation as resolved.
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);
}
}
Loading