diff --git a/Cargo.toml b/Cargo.toml index f8d4309..1acaefd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,9 +29,14 @@ log = { version = "0.4.22", features = [] } serde = { version = "1.0.215", features = ["derive", "serde_derive"] } serde_json = "1.0.133" async-trait = "0.1.83" -mockall = "0.13.1" rand = "0.8.5" typetag = "0.2.18" anyhow = { version = "1.0.94", features = ["backtrace"] } rust-fsm = "0.7.0" event_bridge = "0.3.1" +hrv-algos={ version = "0.4.2", features = ["serde"] } +rayon = "1.10.0" +[dev-dependencies] +mockall = "0.13.1" +tempdir = "0.3.7" +criterion = { version = "0.5", features = ["html_reports"] } diff --git a/README.md b/README.md index 7f04992..7d863ac 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Hrv-rs + [![Pipeline Status](https://github.com/mat-kie/hrv-rs/actions/workflows/rust.yml/badge.svg)](https://github.com/mat-kie/hrv-rs/actions/workflows/rust.yml) [![Coverage](https://codecov.io/gh/mat-kie/hrv-rs/branch/main/graph/badge.svg?token=YOUR_CODECOV_TOKEN)](https://codecov.io/gh/mat-kie/hrv-rs) - **Hrv-rs** is a Rust-based application designed to analyze Heart Rate Variability (HRV) using Bluetooth Low Energy (BLE) chest straps. ## Disclaimer @@ -10,6 +10,7 @@ **This project is in a very early stage and is not intended for any medical applications.** ## Features + - **Bluetooth Connectivity**: - Scan and connect to BLE chest straps that provide R-R interval data. - **HRV Analysis**: @@ -17,6 +18,7 @@ - Visualize HRV statistics in real-time. ## HRV Metrics + - **RMSSD**: Root Mean Square of Successive Differences between R-R intervals. - **SDRR**: Standard Deviation of R-R intervals. - **SD1/SD2**: Short- and long-term HRV metrics derived from Poincaré plots. @@ -25,10 +27,12 @@ ## Getting Started ### Prerequisites + - A BLE-compatible chest strap for HRV measurement. - A system with BLE support. ### Installation + 1. Clone the repository: ```bash git clone https://github.com/mat-kie/hrv-rs.git @@ -48,6 +52,7 @@ ## Code Structure ### Architecture + The project uses a modular, event-driven MVC architecture. ### Modules diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..3d44758 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,4 @@ +coverage: + range: 50..70 + status: + project: false \ No newline at end of file diff --git a/src/api/controller.rs b/src/api/controller.rs index 7a4fc79..f375e66 100644 --- a/src/api/controller.rs +++ b/src/api/controller.rs @@ -9,7 +9,6 @@ use anyhow::Result; use async_trait::async_trait; use btleplug::api::Central; use std::{path::PathBuf, sync::Arc}; -use time::Duration; use tokio::sync::RwLock; use super::model::{BluetoothModelApi, MeasurementModelApi}; @@ -60,16 +59,6 @@ pub trait StorageEventApi { /// /// * `path` - A `PathBuf` representing the file path to which to store data. async fn store_to_file(&mut self, path: PathBuf) -> Result<()>; - - /// Store the recorded measurement. - /// - /// This method handles the storage of a new measurement. - async fn new_measurement(&mut self) -> Result<()>; - - /// Store the recorded measurement. - /// - /// This method handles the storage of the recorded measurement. - async fn store_recorded_measurement(&mut self) -> Result<()>; } /// StorageApi trait @@ -84,8 +73,10 @@ pub trait StorageEventApi { pub trait StorageApi { /// Get the active measurement. /// - /// This method returns a reference to the active measurement, if any. - fn get_active_measurement(&mut self) -> &Option>>; + /// This method returns a reference to the measurement at index. + fn get_measurement(&self, index: usize) -> Result>>; + + fn store_measurement(&mut self, measurement: Arc>) -> Result<()>; } /// MeasurementApi trait @@ -101,8 +92,8 @@ pub trait MeasurementApi: MeasurementModelApi { /// /// # Arguments /// - /// * `window` - A `Duration` representing the length of the statistics window. - async fn set_stats_window(&mut self, window: Duration) -> Result<()>; + /// * `window` - Number of samples to consider for statistics + async fn set_stats_window(&mut self, window: usize) -> Result<()>; /// Set the outlier filter. /// diff --git a/src/api/model.rs b/src/api/model.rs index adfd729..05c8386 100644 --- a/src/api/model.rs +++ b/src/api/model.rs @@ -1,16 +1,16 @@ //! This module defines the read only API for interacting with various models. //! It provides interfaces for accessing data related to HRV measurements, //! Bluetooth adapters, and stored acquisitions. +use crate::model::{ + bluetooth::{AdapterDescriptor, DeviceDescriptor, HeartrateMessage}, + hrv::PoincarePoints, +}; +use anyhow::Result; use btleplug::api::BDAddr; use std::{fmt::Debug, sync::Arc}; use time::{Duration, OffsetDateTime}; use tokio::sync::RwLock; -use crate::model::{ - bluetooth::{AdapterDescriptor, DeviceDescriptor, HeartrateMessage}, - hrv::{HrvSessionData, HrvStatistics}, -}; - /// `MeasurementModelApi` trait. /// /// Defines the interface for managing measurement-related data, including runtime measurements, @@ -28,17 +28,25 @@ pub trait MeasurementModelApi: Debug + Send + Sync { /// An optional `HeartrateMessage` representing the most recent measurement. fn get_last_msg(&self) -> Option<&HeartrateMessage>; - /// Retrieves the current HRV statistics. - /// - /// # Returns - /// A reference to an optional `HrvStatistics` containing computed HRV data. - fn get_hrv_stats(&self) -> Option<&HrvStatistics>; + fn get_rmssd(&self) -> Option; + fn get_sdrr(&self) -> Option; + fn get_sd1(&self) -> Option; + fn get_sd2(&self) -> Option; + fn get_hr(&self) -> Option; + fn get_dfa1a(&self) -> Option; + + fn get_rmssd_ts(&self) -> Vec<[f64; 2]>; + fn get_sdrr_ts(&self) -> Vec<[f64; 2]>; + fn get_sd1_ts(&self) -> Vec<[f64; 2]>; + fn get_sd2_ts(&self) -> Vec<[f64; 2]>; + fn get_hr_ts(&self) -> Vec<[f64; 2]>; + fn get_dfa1a_ts(&self) -> Vec<[f64; 2]>; /// Retrieves the configured statistics window. /// /// # Returns /// A reference to an optional `Duration` representing the analysis window size. - fn get_stats_window(&self) -> Option<&Duration>; + fn get_stats_window(&self) -> Option; /// Getter for the filter parameter value (fraction of std. dev). /// @@ -50,13 +58,7 @@ pub trait MeasurementModelApi: Debug + Send + Sync { /// /// # Returns /// A vector of `[f64; 2]` pairs representing the Poincare points. - fn get_poincare_points(&self) -> Vec<[f64; 2]>; - - /// Retrieves the session data. - /// - /// # Returns - /// A reference to the `HrvSessionData`. - fn get_session_data(&self) -> &HrvSessionData; + fn get_poincare_points(&self) -> Result; /// Retrieves the elapsed time since the start of the acquisition. /// diff --git a/src/components/application.rs b/src/components/application.rs index 965d446..13e22cb 100644 --- a/src/components/application.rs +++ b/src/components/application.rs @@ -6,35 +6,35 @@ use crate::{ api::{ controller::{BluetoothApi, MeasurementApi, RecordingApi, StorageApi, StorageEventApi}, - model::{BluetoothModelApi, MeasurementModelApi, ModelHandle, StorageModelApi}, + model::{BluetoothModelApi, ModelHandle, StorageModelApi}, }, core::events::{AppEvent, StateChangeEvent}, view::manager::{ViewManager, ViewState}, }; -use anyhow::{anyhow, Result}; +use anyhow::Result; use log::{error, trace}; -use std::{marker::PhantomData, sync::Arc}; +use std::sync::Arc; use tokio::sync::{broadcast::Sender, RwLock}; /// Main application controller. /// /// This structure manages the lifecycle of other controllers and handles application-level events. pub struct AppController< - MT: MeasurementApi + 'static, - ST: StorageApi + RecordingApi + Send + 'static, + MT: MeasurementApi + RecordingApi + 'static, + ST: StorageApi + Send + 'static, BT: BluetoothApi + RecordingApi + 'static, > { view_tx: Sender, event_bus: Sender, ble_controller: Arc>, acq_controller: Arc>, - _marker: PhantomData, + active_measurement: Option>>, } impl< - MT: MeasurementApi, - ST: StorageApi + StorageEventApi + StorageModelApi + RecordingApi + Send + 'static, + MT: MeasurementApi + RecordingApi + Default + 'static, + ST: StorageApi + StorageEventApi + StorageModelApi + Send + 'static, BT: BluetoothApi + RecordingApi + 'static, > AppController { @@ -56,7 +56,7 @@ impl< event_bus: event_bus.clone(), ble_controller: Arc::new(RwLock::new(ble_controller)), acq_controller: Arc::new(RwLock::new(acq_controller)), - _marker: Default::default(), + active_measurement: None, } } @@ -79,27 +79,40 @@ impl< None, )))?; } + StateChangeEvent::DiscardRecording => { + self.active_measurement = None; + self.view_tx.send(ViewState::Overview(( + { + let mh: Arc> = self.acq_controller.clone(); + ModelHandle::from(mh) + }, + None, + )))?; + } + StateChangeEvent::StoreRecording => { + if let Some(measurement) = self.active_measurement.as_ref() { + let mut lck = self.acq_controller.write().await; + lck.store_measurement(measurement.clone())?; + self.view_tx.send(ViewState::Overview(( + ModelHandle::from(self.acq_controller.clone()), + Some(measurement.clone()), + )))?; + } + } StateChangeEvent::ToRecordingState => { // move to recording view - let mut lck = self.acq_controller.write().await; - lck.new_measurement().await?; - let m: ModelHandle = lck - .get_active_measurement() - .clone() - .ok_or(anyhow!("No active measurement"))?; + let m: Arc> = Arc::new(RwLock::new(MT::default())); + self.active_measurement = Some(m.clone()); let bm: ModelHandle = self.ble_controller.clone(); self.view_tx.send(ViewState::Acquisition((m, bm)))?; } StateChangeEvent::SelectMeasurement(idx) => { - let lck = self.acq_controller.read().await; - let acqs = lck.get_acquisitions(); - if let Some(acq) = acqs.get(idx) { - let mh: Arc> = self.acq_controller.clone(); - self.view_tx.send(ViewState::Overview(( - ModelHandle::from(mh), - Some(acq.clone()), - )))?; - } + let acq = self.acq_controller.read().await.get_measurement(idx)?; + self.active_measurement = Some(acq.clone()); + self.view_tx.send(ViewState::Overview(( + ModelHandle::from(self.acq_controller.clone()), + Some(acq.clone()), + )))?; } } Ok(()) @@ -113,8 +126,7 @@ impl< event.forward_to(&mut *lck).await } AppEvent::Measurement(event) => { - let mut lck = self.acq_controller.write().await; - if let Some(measurement) = lck.get_active_measurement() { + if let Some(measurement) = self.active_measurement.as_ref() { let mut lck = measurement.write().await; event.forward_to(&mut *lck).await } else { @@ -122,10 +134,11 @@ impl< } } AppEvent::Recording(event) => { - { - let mut acq_lock = self.acq_controller.write().await; - event.clone().forward_to(&mut *acq_lock).await?; + if let Some(measurement) = self.active_measurement.as_ref() { + let mut lck = measurement.write().await; + event.clone().forward_to(&mut *lck).await? } + { let mut ble_lock = self.ble_controller.write().await; event.forward_to(&mut *ble_lock).await @@ -179,23 +192,24 @@ impl< } #[cfg(test)] -mod tests { - use std::path::PathBuf; - +pub mod tests { use super::*; + use crate::api::model::MeasurementModelApi; use crate::components::measurement::MeasurementData; use crate::core::events::{ BluetoothEvent, MeasurementEvent, RecordingEvent, StateChangeEvent, StorageEvent, }; - use crate::model::bluetooth::{AdapterDescriptor, DeviceDescriptor}; - + use crate::model::bluetooth::{AdapterDescriptor, DeviceDescriptor, HeartrateMessage}; + use anyhow::anyhow; use async_trait::async_trait; use btleplug::api::BDAddr; use mockall::mock; - use time::Duration; + use mockall::predicate::{always, eq}; + use std::path::PathBuf; use tokio::sync::broadcast; + mock! { - Bluetooth {} + pub Bluetooth {} impl std::fmt::Debug for Bluetooth{ fn fmt<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result; } @@ -226,7 +240,7 @@ mod tests { } mock! { - Storage{} + pub Storage{} impl std::fmt::Debug for Storage{ fn fmt<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result; } @@ -235,7 +249,8 @@ mod tests { } impl StorageApi for Storage{ - fn get_active_measurement(&mut self) -> &Option>>; + fn get_measurement(& self, index:usize) -> Result>>; + fn store_measurement(&mut self, measurement: Arc>) -> Result<()>; } #[async_trait] @@ -243,8 +258,6 @@ mod tests { async fn clear(&mut self) -> Result<()>; async fn load_from_file(&mut self, path: PathBuf) -> Result<()>; async fn store_to_file(&mut self, path: PathBuf) -> Result<()>; - async fn new_measurement(&mut self) -> Result<()>; - async fn store_recorded_measurement(&mut self) -> Result<()>; } #[async_trait] @@ -272,14 +285,7 @@ mod tests { async fn test_app_controller_recording_state() { let (event_bus_tx, _) = broadcast::channel(16); let ble_controller = MockBluetooth::new(); - let mut acq_controller = MockStorage::new(); - - // Setup mocks - let mock_measurement = Arc::new(RwLock::new(MeasurementData::default())); - acq_controller.expect_new_measurement().returning(|| Ok(())); - acq_controller - .expect_get_active_measurement() - .return_const(Some(mock_measurement.clone())); + let acq_controller = MockStorage::new(); let mut app_controller = AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); @@ -298,14 +304,11 @@ mod tests { let mut acq_controller = MockStorage::new(); let mock_measurement = Arc::new(RwLock::new(MeasurementData::default())); - let measurements = vec![{ - let m: Arc> = mock_measurement.clone(); - m - }]; acq_controller - .expect_get_acquisitions() - .return_const(measurements); + .expect_get_measurement() + .with(eq(0usize)) + .returning(move |_| Ok(mock_measurement.clone())); let mut app_controller = AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); @@ -340,22 +343,20 @@ mod tests { async fn test_app_controller_measurement_event() { let (event_bus_tx, _) = broadcast::channel(16); let ble_controller = MockBluetooth::new(); - let mut acq_controller = MockStorage::new(); + let acq_controller = MockStorage::new(); let mock_measurement = Arc::new(RwLock::new(MeasurementData::default())); - acq_controller - .expect_get_active_measurement() - .return_const(Some(mock_measurement.clone())); let mut app_controller = AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + app_controller.active_measurement = Some(mock_measurement.clone()); - let event = AppEvent::Measurement(MeasurementEvent::SetStatsWindow(Duration::minutes(1))); + let event = AppEvent::Measurement(MeasurementEvent::SetStatsWindow(60)); let result = app_controller.dispatch_event(event).await; assert!(result.is_ok()); assert_eq!( mock_measurement.read().await.get_stats_window().unwrap(), - &Duration::minutes(1) + 60 ); } @@ -407,4 +408,197 @@ mod tests { let result = app_controller.dispatch_event(event).await; assert!(result.is_ok()); } + + #[tokio::test] + async fn test_app_controller_store_recording_no_active_measurement() { + // Covers lines when active_measurement is None + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let acq_controller = MockStorage::new(); + + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + + // Attempt to store recording with no active measurement + let result = app_controller + .handle_state_events(StateChangeEvent::StoreRecording) + .await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_app_controller_store_recording() { + // Covers discarding a measurement if active_measurement is Some + let (event_bus_tx, _) = broadcast::channel(16); + let mut ble_controller = MockBluetooth::new(); + let mut acq_controller = MockStorage::new(); + ble_controller + .expect_start_recording() + .once() + .returning(|| Ok(())); + ble_controller + .expect_stop_recording() + .once() + .returning(|| Ok(())); + let measurement = Arc::new(RwLock::new(MeasurementData::default())); + acq_controller + .expect_store_measurement() + .once() + .returning(move |_| Ok(())); + + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + // needed to have an open view channel + let _view = app_controller.get_viewmanager(); + + app_controller.active_measurement = Some(measurement); + + assert!(app_controller + .handle_state_events(StateChangeEvent::ToRecordingState) + .await + .is_ok()); + assert!(app_controller.active_measurement.is_some()); + assert!(app_controller + .dispatch_event(AppEvent::Recording(RecordingEvent::StartRecording)) + .await + .is_ok()); + assert!(app_controller + .dispatch_event(AppEvent::Measurement(MeasurementEvent::RecordMessage( + HeartrateMessage::from_values(60, None, &[1000]) + ))) + .await + .is_ok()); + assert!(app_controller + .dispatch_event(AppEvent::Recording(RecordingEvent::StopRecording)) + .await + .is_ok()); + assert!(app_controller + .handle_state_events(StateChangeEvent::StoreRecording) + .await + .is_ok()); + } + + #[tokio::test] + async fn test_app_controller_discard_recording() { + // Covers discarding a measurement if active_measurement is Some + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let acq_controller = MockStorage::new(); + + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + // needed to have an open view channel + let _view = app_controller.get_viewmanager(); + + app_controller.active_measurement = Some(Arc::new(RwLock::new(MeasurementData::default()))); + + assert!(app_controller + .handle_state_events(StateChangeEvent::ToRecordingState) + .await + .is_ok()); + assert!(app_controller.active_measurement.is_some()); + assert!(app_controller + .handle_state_events(StateChangeEvent::DiscardRecording) + .await + .is_ok()); + assert!(app_controller.active_measurement.is_none()); + } + + #[tokio::test] + async fn test_app_controller_measurement_event_no_active_measurement() { + // Covers lines where measurement event is ignored if active_measurement is None + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let acq_controller = MockStorage::new(); + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + + // No active measurement + let event = AppEvent::Measurement(MeasurementEvent::SetStatsWindow(30)); + let result = app_controller.dispatch_event(event).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_app_controller_error_storage_event() { + // Covers lines where acq_controller returns an error + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let mut acq_controller = MockStorage::new(); + + acq_controller + .expect_clear() + .returning(|| Err(anyhow!("Mock storage clear error"))); + + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + + let event = AppEvent::Storage(StorageEvent::Clear); + let result = app_controller.dispatch_event(event).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_app_controller_repeated_discovery_fail() { + // Covers lines in event_handler retry logic for discovering adapters + // by returning an error first, then success + let (event_bus_tx, _) = broadcast::channel(16); + let mut ble_controller = MockBluetooth::new(); + ble_controller + .expect_discover_adapters() + .once() + .returning(|| Err(anyhow!("Mock discovery fail"))) + .once() + .returning(|| Ok(())); + let acq_controller = MockStorage::new(); + + let app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + let gui_ctx = egui::Context::default(); + + // Just check it does not panic; + // it should retry once and eventually succeed + tokio::spawn(app_controller.event_handler(gui_ctx)).abort(); + } + + #[tokio::test] + async fn test_app_controller_event_handler_initial_viewstate_error() { + // Covers lines in event_handler where sending the initial view state fails + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let mut acq_controller = MockStorage::new(); + + // Force an error in store_measurement on the initial state + acq_controller + .expect_store_measurement() + .returning(move |_| Err(anyhow!("Mock store error"))); + + let app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + let gui_ctx = egui::Context::default(); + + // Just ensure we handle retries without panicking + tokio::spawn(app_controller.event_handler(gui_ctx)).abort(); + } + + #[tokio::test] + async fn test_app_controller_select_measurement_error() { + // Covers lines where get_measurement returns an error + let (event_bus_tx, _) = broadcast::channel(16); + let ble_controller = MockBluetooth::new(); + let mut acq_controller = MockStorage::new(); + + acq_controller + .expect_get_measurement() + .with(always()) + .returning(move |_| Err(anyhow!("Mock get measurement error"))); + + let mut app_controller = + AppController::new(ble_controller, acq_controller, event_bus_tx.clone()); + let result = app_controller + .handle_state_events(StateChangeEvent::SelectMeasurement(999)) + .await; + // Should return an error + assert!(result.is_err()); + } } diff --git a/src/components/measurement.rs b/src/components/measurement.rs index 8c32815..24165c7 100644 --- a/src/components/measurement.rs +++ b/src/components/measurement.rs @@ -1,12 +1,9 @@ use crate::{ api::{ - controller::{MeasurementApi, OutlierFilter}, + controller::{MeasurementApi, OutlierFilter, RecordingApi}, model::MeasurementModelApi, }, - model::{ - bluetooth::HeartrateMessage, - hrv::{HrvSessionData, HrvStatistics}, - }, + model::{bluetooth::HeartrateMessage, hrv::HrvAnalysisData}, }; use anyhow::Result; use async_trait::async_trait; @@ -23,12 +20,14 @@ pub struct MeasurementData { /// Collected measurements with their elapsed time. measurements: Vec<(Duration, HeartrateMessage)>, /// Window duration for statistical calculations. - window: Option, + window: Option, /// Outlier filter threshold. outlier_filter: f64, /// Processed session data. #[serde(skip)] - sessiondata: HrvSessionData, + sessiondata: HrvAnalysisData, + #[serde(skip)] + is_recording: bool, } impl MeasurementData { @@ -37,8 +36,11 @@ impl MeasurementData { /// # Returns /// A result indicating success or failure. fn update(&mut self) -> Result<()> { - match HrvSessionData::from_acquisition(&self.measurements, self.window, self.outlier_filter) - { + match HrvAnalysisData::from_acquisition( + &self.measurements, + self.window, + self.outlier_filter, + ) { Ok(data) => self.sessiondata = data, Err(e) => { warn!("could not calculate session data: {}", e); @@ -54,8 +56,9 @@ impl Default for MeasurementData { start_time: OffsetDateTime::now_utc(), measurements: Vec::new(), window: None, - outlier_filter: 100.0, + outlier_filter: 5.0, sessiondata: Default::default(), + is_recording: false, } } } @@ -69,14 +72,14 @@ impl<'de> Deserialize<'de> for MeasurementData { struct AcquisitionModelHelper { start_time: OffsetDateTime, measurements: Vec<(Duration, HeartrateMessage)>, - window: Option, + window: Option, outlier_filter: f64, } // Deserialize all fields except `sessiondata` let helper = AcquisitionModelHelper::deserialize(deserializer)?; // Reconstruct `sessiondata` from the `measurements` - let sessiondata = HrvSessionData::from_acquisition( + let sessiondata = HrvAnalysisData::from_acquisition( &helper.measurements, helper.window, helper.outlier_filter, @@ -89,15 +92,16 @@ impl<'de> Deserialize<'de> for MeasurementData { window: helper.window, outlier_filter: helper.outlier_filter, sessiondata, + is_recording: false, }) } } #[async_trait] impl MeasurementApi for MeasurementData { - async fn set_stats_window(&mut self, window: Duration) -> Result<()> { + async fn set_stats_window(&mut self, window: usize) -> Result<()> { self.window = Some(window); - Ok(()) + self.update() } async fn set_outlier_filter(&mut self, filter: OutlierFilter) -> Result<()> { match filter { @@ -108,12 +112,19 @@ impl MeasurementApi for MeasurementData { self.outlier_filter = parameter; } } - Ok(()) + self.update() } async fn record_message(&mut self, msg: HeartrateMessage) -> Result<()> { - let elapsed = OffsetDateTime::now_utc() - self.start_time; - self.measurements.push((elapsed, msg)); - self.update() + if self.is_recording { + let elapsed = OffsetDateTime::now_utc() - self.start_time; + self.measurements.push((elapsed, msg)); + self.sessiondata + .add_measurement(&msg, self.window.unwrap_or(usize::MAX)) + } else { + Err(anyhow::anyhow!( + "RecordMessage event received while not recording" + )) + } } } @@ -128,24 +139,68 @@ impl MeasurementModelApi for MeasurementData { fn get_last_msg(&self) -> Option<&HeartrateMessage> { self.measurements.last().map(|(_, msg)| msg) } - fn get_hrv_stats(&self) -> Option<&HrvStatistics> { - self.sessiondata.hrv_stats.as_ref() - } fn get_outlier_filter_value(&self) -> f64 { self.outlier_filter } - fn get_poincare_points(&self) -> Vec<[f64; 2]> { - self.sessiondata.get_poincare() - } - fn get_session_data(&self) -> &HrvSessionData { - &self.sessiondata + fn get_poincare_points(&self) -> Result<(Vec<[f64; 2]>, Vec<[f64; 2]>)> { + self.sessiondata.get_poincare(self.window) } + fn get_start_time(&self) -> &OffsetDateTime { &self.start_time } - fn get_stats_window(&self) -> Option<&Duration> { - self.window.as_ref() + fn get_stats_window(&self) -> Option { + self.window + } + fn get_dfa1a(&self) -> Option { + self.sessiondata.get_dfa_alpha() + } + fn get_dfa1a_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_dfa_alpha_ts().to_owned() + } + fn get_hr(&self) -> Option { + self.sessiondata.get_hr() + } + fn get_hr_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_hr_ts().to_owned() + } + fn get_rmssd(&self) -> Option { + self.sessiondata.get_rmssd() + } + fn get_rmssd_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_rmssd_ts().to_owned() + } + fn get_sd1(&self) -> Option { + self.sessiondata.get_sd1() + } + fn get_sd1_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_sd1_ts().to_owned() + } + fn get_sd2(&self) -> Option { + self.sessiondata.get_sd2() + } + fn get_sd2_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_sd2_ts().to_owned() + } + fn get_sdrr(&self) -> Option { + self.sessiondata.get_sdrr() + } + fn get_sdrr_ts(&self) -> Vec<[f64; 2]> { + self.sessiondata.get_sdrr_ts().to_owned() + } +} + +#[async_trait] +impl RecordingApi for MeasurementData { + async fn start_recording(&mut self) -> Result<()> { + self.is_recording = true; + Ok(()) + } + + async fn stop_recording(&mut self) -> Result<()> { + self.is_recording = false; + Ok(()) } } @@ -155,46 +210,49 @@ mod tests { use super::*; use crate::model::bluetooth::HeartrateMessage; + use crate::model::hrv::tests::get_data; + #[test] fn test_default_measurement_data() { let data = MeasurementData::default(); assert!(data.measurements.is_empty()); - assert_eq!(data.outlier_filter, 100.0); + assert_eq!(data.outlier_filter, 5.0); assert!(data.window.is_none()); } #[test] fn test_update_session_data() { - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); + let hr_msgs = get_data(4); let mut data = MeasurementData::default(); - for _i in 0..4 { - data.measurements.push((Duration::seconds(1), hr_msg)); + for msg in hr_msgs { + data.measurements.push(msg); } + data.update().unwrap(); assert!(data.update().is_ok()); - assert!(data.sessiondata.hrv_stats.is_some()); } #[test] fn test_deserialize_measurement_data() { - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); + let hr_msgs = get_data(100); let mut data = MeasurementData::default(); - for _i in 0..4 { - data.measurements.push((Duration::seconds(1), hr_msg)); + for msg in &hr_msgs { + data.measurements.push(*msg); } + data.update().unwrap(); data.start_time = datetime!(2023-01-01 00:00:00 UTC); data.outlier_filter = 100.0; let json = serde_json::to_string(&data).unwrap(); let data: MeasurementData = serde_json::from_str(&json).unwrap(); assert_eq!(data.start_time, datetime!(2023-01-01 00:00:00 UTC)); - assert_eq!(data.measurements.len(), 4); - assert_eq!(data.measurements[0].1.get_hr(), 80.0); + assert_eq!(data.measurements.len(), 100); + assert_eq!(data.measurements[0].1.get_hr(), hr_msgs[0].1.get_hr()); assert_eq!(data.outlier_filter, 100.0); } #[tokio::test] async fn test_set_stats_window() { let mut data = MeasurementData::default(); - let window = Duration::seconds(60); + let window = 60; assert!(data.set_stats_window(window).await.is_ok()); assert_eq!(data.window, Some(window)); } @@ -214,6 +272,9 @@ mod tests { async fn test_record_message() { let mut data = MeasurementData::default(); let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); + assert!(data.record_message(hr_msg).await.is_err()); + assert_eq!(data.measurements.len(), 0); + assert!(data.start_recording().await.is_ok()); assert!(data.record_message(hr_msg).await.is_ok()); assert_eq!(data.measurements.len(), 1); assert_eq!(data.measurements[0].1.get_hr(), 80.0); @@ -235,38 +296,22 @@ mod tests { assert_eq!(data.get_last_msg(), Some(&hr_msg)); } - #[test] - fn test_get_hrv_stats() { - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); - let mut data = MeasurementData::default(); - for _i in 0..4 { - data.measurements.push((Duration::seconds(1), hr_msg)); - } - data.update().unwrap(); - assert!(data.get_hrv_stats().is_some()); - } - #[test] fn test_get_outlier_filter_value() { let data = MeasurementData::default(); - assert_eq!(data.get_outlier_filter_value(), 100.0); + assert_eq!(data.get_outlier_filter_value(), 5.0); } #[test] fn test_get_poincare_points() { - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); + let hr_msgs = get_data(10); let mut data = MeasurementData::default(); - for _i in 0..4 { - data.measurements.push((Duration::seconds(1), hr_msg)); + for msg in hr_msgs { + data.measurements.push(msg); } data.update().unwrap(); - assert_eq!(data.get_poincare_points().len(), 3); - } - - #[test] - fn test_get_session_data() { - let data = MeasurementData::default(); - assert!(data.get_session_data().hrv_stats.is_none()); + let (inl, out) = data.get_poincare_points().unwrap(); + assert_eq!(inl.len() + out.len(), 9); } #[test] @@ -279,8 +324,36 @@ mod tests { async fn test_get_stats_window() { let mut data = MeasurementData::default(); assert!(data.get_stats_window().is_none()); - assert!(data.set_stats_window(Duration::seconds(60)).await.is_ok()); + assert!(data.set_stats_window(60).await.is_ok()); assert!(data.get_stats_window().is_some()); - assert_eq!(data.get_stats_window().unwrap(), &Duration::seconds(60)); + assert_eq!(data.get_stats_window().unwrap(), 60); + } + + #[test] + fn get_elapsed_time_none() { + let data = MeasurementData::default(); + assert_eq!(data.get_elapsed_time(), Duration::default()); + } + + #[test] + fn test_getters() { + let mut data = MeasurementData::default(); + let hr_msgs = get_data(120); + for msg in hr_msgs { + data.measurements.push(msg); + } + data.update().unwrap(); + assert!(data.get_dfa1a().is_some()); + assert!(!data.get_dfa1a_ts().is_empty()); + assert!(data.get_hr().is_some()); + assert!(!data.get_hr_ts().is_empty()); + assert!(data.get_rmssd().is_some()); + assert!(!data.get_rmssd_ts().is_empty()); + assert!(data.get_sd1().is_some()); + assert!(!data.get_sd1_ts().is_empty()); + assert!(data.get_sd2().is_some()); + assert!(!data.get_sd2_ts().is_empty()); + assert!(data.get_sdrr().is_some()); + assert!(!data.get_sdrr_ts().is_empty()); } } diff --git a/src/components/storage.rs b/src/components/storage.rs index b7b3001..f3284e3 100644 --- a/src/components/storage.rs +++ b/src/components/storage.rs @@ -6,7 +6,7 @@ use std::{path::PathBuf, sync::Arc}; use crate::api::{ - controller::{MeasurementApi, RecordingApi, StorageApi, StorageEventApi}, + controller::{MeasurementApi, StorageApi, StorageEventApi}, model::{MeasurementModelApi, ModelHandle, StorageModelApi}, }; use anyhow::{anyhow, Result}; @@ -27,8 +27,6 @@ pub struct StorageComponent< > { measurements: Vec>>, handles: Vec>, - active_measurement: Option>>, - is_recording: bool, } #[async_trait] @@ -39,8 +37,6 @@ impl< async fn clear(&mut self) -> Result<()> { self.measurements.clear(); self.handles.clear(); - self.active_measurement = None; - self.is_recording = false; Ok(()) } @@ -66,8 +62,6 @@ impl< mh }) .collect(); - self.active_measurement = None; - self.is_recording = false; Ok(()) } @@ -81,28 +75,23 @@ impl< .await??; fs::write(&path, json).await.map_err(|e| anyhow!(e)) } - - async fn new_measurement(&mut self) -> Result<()> { - self.active_measurement = Some(Arc::new(RwLock::new(MT::default()))); - Ok(()) - } - - async fn store_recorded_measurement(&mut self) -> Result<()> { - if let Some(measurement) = self.active_measurement.take() { - self.measurements.push(measurement.clone()); - self.handles.push(ModelHandle::from(measurement)); - Ok(()) - } else { - Err(anyhow!("No active measurement to store")) - } - } } impl StorageApi for StorageComponent { - fn get_active_measurement(&mut self) -> &Option>> { - &self.active_measurement + fn get_measurement(&self, index: usize) -> Result>> { + if index < self.measurements.len() { + Ok(self.measurements[index].clone()) + } else { + Err(anyhow!("Index out of bounds")) + } + } + fn store_measurement(&mut self, measurement: Arc>) -> Result<()> { + self.measurements.push(measurement.clone()); + let mh: ModelHandle = ModelHandle::from(measurement.clone()); + self.handles.push(mh); + Ok(()) } } @@ -115,72 +104,84 @@ impl< } } -#[async_trait] -impl< - MT: MeasurementApi + Serialize + DeserializeOwned + Default + Send + Clone + Sync + 'static, - > RecordingApi for StorageComponent -{ - async fn start_recording(&mut self) -> Result<()> { - self.is_recording = true; - Ok(()) - } - - async fn stop_recording(&mut self) -> Result<()> { - self.is_recording = false; - Ok(()) - } -} #[cfg(test)] mod tests { - use crate::components::measurement::MeasurementData; + + use crate::api::controller::RecordingApi; + use crate::{components::measurement::MeasurementData, model::hrv::tests::get_data}; use super::*; #[tokio::test] - async fn test_new_measurement() { + async fn test_clear_storage() { let mut storage = StorageComponent::::default(); - assert!(storage.new_measurement().await.is_ok()); - assert!(storage.get_active_measurement().is_some()); + let measurement = Arc::new(RwLock::new(MeasurementData::default())); + assert!(storage.store_measurement(measurement.clone()).is_ok()); + assert!(storage.clear().await.is_ok()); + assert_eq!(storage.get_acquisitions().len(), 0); } #[tokio::test] - async fn test_store_recorded_measurement() { + async fn test_load_from_nonexistent_file() { + let temp_dir = tempdir::TempDir::new("test").unwrap(); + let path = temp_dir + .path() + .join(PathBuf::from("some/invalid/subdir/nonexistent.json")); let mut storage = StorageComponent::::default(); - assert!(storage.new_measurement().await.is_ok()); - assert!(storage.store_recorded_measurement().await.is_ok()); - assert_eq!(storage.get_acquisitions().len(), 1); + let result = storage.load_from_file(path).await; + assert!(result.is_err()); } #[tokio::test] - async fn test_clear_storage() { + async fn test_store_to_invalid_path() { + let temp_dir = tempdir::TempDir::new("test").unwrap(); + let path = temp_dir + .path() + .join(PathBuf::from("some/invalid/subdir/test_measurements.json")); let mut storage = StorageComponent::::default(); - assert!(storage.new_measurement().await.is_ok()); - assert!(storage.store_recorded_measurement().await.is_ok()); - assert!(storage.clear().await.is_ok()); - assert_eq!(storage.get_acquisitions().len(), 0); + let measurement = Arc::new(RwLock::new(MeasurementData::default())); + assert!(storage.store_measurement(measurement.clone()).is_ok()); + let result = storage.store_to_file(path).await; + assert!(result.is_err()); } #[tokio::test] async fn test_store_and_load() { + let temp_dir = tempdir::TempDir::new("test").unwrap(); + let path = temp_dir + .path() + .join(PathBuf::from("test_measurements.json")); let mut storage = StorageComponent::::default(); - assert!(storage.new_measurement().await.is_ok()); - assert!(storage.store_recorded_measurement().await.is_ok()); - - let path = PathBuf::from("test_measurements.json"); + let measurement = Arc::new(RwLock::new(MeasurementData::default())); + { + let mut data = measurement.write().await; + data.start_recording().await.unwrap(); + + for (_, msg) in get_data(120) { + data.record_message(msg).await.unwrap(); + } + } + assert!(storage.store_measurement(measurement.clone()).is_ok()); assert!(storage.store_to_file(path.clone()).await.is_ok()); let mut new_storage = StorageComponent::::default(); assert!(new_storage.load_from_file(path.clone()).await.is_ok()); assert_eq!(new_storage.get_acquisitions().len(), 1); + } - // Cleanup - std::fs::remove_file(path).unwrap(); + #[tokio::test] + async fn test_get_measurement_out_of_bounds() { + let storage = StorageComponent::::default(); + let result = storage.get_measurement(0); + assert!(result.is_err()); } #[tokio::test] - async fn test_recording_state() { + async fn test_store_and_retrieve_measurement() { let mut storage = StorageComponent::::default(); - assert!(storage.start_recording().await.is_ok()); - assert!(storage.stop_recording().await.is_ok()); + let measurement = Arc::new(RwLock::new(MeasurementData::default())); + assert!(storage.store_measurement(measurement.clone()).is_ok()); + let retrieved = storage.get_measurement(0).unwrap(); + assert!(Arc::ptr_eq(&measurement, &retrieved)) } } diff --git a/src/core/events.rs b/src/core/events.rs index 15a5bf3..db0928a 100644 --- a/src/core/events.rs +++ b/src/core/events.rs @@ -6,7 +6,6 @@ use anyhow::Result; use event_bridge::EventBridge; use std::path::PathBuf; -use time::Duration; use crate::{ api::controller::{BluetoothApi, MeasurementApi, OutlierFilter, RecordingApi, StorageEventApi}, @@ -20,14 +19,13 @@ pub enum StorageEvent { Clear, LoadFromFile(PathBuf), StoreToFile(PathBuf), - StoreRecordedMeasurement, } #[derive(Debug, Clone, EventBridge)] #[forward_to_trait(MeasurementApi)] #[trait_returned_type(HandlerResult)] pub enum MeasurementEvent { - SetStatsWindow(Duration), + SetStatsWindow(usize), SetOutlierFilter(OutlierFilter), RecordMessage(HeartrateMessage), } @@ -53,6 +51,8 @@ pub enum BluetoothEvent { #[derive(Debug, Clone)] pub enum StateChangeEvent { + DiscardRecording, + StoreRecording, ToRecordingState, InitialState, SelectMeasurement(usize), diff --git a/src/main.rs b/src/main.rs index 148ba2f..8ec0231 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,12 +40,6 @@ mod components { pub mod storage; } -/// Mathematical utilities for HRV analysis. -mod math { - /// Functions and structures for HRV computation. - pub mod hrv; -} - /// Data models representing the application's domain. mod model { diff --git a/src/math/hrv.rs b/src/math/hrv.rs deleted file mode 100644 index 703908a..0000000 --- a/src/math/hrv.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! HRV (Heart Rate Variability) Computation -//! -//! This module contains functions and utilities for calculating HRV metrics. -//! It provides statistical and frequency-based methods for HRV analysis. - -use nalgebra::{DMatrix, DVector}; - -/// `calc_rmssd` function. -/// -/// Calculates RMSSD (Root Mean Square of Successive Differences). -/// -/// # Arguments -/// - `data`: A slice of RR intervals in milliseconds. -/// -/// # Returns -/// RMSSD value as a `f64`. -/// -/// # Panics -/// Panics if the input slice has less than 2 elements. -pub fn calc_rmssd(data: &[f64]) -> f64 { - assert!( - data.len() > 1, - "Data must contain at least two elements for RMSSD calculation." - ); - - let rr_points_a = DVector::from_row_slice(&data[0..data.len() - 1]); - let rr_points_b = DVector::from_row_slice(&data[1..]); - let successive_diffs = rr_points_b - rr_points_a; - - (successive_diffs.dot(&successive_diffs) / (successive_diffs.len() as f64)).sqrt() -} - -/// `calc_sdrr` function. -/// -/// Calculates SDRR (Standard Deviation of RR intervals). -/// -/// # Arguments -/// - `data`: A slice of RR intervals in milliseconds. -/// -/// # Returns -/// SDRR value as a `f64`. -/// -/// # Panics -/// Panics if the input slice has less than 2 elements. -pub fn calc_sdrr(data: &[f64]) -> f64 { - assert!( - data.len() > 1, - "Data must contain at least two elements for SDRR calculation." - ); - - let variance = DVector::from_row_slice(data).variance(); - variance.sqrt() -} - -/// Results of Poincare plot metrics. -#[derive(Clone, Copy, Default)] -/// `PoincareAnalysisResult` structure. -/// -/// Stores results of Poincare plot analysis, including SD1, SD2, and their eigenvectors. -pub struct PoincareAnalysisResult { - pub sd1: f64, - pub sd1_eigenvector: [f64; 2], - pub sd2: f64, - pub sd2_eigenvector: [f64; 2], -} - -/// `calc_poincare_metrics` function. -/// -/// Calculates Poincare plot metrics SD1 and SD2 with their eigenvectors. -/// -/// # Arguments -/// - `data`: A slice of RR intervals in milliseconds. -/// -/// # Returns -/// A `PoincareAnalysisResult` containing SD1, SD2, and their eigenvectors. -/// -/// # Panics -/// Panics if the input slice has less than 2 elements. -pub fn calc_poincare_metrics(data: &[f64]) -> PoincareAnalysisResult { - assert!( - data.len() > 1, - "Data must contain at least two elements for Poincare metrics calculation." - ); - - let rr_points_a = DVector::from_row_slice(&data[0..data.len() - 1]); - let rr_points_b = DVector::from_row_slice(&data[1..]); - - // Center the data - let poincare_matrix = { - let mut centered = DMatrix::from_columns(&[rr_points_a, rr_points_b]); - let col_means = centered.row_mean(); - for mut row in centered.row_iter_mut() { - row -= &col_means; - } - centered - }; - - // Covariance matrix and eigen decomposition - let poincare_cov = - poincare_matrix.transpose() * &poincare_matrix / (poincare_matrix.nrows() as f64 - 1.0); - let ev = nalgebra::SymmetricEigen::new(poincare_cov); - - PoincareAnalysisResult { - sd1: ev.eigenvalues[0].sqrt(), - sd1_eigenvector: [ev.eigenvectors.column(0)[0], ev.eigenvectors.column(0)[1]], - sd2: ev.eigenvalues[1].sqrt(), - sd2_eigenvector: [ev.eigenvectors.column(1)[0], ev.eigenvectors.column(1)[1]], - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rmssd() { - let data = [1000.0, 1010.0, 1020.0, 1030.0, 1040.0]; - let rmssd = calc_rmssd(&data); - assert!(rmssd > 0.0, "RMSSD should be positive."); - } - - #[test] - fn test_sdrr() { - let data = [1000.0, 1010.0, 1020.0, 1030.0, 1040.0]; - let sdrr = calc_sdrr(&data); - assert!(sdrr > 0.0, "SDRR should be positive."); - } - - #[test] - fn test_poincare_metrics() { - let data = [1000.0, 1010.0, 1001.0, 1030.0, 1049.0]; - let poincare = calc_poincare_metrics(&data); - assert!(poincare.sd1 > 0.0, "SD1 should be positive."); - assert!(poincare.sd2 > 0.0, "SD2 should be positive."); - assert!( - poincare.sd1_eigenvector[0] != 0.0, - "SD1 eigenvector should not be zero." - ); - assert!( - poincare.sd2_eigenvector[0] != 0.0, - "SD2 eigenvector should not be zero." - ); - } -} diff --git a/src/model/bluetooth.rs b/src/model/bluetooth.rs index b9ed3d9..b1398ce 100644 --- a/src/model/bluetooth.rs +++ b/src/model/bluetooth.rs @@ -86,6 +86,48 @@ impl HeartrateMessage { result } + /// Constructs a new `HeartrateMessage` from individual values. + /// This method is useful for testing and constructing messages with specific data. + /// # Arguments + /// * `hr_value` - The heart rate value in BPM. + /// * `energy_expended` - The energy expenditure in kilojoules (optional). + /// * `rr_values_ms` - A slice of RR interval values in milliseconds. + /// # Returns + /// A new `HeartrateMessage` instance with the specified values. + /// # Panics + /// Panics if the provided RR interval slice is longer than 9 elements. + /// # Example + /// ``` + /// use hrv_rs::model::bluetooth::HeartrateMessage; + /// let msg = HeartrateMessage::from_values(80, Some(10), &[1000, 250]); + /// assert_eq!(msg.get_hr(), 80.0); + /// assert!(msg.has_energy_exp()); + /// assert_eq!(msg.get_energy_exp(), 10.0); + /// assert!(msg.has_rr_interval()); + /// assert_eq!(msg.get_rr_intervals(), &[1000, 250]); + /// ``` + #[cfg(test)] + pub fn from_values(hr_value: u16, energy_expended: Option, rr_values_ms: &[u16]) -> Self { + let mut flags = 0b00000000; + if !rr_values_ms.is_empty() { + flags |= 0b00010000; + } + if energy_expended.is_some() { + flags |= 0b00001000; + } + let mut rr_values = [0u16; 9]; + rr_values + .iter_mut() + .zip(rr_values_ms.iter()) + .for_each(|(a, &b)| *a = b); + + HeartrateMessage { + flags, + hr_value, + energy_expended: energy_expended.unwrap_or(0), + rr_values, + } + } /// Checks if the heart rate value uses 16-bit representation. pub fn has_long_hr(&self) -> bool { is_bit_set!(self.flags, 0) @@ -341,4 +383,32 @@ mod test { assert!(msg.sen_has_contact()); assert!(msg.sen_contact_supported()); } + + #[test] + fn test_from_values_full() { + let msg = HeartrateMessage::from_values(80, Some(10), &[1000, 250]); + assert_eq!(msg.get_hr(), 80.0); + assert!(msg.has_energy_exp()); + assert_eq!(msg.get_energy_exp(), 10.0); + assert!(msg.has_rr_interval()); + assert_eq!(msg.get_rr_intervals(), &[1000, 250]); + } + + #[test] + fn test_from_values_no_rr() { + let msg = HeartrateMessage::from_values(80, Some(10), &[]); + assert_eq!(msg.get_hr(), 80.0); + assert!(msg.has_energy_exp()); + assert_eq!(msg.get_energy_exp(), 10.0); + assert!(!msg.has_rr_interval()); + } + + #[test] + fn test_from_values_no_exp() { + let msg = HeartrateMessage::from_values(80, None, &[1000, 250]); + assert_eq!(msg.get_hr(), 80.0); + assert!(!msg.has_energy_exp()); + assert!(msg.has_rr_interval()); + assert_eq!(msg.get_rr_intervals(), &[1000, 250]); + } } diff --git a/src/model/hrv.rs b/src/model/hrv.rs index 91c6d70..9c53c70 100644 --- a/src/model/hrv.rs +++ b/src/model/hrv.rs @@ -7,69 +7,58 @@ //! in the analysis of heart rate variability. use super::bluetooth::HeartrateMessage; -use crate::math::hrv::{calc_poincare_metrics, calc_rmssd, calc_sdrr}; use anyhow::{anyhow, Result}; -use nalgebra::DVector; +use hrv_algos::analysis::dfa::{DFAnalysis, DetrendStrategy}; +use hrv_algos::analysis::nonlinear::calc_poincare_metrics; +use hrv_algos::analysis::time::{calc_rmssd, calc_sdrr}; +use hrv_algos::preprocessing::outliers::{MovingQuantileFilter, OutlierClassifier}; + +use rayon::iter::{ + IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, ParallelIterator, +}; +use serde::{Deserialize, Serialize}; use std::fmt::Debug; +use std::ops::Range; use time::Duration; -/// The size of the sliding window used in the outlier filter. -/// -/// This constant defines the number of RR intervals considered when applying -/// the outlier filter to remove anomalies in the data. -const FILTER_WINDOW_SIZE: usize = 5; - -/// Stores heart rate variability (HRV) statistics results. -/// -/// This structure contains the calculated HRV parameters based on RR intervals. -/// It includes statistical measures like RMSSD, SDRR, and Poincaré plot metrics. -#[derive(Default, Clone, Debug)] -pub struct HrvStatistics { - /// Root Mean Square of Successive Differences (RMSSD). - pub rmssd: f64, - /// Standard Deviation of RR intervals (SDRR). - pub sdrr: f64, - /// Short-term variability (SD1) from Poincaré plot. - pub sd1: f64, - /// Eigenvector corresponding to SD1. - #[allow(dead_code)] - pub sd1_eigenvec: [f64; 2], - /// Long-term variability (SD2) from Poincaré plot. - pub sd2: f64, - /// Eigenvector corresponding to SD2. - #[allow(dead_code)] - pub sd2_eigenvec: [f64; 2], - /// Ratio of SD1 to SD2, indicating the balance between short-term and long-term variability. - #[allow(dead_code)] - pub sd1_sd2_ratio: f64, - /// Average heart rate over the analysis period. - pub avg_hr: f64, -} +/// Represents inliers and outliers on the Poincare plot. +pub type PoincarePoints = (Vec<[f64; 2]>, Vec<[f64; 2]>); /// Manages runtime data related to HRV analysis. /// /// This structure collects RR intervals, heart rate values, and timestamps. /// It processes incoming heart rate measurements and computes HRV statistics. -#[derive(Default, Debug, Clone)] -pub struct HrvSessionData { - /// RR intervals in milliseconds. - pub rr_intervals: Vec, - /// Cumulative time for each RR interval. - pub rr_time: Vec, - /// Heart rate values. - pub hr_values: Vec, - /// Reception timestamps for heart rate measurements. - pub rx_time: Vec, - /// Calculated HRV statistics. - pub hrv_stats: Option, - /// Time series of RMSSD values over time. - pub rmssd_ts: Vec<[f64; 2]>, - /// Time series of SD1 values over time. - pub sd1_ts: Vec<[f64; 2]>, - /// Time series of SD2 values over time. - pub sd2_ts: Vec<[f64; 2]>, - /// Time series of heart rate values over time. - pub hr_ts: Vec<[f64; 2]>, +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HrvAnalysisData { + data: MovingQuantileFilter, + rr_timepoints: Vec, + /// Time series of RMSSD values. + rmssd_ts: Vec<[f64; 2]>, + /// Time series of SDRR values. + sdrr_ts: Vec<[f64; 2]>, + /// Time series of SD1 values. + sd1_ts: Vec<[f64; 2]>, + /// Time series of SD2 values. + sd2_ts: Vec<[f64; 2]>, + /// Time series of heart rate values. + hr_ts: Vec<[f64; 2]>, + /// Time series of DFA alpha values + dfa_alpha_ts: Vec<[f64; 2]>, +} + +impl Default for HrvAnalysisData { + fn default() -> Self { + Self { + data: MovingQuantileFilter::new(None, None, None), + rr_timepoints: Vec::new(), + rmssd_ts: Vec::new(), + sdrr_ts: Vec::new(), + sd1_ts: Vec::new(), + sd2_ts: Vec::new(), + hr_ts: Vec::new(), + dfa_alpha_ts: Vec::new(), + } + } } /// Represents data collected during an HRV (Heart Rate Variability) session. @@ -77,7 +66,7 @@ pub struct HrvSessionData { /// This struct holds heart rate values, RR intervals, reception timestamps, and HRV statistics. /// It provides methods for processing raw acquisition data, filtering outliers, and calculating /// HRV statistics. -impl HrvSessionData { +impl HrvAnalysisData { /// Creates an `HrvSessionData` instance from acquisition data. /// /// Processes raw acquisition data, applies optional time-based filtering, @@ -98,94 +87,191 @@ impl HrvSessionData { /// statistics calculation fails (e.g., due to insufficient data). pub fn from_acquisition( data: &[(Duration, HeartrateMessage)], - window: Option, + window: Option, outlier_filter: f64, ) -> Result { let mut new = Self::default(); if data.is_empty() { return Ok(new); } + new.data.set_quantile_scale(outlier_filter)?; + new.add_measurements(data, window.unwrap_or(usize::MAX))?; - new.hr_values.reserve(data.len()); - new.rr_intervals.reserve(data.len()); - new.rx_time.reserve(data.len()); - - let start_time = if let Some(window) = window { - // we know the vector is not empty at this point - data.last().unwrap().0 - window - } else { - // we know the vector is not empty at this point - data.first().unwrap().0 - }; + Ok(new) + } - for (ts, msg) in data.iter().filter(|val| val.0 >= start_time) { - new.add_measurement(msg, ts); + fn calc_time_series< + 'a, + T: Send + Sync + 'a, + R: Send + Sync, + F: Fn(&[T]) -> Result + Send + Sync, + >( + start: usize, + window: usize, + data: &[T], + time: &[Duration], + func: F, + ) -> Result<(Vec, Vec)> { + if start >= data.len() { + return Err(anyhow!("start index out of bounds")); } - - if new.has_sufficient_data() { - // Apply the outlier filter to the RR intervals and times. - let (filtered_rr, filtered_time) = Self::apply_outlier_filter( - &new.rr_intervals, - Some(&new.rr_time), - outlier_filter, - FILTER_WINDOW_SIZE, - ); - - new.rr_intervals = filtered_rr; - new.rr_time = filtered_time; - - let hrv_stats = HrvStatistics::new(&new.rr_intervals, &new.hr_values)?; - let rr_total: Vec = data - .iter() - .flat_map(|m| m.1.get_rr_intervals()) - .map(|f| *f as f64) - .collect(); - - let ahr = hrv_stats.avg_hr; - let win = (ahr * window.unwrap_or(Duration::seconds(30)).as_seconds_f64() / 60.0) - .floor() as usize; - - let mut elapsed_time = 0.0; - for (start_rr, rr) in rr_total - .iter() - .zip(rr_total.windows(win.max(1)).map(|slice| { - Self::apply_outlier_filter(slice, None, outlier_filter, FILTER_WINDOW_SIZE).0 - })) - { - let hr = 60000.0 * rr.len() as f64 / rr.iter().sum::(); - elapsed_time += start_rr * 1e-3; - - if let Ok(stats) = HrvStatistics::new(&rr, Default::default()) { - new.rmssd_ts.push([elapsed_time, stats.rmssd]); - new.sd1_ts.push([elapsed_time, stats.sd1]); - new.sd2_ts.push([elapsed_time, stats.sd2]); - new.hr_ts.push([elapsed_time, hr]); - } - } - - new.hrv_stats = Some(hrv_stats); + if data.len() != time.len() { + return Err(anyhow!("data and time series length mismatch")); } + Ok(time + .into_par_iter() + .enumerate() + .skip(start) + .filter_map(|(idx, ts)| { + let rr = &data[idx.saturating_sub(window) + 1..idx + 1]; + if let Ok(res) = func(rr) { + Some((res, *ts)) + } else { + None + } + }) + .unzip()) + } - Ok(new) + pub fn add_measurement(&mut self, hrs_msg: &HeartrateMessage, window: usize) -> Result<()> { + // add rr point + self.add_measurements(&[(Duration::default(), *hrs_msg)], window) } - /// Adds an RR interval measurement to the session. - /// - /// Calculates cumulative time and updates the `rr_intervals` and `rr_time` vectors. - /// - /// # Arguments - /// - /// * `rr_measurement` - The RR interval in milliseconds. - fn add_rr_measurement(&mut self, rr_measurement: u16) { - let rr_ms = rr_measurement as f64; - let cumulative_time = if let Some(last) = self.rr_time.last() { - *last + Duration::milliseconds(rr_measurement as i64) - } else { - Duration::milliseconds(rr_measurement as i64) - }; + fn get_last_filtered(&self, window: Range) -> Result<(Vec, Vec)> { + if window.end > self.data.get_data().len() { + return Err(anyhow!("window end out of bounds")); + } + let data = self.data.get_data(); + let classes = self.data.get_classification(); + Ok(window + .into_par_iter() + .filter_map(|idx| { + if classes[idx].is_outlier() { + None + } else { + Some((data[idx], self.rr_timepoints[idx])) + } + }) + .unzip()) + } - self.rr_intervals.push(rr_ms); - self.rr_time.push(cumulative_time); + fn calc_statistics(&mut self, window: usize, new: usize) -> Result<()> { + let start_idx = self + .data + .get_data() + .len() + .saturating_sub(new.saturating_add(window)); + let (filtered_rr, filtered_ts) = + self.get_last_filtered(start_idx..self.data.get_data().len())?; + // estimate start index of new data in filtered_rr assuming no outliers + // add 5 to have room for some outliers + let start_idx = filtered_rr.len().saturating_sub(new + 5); + + { + let (mut new_data, ts) = + Self::calc_time_series(start_idx, window, &filtered_rr, &filtered_ts, |win| { + calc_rmssd(win) + })?; + let last_ts = self.rmssd_ts.last().map(|v| v[0]).unwrap_or(0.0); + self.rmssd_ts + .extend(new_data.drain(..).zip(ts).filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + })); + } + { + let (mut new_data, ts) = + Self::calc_time_series(start_idx, window, &filtered_rr, &filtered_ts, |win| { + calc_sdrr(win) + })?; + let last_ts = self.sdrr_ts.last().map(|v| v[0]).unwrap_or(0.0); + + self.sdrr_ts + .extend(new_data.drain(..).zip(ts).filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + })); + } + { + let (mut new_data, ts) = + Self::calc_time_series(start_idx, window, &filtered_rr, &filtered_ts, |win| { + let dfa = DFAnalysis::udfa( + win, + &[4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + DetrendStrategy::Linear, + )?; + Ok(dfa.alpha) + })?; + let last_ts = self.dfa_alpha_ts.last().map(|v| v[0]).unwrap_or(0.0); + + self.dfa_alpha_ts + .extend(new_data.drain(..).zip(ts).filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + })); + } + { + let (new_data, ts) = + Self::calc_time_series(start_idx, window, &filtered_rr, &filtered_ts, |win| { + let res = calc_poincare_metrics(win)?; + Ok((res.sd1, res.sd2)) + })?; + let (mut new_sd1_ts, mut new_sd2_ts): (Vec<_>, Vec<_>) = new_data.into_iter().unzip(); + let last_ts = self.sd1_ts.last().map(|v| v[0]).unwrap_or(0.0); + self.sd1_ts + .extend( + new_sd1_ts + .drain(..) + .zip(ts.iter().cloned()) + .filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + }), + ); + self.sd2_ts + .extend(new_sd2_ts.drain(..).zip(ts).filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + })); + } + { + let (mut new_data, ts) = + Self::calc_time_series(start_idx, window, &filtered_rr, &filtered_ts, |rr| { + Ok(60000.0 * rr.len() as f64 / rr.iter().sum::()) + })?; + let last_ts = self.hr_ts.last().map(|v| v[0]).unwrap_or(0.0); + self.hr_ts + .extend(new_data.drain(..).zip(ts).filter_map(|(data, ts)| { + let ts = ts.as_seconds_f64(); + if ts > last_ts { + Some([ts, data]) + } else { + None + } + })); + } + Ok(()) } /// Adds a heart rate measurement to the session data. @@ -197,24 +283,64 @@ impl HrvSessionData { /// /// * `hrs_msg` - The `HeartrateMessage` containing HR and RR interval data. /// * `elapsed_time` - The timestamp associated with the message. - fn add_measurement(&mut self, hrs_msg: &HeartrateMessage, elapsed_time: &Duration) { - for &rr_interval in hrs_msg.get_rr_intervals() { - self.add_rr_measurement(rr_interval); + fn add_measurements( + &mut self, + hrs_msgs: &[(Duration, HeartrateMessage)], + window: usize, + ) -> Result<()> { + let rr: Vec<_> = hrs_msgs + .par_iter() + .map(|(_, hrs_msg)| { + hrs_msg + .get_rr_intervals() + .iter() + .filter_map(|&rr| if rr > 0 { Some(f64::from(rr)) } else { None }) + .collect::>() + }) + .flatten() + .collect(); + let rr_len = rr.len(); + self.data.add_data(&rr)?; + self.rr_timepoints.extend(rr.iter().scan( + *self.rr_timepoints.last().unwrap_or(&Duration::default()), + |acc, &rr| { + *acc += Duration::milliseconds(rr as i64); + Some(*acc) + }, + )); + + if let Err(e) = self.calc_statistics(window, rr_len) { + log::warn!("error calculating statistics: {}", e); } - self.hr_values.push(hrs_msg.get_hr()); - self.rx_time.push(*elapsed_time); + Ok(()) } /// Returns a list of Poincaré plot points. /// /// # Returns /// - /// A vector of `[f64; 2]` points representing successive RR intervals. - pub fn get_poincare(&self) -> Vec<[f64; 2]> { - self.rr_intervals - .windows(2) - .map(|win| [win[0], win[1]]) - .collect() + /// A tuple containing two lists of `[x, y]` points: the first list contains inlier points, + /// and the second list contains outlier points. + pub fn get_poincare(&self, window: Option) -> Result { + let data = self.data.get_data(); + let classes = self.data.get_classification(); + if data.len() < 2 { + return Err(anyhow!("too few rr intervals for poincare points")); + } + let start = window.map(|s| data.len().saturating_sub(s)).unwrap_or(0); + let mut inliers = Vec::with_capacity(window.unwrap_or(data.len())); + let mut outliers = Vec::with_capacity(window.unwrap_or(data.len())); + for (rr, classes) in data.windows(2).zip(classes.windows(2)).skip(start) { + if classes[0].is_outlier() || classes[1].is_outlier() { + outliers.push([rr[0], rr[1]]); + } else { + inliers.push([rr[0], rr[1]]); + } + } + inliers.shrink_to_fit(); + outliers.shrink_to_fit(); + + Ok((inliers, outliers)) } /// Checks if there is sufficient data for HRV calculations. @@ -222,187 +348,152 @@ impl HrvSessionData { /// # Returns /// /// `true` if there are enough RR intervals to perform HRV analysis; `false` otherwise. + #[allow(dead_code)] pub fn has_sufficient_data(&self) -> bool { - self.rr_intervals.len() >= 4 + self.data.get_data().len() >= 4 } - /// Applies an outlier filter to the RR intervals and optional time series. - /// - /// # Arguments - /// - /// * `rr_intervals` - A slice of RR intervals to filter. - /// * `opt_rr_time` - An optional slice of timestamps corresponding to the RR intervals. - /// * `outlier_filter` - The outlier threshold for filtering. - /// * `window_size` - The size of the sliding window used for filtering. - /// - /// # Returns - /// - /// A tuple `(Vec, Vec)` containing the filtered RR intervals - /// and timestamps (empty if `opt_rr_time` is `None`). - fn apply_outlier_filter( - rr_intervals: &[f64], - opt_rr_time: Option<&[Duration]>, - outlier_filter: f64, - window_size: usize, - ) -> (Vec, Vec) { - let half_window = window_size / 2; - - // Helper function to check if a value is an outlier - let is_outlier = |idx: usize, values: &[f64]| { - let mut start = idx.saturating_sub(half_window); - let mut end = start + window_size; - if end >= values.len() { - end = values.len(); - start = end.saturating_sub(window_size); - } - - let window = &values[start..end]; - let mean = window - .iter() - .enumerate() - .filter(|(i, _)| start + i != idx) - .map(|(_, &v)| v) - .sum::() - / (window.len() - 1) as f64; - - let deviation = (values[idx] - mean).abs(); - - deviation > outlier_filter - }; - - if let Some(rr_time) = opt_rr_time { - // Process both RR intervals and timestamps - rr_intervals - .iter() - .zip(rr_time) - .enumerate() - .filter_map(|(i, (&rr, &time))| { - if !is_outlier(i, rr_intervals) { - Some((rr, time)) - } else { - None - } - }) - .unzip() - } else { - // Process only RR intervals - ( - rr_intervals - .iter() - .enumerate() - .filter_map(|(i, &rr)| { - if !is_outlier(i, rr_intervals) { - Some(rr) - } else { - None - } - }) - .collect(), - Default::default(), - ) - } + pub fn get_rmssd_ts(&self) -> &[[f64; 2]] { + &self.rmssd_ts } -} - -impl HrvStatistics { - /// Constructs a new `HrvStatistics` from RR intervals and heart rate values. - /// - /// # Arguments - /// - /// * `rr_intervals` - A slice of RR intervals in milliseconds. - /// * `hr_values` - A slice of heart rate values. - /// - /// # Returns - /// - /// Returns an `Ok(HrvStatistics)` containing the calculated HRV statistics, or - /// an `Err` if there is insufficient data. - fn new(rr_intervals: &[f64], hr_values: &[f64]) -> Result { - if rr_intervals.len() < 4 { - return Err(anyhow!( - "Not enough RR intervals for HRV stats calculation." - )); - } - let avg_hr = if hr_values.is_empty() { - 0.0 - } else { - DVector::from_row_slice(hr_values).mean() - }; - - let poincare = calc_poincare_metrics(rr_intervals); - - Ok(HrvStatistics { - rmssd: calc_rmssd(rr_intervals), - sdrr: calc_sdrr(rr_intervals), - sd1: poincare.sd1, - sd1_eigenvec: poincare.sd1_eigenvector, - sd2: poincare.sd2, - sd2_eigenvec: poincare.sd2_eigenvector, - sd1_sd2_ratio: poincare.sd1 / poincare.sd2, - avg_hr, - }) + pub fn get_sdrr_ts(&self) -> &[[f64; 2]] { + &self.sdrr_ts + } + pub fn get_sd1_ts(&self) -> &[[f64; 2]] { + &self.sd1_ts + } + pub fn get_sd2_ts(&self) -> &[[f64; 2]] { + &self.sd2_ts + } + pub fn get_hr_ts(&self) -> &[[f64; 2]] { + &self.hr_ts + } + pub fn get_dfa_alpha_ts(&self) -> &[[f64; 2]] { + &self.dfa_alpha_ts + } + pub fn get_rmssd(&self) -> Option { + self.rmssd_ts.last().map(|v| v[1]) + } + pub fn get_sdrr(&self) -> Option { + self.sdrr_ts.last().map(|v| v[1]) + } + pub fn get_sd1(&self) -> Option { + self.sd1_ts.last().map(|v| v[1]) + } + pub fn get_sd2(&self) -> Option { + self.sd2_ts.last().map(|v| v[1]) + } + pub fn get_hr(&self) -> Option { + self.hr_ts.last().map(|v| v[1]) + } + pub fn get_dfa_alpha(&self) -> Option { + self.dfa_alpha_ts.last().map(|v| v[1]) } } #[cfg(test)] -mod tests { +pub mod tests { + use rand::{Rng, SeedableRng}; + use super::*; + pub fn get_data(len: usize) -> Vec<(Duration, HeartrateMessage)> { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + (0..len) + .map(|idx| { + let rr = rng.gen_range(500..1500); + let hr = rng.gen_range(55..65); + ( + Duration::seconds(idx as _), + HeartrateMessage::from_values(hr, None, &[rr]), + ) + }) + .collect() + } + #[test] fn test_hrv_runtime_data_add_measurement() { - let mut runtime = HrvSessionData::default(); - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); - runtime.add_measurement(&hr_msg, &Duration::milliseconds(500)); + let mut runtime = HrvAnalysisData::default(); + let data = get_data(4); + runtime.add_measurements(&data[0..1], 50).unwrap(); assert!(!runtime.has_sufficient_data()); - runtime.add_measurement(&hr_msg, &Duration::milliseconds(500)); - runtime.add_measurement(&hr_msg, &Duration::milliseconds(500)); - runtime.add_measurement(&hr_msg, &Duration::milliseconds(500)); + runtime.add_measurements(&data[1..], 50).unwrap(); assert!(runtime.has_sufficient_data()); } #[test] - fn test_hrv_statistics_new() { - let rr_intervals = vec![800.0, 810.0, 790.0, 805.0]; - let hr_values = vec![75.0, 76.0, 74.0, 75.5]; - let hrv_stats = HrvStatistics::new(&rr_intervals, &hr_values).unwrap(); - assert!(hrv_stats.rmssd > 0.0); - assert!(hrv_stats.sdrr > 0.0); - assert!(hrv_stats.sd1 > 0.0); - assert!(hrv_stats.sd2 > 0.0); - assert!(hrv_stats.avg_hr > 0.0); + fn test_hrv_session_data_from_acquisition() { + let data = get_data(4); + let session_data = HrvAnalysisData::from_acquisition(&data, None, 50.0).unwrap(); + assert!(session_data.has_sufficient_data()); } #[test] - fn test_hrv_session_data_from_acquisition() { - let hr_msg = HeartrateMessage::new(&[0b10000, 80, 255, 0]); - let data = vec![ - (Duration::milliseconds(0), hr_msg), - (Duration::milliseconds(1000), hr_msg), - (Duration::milliseconds(2000), hr_msg), - (Duration::milliseconds(3000), hr_msg), + fn test_hrv_insufficient_data() { + let data = get_data(2); + let session_data = HrvAnalysisData::from_acquisition(&data, None, 50.0).unwrap(); + assert!(!session_data.has_sufficient_data()); + } + + #[test] + fn test_hrv_outlier_removal() { + let data = [ + ( + Duration::seconds(0), + HeartrateMessage::from_values(60, None, &[600, 1000]), + ), + ( + Duration::seconds(0), + HeartrateMessage::from_values(60, None, &[600, 1000]), + ), + ( + Duration::seconds(1), + HeartrateMessage::from_values(60, None, &[800, 20000]), + ), + ( + Duration::seconds(0), + HeartrateMessage::from_values(60, None, &[600, 1000]), + ), ]; - let session_data = HrvSessionData::from_acquisition(&data, None, 50.0).unwrap(); - assert!(session_data.has_sufficient_data()); - assert!(session_data.hrv_stats.is_some()); + let session_data = HrvAnalysisData::from_acquisition(&data, None, 50.0).unwrap(); + let poincare = session_data.get_poincare(None).unwrap(); + // Expect some outliers because of the large RR interval + assert!(!poincare.1.is_empty()); } #[test] - fn test_apply_outlier_filter() { - let rr_intervals = vec![800.0, 810.0, 790.0, 805.0, 900.0, 805.0, 810.0]; - let (filtered_rr, _) = HrvSessionData::apply_outlier_filter(&rr_intervals, None, 50.0, 5); - assert_eq!(filtered_rr.len(), 6); // The outlier (900.0) should be filtered out + fn test_hrv_poincare_points() { + let data = get_data(5); + let session_data = HrvAnalysisData::from_acquisition(&data, None, 50.0).unwrap(); + let (inliers, outliers) = session_data.get_poincare(None).unwrap(); + assert_eq!(inliers.len() + outliers.len(), 4); } #[test] - fn test_get_poincare() { - let session_data = HrvSessionData { - rr_intervals: vec![800.0, 810.0, 790.0, 805.0], - ..Default::default() - }; - let poincare_points = session_data.get_poincare(); - assert_eq!(poincare_points.len(), 3); - assert_eq!(poincare_points[0], [800.0, 810.0]); - assert_eq!(poincare_points[1], [810.0, 790.0]); - assert_eq!(poincare_points[2], [790.0, 805.0]); + fn test_full_dataset() { + fn assert_ts_props(ts: &[[f64; 2]]) { + ts.windows(2).for_each(|w| { + // time must be progressing + assert!(w[0][0] <= w[1][0]); + assert!(w[0][0] >= 0.0); + assert!((w[0][1] >= 0.0 && w[1][1] >= 0.0) || w[0][1].is_nan() || w[1][1].is_nan()); + }); + } + let data = get_data(256); + let session_data = HrvAnalysisData::from_acquisition(&data, Some(120), 5.0).unwrap(); + assert!(session_data.has_sufficient_data()); + assert!(session_data.get_rmssd().is_some()); + assert!(session_data.get_sdrr().is_some()); + assert!(session_data.get_sd1().is_some()); + assert!(session_data.get_sd2().is_some()); + assert!(session_data.get_hr().is_some()); + assert!(session_data.get_dfa_alpha().is_some()); + assert_ts_props(session_data.get_rmssd_ts()); + assert_ts_props(session_data.get_sdrr_ts()); + assert_ts_props(session_data.get_sd1_ts()); + assert_ts_props(session_data.get_sd2_ts()); + assert_ts_props(session_data.get_hr_ts()); + assert_ts_props(session_data.get_dfa_alpha_ts()); } } diff --git a/src/view/acquisition.rs b/src/view/acquisition.rs index f4b78bc..27d281d 100644 --- a/src/view/acquisition.rs +++ b/src/view/acquisition.rs @@ -7,7 +7,6 @@ use eframe::egui; use egui::Color32; use egui_plot::{Legend, Plot, Points}; use std::ops::RangeInclusive; -use time::Duration; use crate::{ api::{ @@ -15,11 +14,18 @@ use crate::{ model::{BluetoothModelApi, MeasurementModelApi, ModelHandle}, view::ViewApi, }, - core::events::{ - AppEvent, BluetoothEvent, MeasurementEvent, RecordingEvent, StateChangeEvent, StorageEvent, - }, + core::events::{AppEvent, BluetoothEvent, MeasurementEvent, RecordingEvent, StateChangeEvent}, }; +fn render_labelled_data(ui: &mut egui::Ui, label: &str, data: Option) { + if let Some(data) = data { + let desc = egui::Label::new(label); + ui.add(desc); + let val = egui::Label::new(data); + ui.add(val); + } +} + pub fn render_stats(ui: &mut egui::Ui, model: &dyn MeasurementModelApi, hr: f64) { ui.heading("Statistics"); egui::Grid::new("stats grid").num_columns(2).show(ui, |ui| { @@ -34,29 +40,36 @@ pub fn render_stats(ui: &mut egui::Ui, model: &dyn MeasurementModelApi, hr: f64) let val = egui::Label::new(format!("{} s", model.get_elapsed_time().whole_seconds())); ui.add(val); ui.end_row(); - - if let Some(stats) = model.get_hrv_stats() { - let desc = egui::Label::new("RMSSD [ms]"); - ui.add(desc); - let val = egui::Label::new(format!("{:.2} ms", stats.rmssd)); - ui.add(val); - ui.end_row(); - let desc = egui::Label::new("SDRR [ms]"); - ui.add(desc); - let val = egui::Label::new(format!("{:.2} ms", stats.sdrr)); - ui.add(val); - ui.end_row(); - let desc = egui::Label::new("SD1 [ms]"); - ui.add(desc); - let val = egui::Label::new(format!("{:.2} ms", stats.sd1)); - ui.add(val); - ui.end_row(); - let desc = egui::Label::new("SD2 [ms]"); - ui.add(desc); - let val = egui::Label::new(format!("{:.2} ms", stats.sd2)); - ui.add(val); - ui.end_row(); - } + render_labelled_data( + ui, + "RMSSD", + model.get_rmssd().map(|val| format!("{:.2} ms", val)), + ); + ui.end_row(); + render_labelled_data( + ui, + "SDRR", + model.get_sdrr().map(|val| format!("{:.2} ms", val)), + ); + ui.end_row(); + render_labelled_data( + ui, + "SD1", + model.get_sd1().map(|val| format!("{:.2} ms", val)), + ); + ui.end_row(); + render_labelled_data( + ui, + "SD2", + model.get_sd2().map(|val| format!("{:.2} ms", val)), + ); + ui.end_row(); + render_labelled_data( + ui, + "DFA 1 alpha", + model.get_dfa1a().map(|val| format!("{:.2} ms", val)), + ); + ui.end_row(); }); } @@ -65,25 +78,36 @@ pub fn render_time_series(ui: &mut egui::Ui, model: &dyn MeasurementModelApi) { plot.show(ui, |plot_ui| { plot_ui.line( - egui_plot::Line::new(model.get_session_data().rmssd_ts.clone()) + egui_plot::Line::new(model.get_rmssd_ts()) .name("RMSSD [ms]") .color(Color32::RED), ); plot_ui.line( - egui_plot::Line::new(model.get_session_data().sd1_ts.clone()) + egui_plot::Line::new(model.get_sdrr_ts()) + .name("SDRR [ms]") + .color(Color32::DARK_GREEN), + ); + plot_ui.line( + egui_plot::Line::new(model.get_sd1_ts()) .name("SD1 [ms]") .color(Color32::BLUE), ); plot_ui.line( - egui_plot::Line::new(model.get_session_data().sd2_ts.clone()) + egui_plot::Line::new(model.get_sd2_ts()) .name("SD2 [ms]") .color(Color32::YELLOW), ); plot_ui.line( - egui_plot::Line::new(model.get_session_data().hr_ts.clone()) + egui_plot::Line::new(model.get_hr_ts()) .name("HR [1/min]") .color(Color32::GREEN), ); + + plot_ui.line( + egui_plot::Line::new(model.get_dfa1a_ts()) + .name("DFA 1 alpha") + .color(Color32::KHAKI), + ); }); } @@ -93,13 +117,22 @@ pub fn render_poincare_plot(ui: &mut egui::Ui, model: &dyn MeasurementModelApi) .data_aspect(1.0); plot.show(ui, |plot_ui| { - plot_ui.points( - Points::new(model.get_poincare_points()) - .name("R-R Intervals") - .shape(egui_plot::MarkerShape::Diamond) - .color(Color32::RED) - .radius(5.0), - ); + if let Ok((inliers, outliers)) = model.get_poincare_points() { + plot_ui.points( + Points::new(inliers) + .name("R-R") + .shape(egui_plot::MarkerShape::Diamond) + .color(Color32::RED) + .radius(5.0), + ); + plot_ui.points( + Points::new(outliers) + .name("R-R outliers") + .shape(egui_plot::MarkerShape::Diamond) + .color(Color32::GRAY) + .radius(5.0), + ); + } }); } @@ -174,25 +207,20 @@ pub fn render_filter_params( ) { ui.heading("Filter parameters:"); egui::Grid::new("a grid").num_columns(2).show(ui, |ui| { - let mut seconds = model - .get_stats_window() - .unwrap_or(&Duration::minutes(5)) - .as_seconds_f64(); - let desc = egui::Label::new("time window [s]"); + let mut samples = model.get_stats_window().unwrap_or(usize::MAX).to_owned(); + let desc = egui::Label::new("window size [# samples]"); ui.add(desc); - let slider = egui::Slider::new(&mut seconds, RangeInclusive::new(0.0, 600.0)); + let slider = egui::Slider::new(&mut samples, RangeInclusive::new(30, 300)); if ui.add(slider).changed() { - if let Some(new_duration) = Duration::checked_seconds_f64(seconds) { - publish(AppEvent::Measurement(MeasurementEvent::SetStatsWindow( - new_duration, - ))); - } + publish(AppEvent::Measurement(MeasurementEvent::SetStatsWindow( + samples, + ))); } ui.end_row(); let mut outlier_value = model.get_outlier_filter_value(); - let desc = egui::Label::new("outlier filter"); + let desc = egui::Label::new("outlier filter scale"); ui.add(desc); - let slider = egui::Slider::new(&mut outlier_value, RangeInclusive::new(0.1, 400.0)); + let slider = egui::Slider::new(&mut outlier_value, RangeInclusive::new(0.5, 10.0)); if ui.add(slider).changed() { publish(AppEvent::Measurement(MeasurementEvent::SetOutlierFilter( OutlierFilter::MovingMAD { @@ -239,12 +267,11 @@ impl AcquisitionView { } if ui.button("discard").clicked() { publish(AppEvent::Recording(RecordingEvent::StopRecording)); - publish(AppEvent::AppState(StateChangeEvent::InitialState)); + publish(AppEvent::AppState(StateChangeEvent::DiscardRecording)); } if ui.button("Save").clicked() { publish(AppEvent::Recording(RecordingEvent::StopRecording)); - publish(AppEvent::Storage(StorageEvent::StoreRecordedMeasurement)); - publish(AppEvent::AppState(StateChangeEvent::InitialState)); + publish(AppEvent::AppState(StateChangeEvent::StoreRecording)); } }); } diff --git a/src/view/manager.rs b/src/view/manager.rs index 51bdfcc..8aebe0b 100644 --- a/src/view/manager.rs +++ b/src/view/manager.rs @@ -176,3 +176,37 @@ impl App for ViewManager { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::{application::tests::MockBluetooth, measurement::MeasurementData}; + + fn setup_test_manager() -> (ViewManager, Sender) { + let (v_tx, v_rx) = tokio::sync::broadcast::channel(1); + let (e_tx, _e_rx) = tokio::sync::broadcast::channel(1); + let manager = ViewManager::new(v_rx, e_tx); + (manager, v_tx) + } + + #[tokio::test] + async fn test_view_manager_initial_state() { + let (manager, _v_tx) = setup_test_manager(); + let view = manager.active_view.read().await; + assert!(matches!(&*view, View::Empty)); + } + + #[tokio::test] + async fn test_view_manager_state_switch() { + let (manager, v_tx) = setup_test_manager(); + v_tx.send(ViewState::Acquisition(( + Arc::new(RwLock::new(MeasurementData::default())) + as ModelHandle, + Arc::new(RwLock::new(MockBluetooth::new())) as ModelHandle, + ))) + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let view = manager.active_view.read().await; + assert!(matches!(&*view, View::Acquisition(_))); + } +} diff --git a/src/view/overview.rs b/src/view/overview.rs index a6eaec8..fcf2a71 100644 --- a/src/view/overview.rs +++ b/src/view/overview.rs @@ -112,11 +112,7 @@ impl ViewApi for StorageView { let lck = selected.blocking_read(); egui::SidePanel::right("right:overview").show(ctx, |ui| { let model = &*lck; - let hr = if let Some(stats) = model.get_hrv_stats() { - stats.avg_hr - } else { - 0.0 - }; + let hr = model.get_hr().unwrap_or(0.0); render_stats(ui, model, hr); ui.separator(); render_filter_params(ui, &publish, model);