use crate::domain::{MovementProfile, MovementType};
#[derive(Debug, Clone)]
pub struct MovementClassifierConfig {
pub movement_threshold: f64,
pub gross_movement_threshold: f64,
pub window_size: usize,
pub periodicity_threshold: f64,
}
impl Default for MovementClassifierConfig {
fn default() -> Self {
Self {
movement_threshold: 0.1,
gross_movement_threshold: 0.5,
window_size: 100,
periodicity_threshold: 0.3,
}
}
}
pub struct MovementClassifier {
config: MovementClassifierConfig,
}
impl MovementClassifier {
pub fn new(config: MovementClassifierConfig) -> Self {
Self { config }
}
pub fn with_defaults() -> Self {
Self::new(MovementClassifierConfig::default())
}
pub fn classify(&self, csi_signal: &[f64], sample_rate: f64) -> MovementProfile {
if csi_signal.len() < self.config.window_size {
return MovementProfile::default();
}
let variance = self.calculate_variance(csi_signal);
let max_change = self.calculate_max_change(csi_signal);
let periodicity = self.calculate_periodicity(csi_signal, sample_rate);
let (movement_type, is_voluntary) =
self.determine_movement_type(variance, max_change, periodicity);
let intensity = self.calculate_intensity(variance, max_change);
let frequency = self.calculate_movement_frequency(csi_signal, sample_rate);
MovementProfile {
movement_type,
intensity,
frequency,
is_voluntary,
}
}
fn calculate_variance(&self, signal: &[f64]) -> f64 {
if signal.is_empty() {
return 0.0;
}
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
let variance = signal.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / signal.len() as f64;
variance
}
fn calculate_max_change(&self, signal: &[f64]) -> f64 {
if signal.len() < 2 {
return 0.0;
}
signal
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0, f64::max)
}
fn calculate_periodicity(&self, signal: &[f64], _sample_rate: f64) -> f64 {
if signal.len() < 3 {
return 0.0;
}
let n = signal.len();
let mean = signal.iter().sum::<f64>() / n as f64;
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
let variance: f64 = centered.iter().map(|x| x * x).sum();
if variance == 0.0 {
return 0.0;
}
let max_lag = n / 2;
let mut max_corr = 0.0;
for lag in 1..max_lag {
let corr: f64 = centered
.iter()
.take(n - lag)
.zip(centered.iter().skip(lag))
.map(|(a, b)| a * b)
.sum();
let normalized_corr = corr / variance;
if normalized_corr > max_corr {
max_corr = normalized_corr;
}
}
max_corr.max(0.0)
}
fn determine_movement_type(
&self,
variance: f64,
max_change: f64,
periodicity: f64,
) -> (MovementType, bool) {
if variance < self.config.movement_threshold * 0.5
&& max_change < self.config.movement_threshold
{
return (MovementType::None, false);
}
if max_change > self.config.gross_movement_threshold
&& variance > self.config.movement_threshold
{
let is_voluntary = periodicity < self.config.periodicity_threshold;
return (MovementType::Gross, is_voluntary);
}
if periodicity > self.config.periodicity_threshold {
if variance < self.config.movement_threshold * 2.0 {
return (MovementType::Periodic, false);
}
return (MovementType::Tremor, false);
}
if variance > self.config.movement_threshold * 0.5 {
let is_voluntary = periodicity < 0.2;
return (MovementType::Fine, is_voluntary);
}
(MovementType::None, false)
}
fn calculate_intensity(&self, variance: f64, max_change: f64) -> f32 {
let variance_score = (variance / (self.config.gross_movement_threshold * 2.0)).min(1.0);
let change_score = (max_change / self.config.gross_movement_threshold).min(1.0);
((variance_score * 0.6 + change_score * 0.4) as f32).min(1.0)
}
fn calculate_movement_frequency(&self, signal: &[f64], sample_rate: f64) -> f32 {
if signal.len() < 3 {
return 0.0;
}
let mean = signal.iter().sum::<f64>() / signal.len() as f64;
let centered: Vec<f64> = signal.iter().map(|x| x - mean).collect();
let zero_crossings: usize = centered
.windows(2)
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
.count();
let duration = signal.len() as f64 / sample_rate;
let frequency = zero_crossings as f64 / (2.0 * duration);
frequency as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_movement() {
let classifier = MovementClassifier::with_defaults();
let signal: Vec<f64> = vec![1.0; 200];
let profile = classifier.classify(&signal, 100.0);
assert!(matches!(profile.movement_type, MovementType::None));
}
#[test]
fn test_gross_movement() {
let classifier = MovementClassifier::with_defaults();
let signal: Vec<f64> = (0..200)
.map(|i| {
if (50..100).contains(&i) {
2.0
} else if (150..180).contains(&i) {
-1.5
} else {
0.0
}
})
.collect();
let profile = classifier.classify(&signal, 100.0);
assert!(matches!(profile.movement_type, MovementType::Gross));
}
#[test]
fn test_periodic_movement() {
let classifier = MovementClassifier::with_defaults();
let signal: Vec<f64> = (0..1000)
.map(|i| (2.0 * std::f64::consts::PI * i as f64 / 100.0).sin() * 1.5)
.collect();
let profile = classifier.classify(&signal, 100.0);
assert!(profile.intensity > 0.0 || !matches!(profile.movement_type, MovementType::None));
}
#[test]
fn test_intensity_calculation() {
let classifier = MovementClassifier::with_defaults();
let low_signal: Vec<f64> = (0..200).map(|i| (i as f64 * 0.1).sin() * 0.05).collect();
let low_profile = classifier.classify(&low_signal, 100.0);
let high_signal: Vec<f64> = (0..200).map(|i| (i as f64 * 0.1).sin() * 2.0).collect();
let high_profile = classifier.classify(&high_signal, 100.0);
assert!(high_profile.intensity > low_profile.intensity);
}
}