//! Wall Shear Stress (WSS) computation for hemodynamics //! //! WSS is the tangential force per unit area exerted by flowing blood on //! the vessel wall. It's a critical biomarker for: //! - Aneurysm rupture risk (high WSS at neck) //! - Atherosclerosis progression (low/oscillatory WSS) //! - Endothelial cell health use crate::vessel::VesselSdf; use rtx_hemodynamics_shared::geometry::Point2D; /// Wall Shear Stress computer #[derive(Debug)] pub struct WssComputer { /// Dynamic viscosity (Pa.s) mu: f64, /// Finite difference step for gradient computation eps: f64, } impl WssComputer { /// Creates a new WSS computer /// /// # Arguments /// /// * `mu` - Dynamic viscosity in Pa.s #[must_use] pub fn new(mu: f64) -> Self { Self { mu, eps: 1e-6 } } /// Creates WSS computer for blood #[must_use] pub fn blood() -> Self { Self::new(0.0035) } /// Sets the finite difference step size #[must_use] pub const fn with_eps(mut self, eps: f64) -> Self { self.eps = eps; self } /// Returns the viscosity #[must_use] pub const fn viscosity(&self) -> f64 { self.mu } /// Computes WSS at a wall point given velocity gradients /// /// WSS = μ * |∂`u_t/∂n`| where: /// - `u_t` is the tangential velocity component /// - n is the wall normal direction /// /// For a 2D case with wall normal (nx, ny): /// - Tangent: t = (-ny, nx) /// - ∂`u_t/∂n` = (∂u/∂n) * tx + (∂v/∂n) * ty /// where ∂/∂n = nx * ∂/∂x + ny * ∂/∂y #[must_use] pub fn compute_wss( &self, du_dx: f64, du_dy: f64, dv_dx: f64, dv_dy: f64, normal: &Point2D, ) -> f64 { let nx = normal.x; let ny = normal.y; // Tangent vector (perpendicular to normal) let tx = -ny; let ty = nx; // Normal gradient of velocity components // ∂u/∂n = nx * ∂u/∂x + ny * ∂u/∂y let du_dn = nx * du_dx + ny * du_dy; let dv_dn = nx * dv_dx + ny * dv_dy; // Tangential velocity gradient in normal direction let dut_dn = du_dn * tx + dv_dn * ty; // WSS magnitude self.mu * dut_dn.abs() } /// Computes WSS using finite differences from velocity field /// /// # Arguments /// /// * `wall_point` - Point on the vessel wall /// * `vessel` - Vessel SDF for normal computation /// * `velocity_fn` - Function that returns (u, v) at a point #[must_use] pub fn compute_wss_from_velocity( &self, wall_point: &Point2D, vessel: &VesselSdf, velocity_fn: F, ) -> f64 where F: Fn(&Point2D) -> (f64, f64), { // Get wall normal let normal = vessel.normal(wall_point); // Compute velocity gradients using finite differences let (u, v) = velocity_fn(wall_point); let (u_px, v_px) = velocity_fn(&Point2D::new(wall_point.x + self.eps, wall_point.y)); let (u_py, v_py) = velocity_fn(&Point2D::new(wall_point.x, wall_point.y + self.eps)); let du_dx = (u_px - u) / self.eps; let du_dy = (u_py - u) / self.eps; let dv_dx = (v_px - v) / self.eps; let dv_dy = (v_py - v) / self.eps; self.compute_wss(du_dx, du_dy, dv_dx, dv_dy, &normal) } /// Computes WSS at multiple wall points #[must_use] pub fn compute_wss_batch( &self, wall_points: &[Point2D], vessel: &VesselSdf, velocity_fn: F, ) -> Vec where F: Fn(&Point2D) -> (f64, f64), { wall_points .iter() .map(|p| self.compute_wss_from_velocity(p, vessel, &velocity_fn)) .collect() } /// Computes WSS for Poiseuille flow (analytical validation) /// /// `τ_w` = μ * |du/dr|_wall = 4μQ / (πR³) = 8μ * `u_mean` / R /// /// For parabolic profile u(r) = `u_max` * (1 - r²/R²): /// du/dr = -2 * `u_max` * r / R² /// At wall (r = R): |du/dr| = 2 * `u_max` / R /// `τ_w` = 2μ * `u_max` / R #[must_use] pub fn poiseuille_wss(&self, u_max: f64, radius: f64) -> f64 { 2.0 * self.mu * u_max / radius } /// Computes mean WSS from a set of values #[must_use] pub fn mean_wss(wss_values: &[f64]) -> f64 { if wss_values.is_empty() { return 0.0; } wss_values.iter().sum::() / wss_values.len() as f64 } /// Computes max WSS from a set of values #[must_use] pub fn max_wss(wss_values: &[f64]) -> f64 { wss_values.iter().copied().fold(0.0_f64, f64::max) } /// Identifies high-risk regions where WSS exceeds threshold #[must_use] pub fn high_risk_regions( wall_points: &[Point2D], wss_values: &[f64], threshold: f64, ) -> Vec<(Point2D, f64)> { wall_points .iter() .zip(wss_values.iter()) .filter(|&(_, &wss)| wss > threshold) .map(|(p, &wss)| (*p, wss)) .collect() } /// Computes the Oscillatory Shear Index (OSI) for time-varying WSS /// /// OSI = 0.5 * (1 - |∫`τ_w` dt| / ∫|`τ_w`| dt) /// /// OSI = 0: unidirectional flow /// OSI = 0.5: purely oscillatory flow #[must_use] pub fn oscillatory_shear_index(wss_time_series: &[f64]) -> f64 { if wss_time_series.is_empty() { return 0.0; } let sum_signed: f64 = wss_time_series.iter().sum(); let sum_magnitude: f64 = wss_time_series.iter().map(|w| w.abs()).sum(); if sum_magnitude.abs() < f64::EPSILON { return 0.0; } 0.5 * (1.0 - sum_signed.abs() / sum_magnitude) } /// Computes Time-Averaged WSS (TAWSS) #[must_use] pub fn time_averaged_wss(wss_time_series: &[f64]) -> f64 { if wss_time_series.is_empty() { return 0.0; } wss_time_series.iter().map(|w| w.abs()).sum::() / wss_time_series.len() as f64 } } /// WSS risk classification thresholds #[derive(Debug, Clone, Copy)] pub struct WssThresholds { /// Low WSS threshold (Pa) - below this indicates atherosclerosis risk pub low: f64, /// Normal WSS lower bound (Pa) pub normal_low: f64, /// Normal WSS upper bound (Pa) pub normal_high: f64, /// High WSS threshold (Pa) - above this indicates rupture risk pub high: f64, } impl Default for WssThresholds { fn default() -> Self { // Typical physiological values for arteries Self { low: 0.4, // < 0.4 Pa associated with atherosclerosis normal_low: 1.0, normal_high: 7.0, high: 15.0, // > 15 Pa associated with aneurysm rupture risk } } } impl WssThresholds { /// Classifies a WSS value #[must_use] pub fn classify(&self, wss: f64) -> WssRiskLevel { if wss < self.low { WssRiskLevel::Low } else if wss < self.normal_low { WssRiskLevel::BelowNormal } else if wss <= self.normal_high { WssRiskLevel::Normal } else if wss <= self.high { WssRiskLevel::AboveNormal } else { WssRiskLevel::High } } } /// WSS risk level classification #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WssRiskLevel { /// Very low WSS - atherosclerosis risk Low, /// Below normal WSS BelowNormal, /// Normal physiological WSS Normal, /// Above normal WSS AboveNormal, /// Very high WSS - rupture risk High, } #[cfg(test)] mod tests { use super::*; #[test] fn test_wss_computer_creation() { let wss = WssComputer::blood(); assert!((wss.viscosity() - 0.0035).abs() < f64::EPSILON); } #[test] fn test_poiseuille_wss() { let computer = WssComputer::blood(); // For u_max = 0.1 m/s, R = 0.005 m // τ_w = 2 * 0.0035 * 0.1 / 0.005 = 0.14 Pa let wss = computer.poiseuille_wss(0.1, 0.005); assert!((wss - 0.14).abs() < 1e-10); } #[test] fn test_wss_from_gradients() { let computer = WssComputer::blood(); // Simple shear flow: u = y * γ̇, v = 0 // du/dx = 0, du/dy = γ̇, dv/dx = 0, dv/dy = 0 // At wall with normal (0, 1): WSS = μ * γ̇ let shear_rate = 100.0; // 1/s let normal = Point2D::new(0.0, 1.0); let wss = computer.compute_wss(0.0, shear_rate, 0.0, 0.0, &normal); let expected = 0.0035 * shear_rate; assert!((wss - expected).abs() < 1e-10); } #[test] fn test_oscillatory_shear_index() { // Unidirectional flow: all positive WSS let unidirectional = vec![1.0, 1.0, 1.0, 1.0]; let osi = WssComputer::oscillatory_shear_index(&unidirectional); assert!(osi.abs() < f64::EPSILON, "Unidirectional OSI should be 0"); // Perfectly oscillatory: equal positive and negative let oscillatory = vec![1.0, -1.0, 1.0, -1.0]; let osi = WssComputer::oscillatory_shear_index(&oscillatory); assert!( (osi - 0.5).abs() < f64::EPSILON, "Oscillatory OSI should be 0.5" ); } #[test] fn test_time_averaged_wss() { let wss_series = vec![1.0, 2.0, 3.0, 4.0]; let tawss = WssComputer::time_averaged_wss(&wss_series); assert!((tawss - 2.5).abs() < f64::EPSILON); // With negative values let wss_series = vec![1.0, -2.0, 3.0, -4.0]; let tawss = WssComputer::time_averaged_wss(&wss_series); assert!((tawss - 2.5).abs() < f64::EPSILON); } #[test] fn test_wss_thresholds() { let thresholds = WssThresholds::default(); assert_eq!(thresholds.classify(0.2), WssRiskLevel::Low); assert_eq!(thresholds.classify(0.7), WssRiskLevel::BelowNormal); assert_eq!(thresholds.classify(3.0), WssRiskLevel::Normal); assert_eq!(thresholds.classify(10.0), WssRiskLevel::AboveNormal); assert_eq!(thresholds.classify(20.0), WssRiskLevel::High); } #[test] fn test_high_risk_regions() { let points = vec![ Point2D::new(0.01, 0.005), Point2D::new(0.02, 0.005), Point2D::new(0.03, 0.005), ]; let wss_values = vec![1.0, 20.0, 5.0]; let high_risk = WssComputer::high_risk_regions(&points, &wss_values, 10.0); assert_eq!(high_risk.len(), 1); assert!((high_risk[0].0.x - 0.02).abs() < f64::EPSILON); assert!((high_risk[0].1 - 20.0).abs() < f64::EPSILON); } #[test] fn test_mean_max_wss() { let wss_values = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let mean = WssComputer::mean_wss(&wss_values); let max = WssComputer::max_wss(&wss_values); assert!((mean - 3.0).abs() < f64::EPSILON); assert!((max - 5.0).abs() < f64::EPSILON); } }