Files
rustytorch/demos/rtx-hemodynamics/src/navier_stokes.rs
T
2026-03-04 00:08:42 +00:00

539 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 2D Incompressible Navier-Stokes residual computation
//!
//! This module implements the physics-informed loss terms for the 2D
//! incompressible Navier-Stokes equations:
//!
//! **Momentum (x)**: ρ(∂u/∂t + u∂u/∂x + v∂u/∂y) = -∂p/∂x + μ(∂²u/∂x² + ∂²u/∂y²)
//! **Momentum (y)**: ρ(∂v/∂t + u∂v/∂x + v∂v/∂y) = -∂p/∂y + μ(∂²v/∂x² + ∂²v/∂y²)
//! **Continuity**: ∂u/∂x + ∂v/∂y = 0
use rtx_hemodynamics_shared::physics::FluidProperties;
/// Navier-Stokes residual computer for 2D incompressible flow
///
/// Computes the physics residuals for the momentum and continuity equations.
#[derive(Debug, Clone)]
pub struct NavierStokesResidual {
/// Fluid density (kg/m³)
rho: f64,
/// Dynamic viscosity (Pa.s)
mu: f64,
/// Whether flow is steady-state (no time derivatives)
steady_state: bool,
}
impl NavierStokesResidual {
/// Creates a new Navier-Stokes residual computer
#[must_use]
pub fn new(fluid: &FluidProperties, steady_state: bool) -> Self {
Self {
rho: fluid.density(),
mu: fluid.dynamic_viscosity(),
steady_state,
}
}
/// Creates residual computer for blood with steady-state assumption
#[must_use]
pub fn blood_steady() -> Self {
Self::new(&FluidProperties::blood(), true)
}
/// Creates residual computer for blood with transient flow
#[must_use]
pub fn blood_transient() -> Self {
Self::new(&FluidProperties::blood(), false)
}
/// Returns the fluid density
#[must_use]
pub const fn density(&self) -> f64 {
self.rho
}
/// Returns the dynamic viscosity
#[must_use]
pub const fn viscosity(&self) -> f64 {
self.mu
}
/// Returns whether using steady-state assumption
#[must_use]
pub const fn is_steady_state(&self) -> bool {
self.steady_state
}
/// Computes the x-momentum residual at a single point
///
/// `R_x` = ρ(∂u/∂t + u∂u/∂x + v∂u/∂y) + ∂p/∂x - μ(∂²u/∂x² + ∂²u/∂y²)
///
/// For steady-state: `R_x` = ρ(u∂u/∂x + v∂u/∂y) + ∂p/∂x - μ(∂²u/∂x² + ∂²u/∂y²)
///
/// # Arguments
///
/// * `u` - x-velocity
/// * `v` - y-velocity
/// * `du_dt` - time derivative of u (ignored if steady-state)
/// * `du_dx` - spatial derivative ∂u/∂x
/// * `du_dy` - spatial derivative ∂u/∂y
/// * `d2u_dx2` - second derivative ∂²u/∂x²
/// * `d2u_dy2` - second derivative ∂²u/∂y²
/// * `dp_dx` - pressure gradient ∂p/∂x
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn x_momentum_residual(
&self,
u: f64,
v: f64,
du_dt: f64,
du_dx: f64,
du_dy: f64,
d2u_dx2: f64,
d2u_dy2: f64,
dp_dx: f64,
) -> f64 {
let time_term = if self.steady_state { 0.0 } else { du_dt };
let convection = u * du_dx + v * du_dy;
let diffusion = d2u_dx2 + d2u_dy2;
self.rho * (time_term + convection) + dp_dx - self.mu * diffusion
}
/// Computes the y-momentum residual at a single point
///
/// `R_y` = ρ(∂v/∂t + u∂v/∂x + v∂v/∂y) + ∂p/∂y - μ(∂²v/∂x² + ∂²v/∂y²)
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn y_momentum_residual(
&self,
u: f64,
v: f64,
dv_dt: f64,
dv_dx: f64,
dv_dy: f64,
d2v_dx2: f64,
d2v_dy2: f64,
dp_dy: f64,
) -> f64 {
let time_term = if self.steady_state { 0.0 } else { dv_dt };
let convection = u * dv_dx + v * dv_dy;
let diffusion = d2v_dx2 + d2v_dy2;
self.rho * (time_term + convection) + dp_dy - self.mu * diffusion
}
/// Computes the continuity residual at a single point
///
/// `R_cont` = ∂u/∂x + ∂v/∂y
///
/// For incompressible flow, this should be zero (divergence-free).
#[must_use]
pub fn continuity_residual(&self, du_dx: f64, dv_dy: f64) -> f64 {
du_dx + dv_dy
}
/// Computes all three residuals at a single point
///
/// Returns (`R_x`, `R_y`, `R_cont`)
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn all_residuals(
&self,
u: f64,
v: f64,
du_dt: f64,
dv_dt: f64,
du_dx: f64,
du_dy: f64,
dv_dx: f64,
dv_dy: f64,
d2u_dx2: f64,
d2u_dy2: f64,
d2v_dx2: f64,
d2v_dy2: f64,
dp_dx: f64,
dp_dy: f64,
) -> (f64, f64, f64) {
let r_x = self.x_momentum_residual(u, v, du_dt, du_dx, du_dy, d2u_dx2, d2u_dy2, dp_dx);
let r_y = self.y_momentum_residual(u, v, dv_dt, dv_dx, dv_dy, d2v_dx2, d2v_dy2, dp_dy);
let r_cont = self.continuity_residual(du_dx, dv_dy);
(r_x, r_y, r_cont)
}
/// Computes the total physics loss (MSE of all residuals)
///
/// `L_physics` = `mean(R_x²` + `R_y²` + `R_cont²`)
#[must_use]
pub fn physics_loss(&self, residuals: &[(f64, f64, f64)]) -> f64 {
if residuals.is_empty() {
return 0.0;
}
let sum: f64 = residuals
.iter()
.map(|(rx, ry, rc)| rx.powi(2) + ry.powi(2) + rc.powi(2))
.sum();
sum / residuals.len() as f64
}
/// Computes individual loss components for monitoring
///
/// Returns (`momentum_x_loss`, `momentum_y_loss`, `continuity_loss`)
#[must_use]
pub fn loss_components(&self, residuals: &[(f64, f64, f64)]) -> (f64, f64, f64) {
if residuals.is_empty() {
return (0.0, 0.0, 0.0);
}
let n = residuals.len() as f64;
let (sum_x, sum_y, sum_c) = residuals.iter().fold((0.0, 0.0, 0.0), |acc, (rx, ry, rc)| {
(acc.0 + rx.powi(2), acc.1 + ry.powi(2), acc.2 + rc.powi(2))
});
(sum_x / n, sum_y / n, sum_c / n)
}
}
/// Analytical solution for 2D planar Poiseuille flow (steady channel flow)
///
/// This represents flow between two parallel plates (2D channel flow),
/// which exactly satisfies the 2D Cartesian Navier-Stokes equations.
///
/// For a channel of half-height h:
/// - Velocity profile: u(y) = (1/2μ)(-dp/dx)(h² - y²)
/// - Maximum velocity at centerline (y=0)
/// - Zero velocity at walls (y=±h)
#[derive(Debug, Clone)]
pub struct PoiseuilleFlow {
/// Channel half-height (m) - distance from centerline to wall
half_height: f64,
/// Pressure gradient (Pa/m)
dp_dx: f64,
/// Dynamic viscosity (Pa.s)
mu: f64,
}
impl PoiseuilleFlow {
/// Creates a new 2D planar Poiseuille flow solution
///
/// # Arguments
///
/// * `half_height` - Channel half-height (distance from centerline to wall) in meters
/// * `pressure_drop` - Total pressure drop over channel length
/// * `length` - Channel length in meters
/// * `mu` - Dynamic viscosity in Pa.s
#[must_use]
pub fn new(half_height: f64, pressure_drop: f64, length: f64, mu: f64) -> Self {
Self {
half_height,
dp_dx: -pressure_drop / length, // Negative because pressure decreases
mu,
}
}
/// Creates Poiseuille flow with blood properties
#[must_use]
pub fn blood(half_height: f64, pressure_drop: f64, length: f64) -> Self {
Self::new(
half_height,
pressure_drop,
length,
FluidProperties::blood().dynamic_viscosity(),
)
}
/// Returns the channel half-height (also called radius for compatibility)
#[must_use]
pub const fn radius(&self) -> f64 {
self.half_height
}
/// Returns the pressure gradient
#[must_use]
pub const fn pressure_gradient(&self) -> f64 {
self.dp_dx
}
/// Computes the analytical velocity at position y from centerline
///
/// u(y) = (1/2μ)(-dp/dx)(h² - y²)
///
/// The velocity profile is parabolic with maximum at centerline.
#[must_use]
pub fn velocity(&self, y: f64) -> f64 {
if y.abs() > self.half_height {
return 0.0; // Outside channel
}
(-self.dp_dx / (2.0 * self.mu)) * (self.half_height.powi(2) - y.powi(2))
}
/// Returns the maximum (centerline) velocity
#[must_use]
pub fn max_velocity(&self) -> f64 {
self.velocity(0.0)
}
/// Returns the average velocity
///
/// `u_avg` = (2/3) * `u_max` for 2D channel flow
#[must_use]
pub fn average_velocity(&self) -> f64 {
self.max_velocity() * 2.0 / 3.0
}
/// Computes the volumetric flow rate per unit depth
///
/// Q = (2h³ / 3μ) * (-dp/dx)
#[must_use]
pub fn flow_rate(&self) -> f64 {
2.0 * self.half_height.powi(3) * (-self.dp_dx) / (3.0 * self.mu)
}
/// Computes the wall shear stress
///
/// `τ_w` = μ|du/dy|_wall = h * (-dp/dx)
#[must_use]
pub fn wall_shear_stress(&self) -> f64 {
self.half_height * (-self.dp_dx)
}
/// Computes the velocity gradient at position y
///
/// du/dy = (1/μ)(dp/dx) * y
#[must_use]
pub fn velocity_gradient(&self, y: f64) -> f64 {
(self.dp_dx / self.mu) * y
}
/// Computes the pressure at axial position x (relative to inlet)
///
/// p(x) = `p_inlet` + dp/dx * x
#[must_use]
pub fn pressure(&self, x: f64, p_inlet: f64) -> f64 {
p_inlet + self.dp_dx * x
}
/// Validates that Navier-Stokes residuals are zero for this analytical solution
///
/// For 2D planar Poiseuille flow:
/// - u = u(y), v = 0
/// - All time derivatives are zero
/// - du/dx = 0 (fully developed)
/// - dv/dx = dv/dy = 0
/// - d²u/dx² = 0
/// - d²u/dy² = (dp/dx) / μ
#[must_use]
pub fn validate_residuals(&self, ns: &NavierStokesResidual, y: f64) -> (f64, f64, f64) {
let u = self.velocity(y);
let v = 0.0;
// Time derivatives (steady-state)
let du_dt = 0.0;
let dv_dt = 0.0;
// Spatial derivatives
let du_dx = 0.0; // Fully developed
let du_dy = self.velocity_gradient(y);
let dv_dx = 0.0;
let dv_dy = 0.0;
// Second derivatives
let d2u_dx2 = 0.0;
// d²u/dy² = (dp/dx) / μ for 2D planar flow
let d2u_dy2 = self.dp_dx / self.mu;
let d2v_dx2 = 0.0;
let d2v_dy2 = 0.0;
// Pressure gradients
let dp_dx = self.dp_dx;
let dp_dy = 0.0;
ns.all_residuals(
u, v, du_dt, dv_dt, du_dx, du_dy, dv_dx, dv_dy, d2u_dx2, d2u_dy2, d2v_dx2, d2v_dy2,
dp_dx, dp_dy,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ns_residual_creation() {
let ns = NavierStokesResidual::blood_steady();
assert!((ns.density() - 1060.0).abs() < 1.0);
assert!((ns.viscosity() - 0.0035).abs() < 0.001);
assert!(ns.is_steady_state());
}
#[test]
fn test_continuity_residual() {
let ns = NavierStokesResidual::blood_steady();
// Divergence-free flow should have zero residual
let residual = ns.continuity_residual(0.5, -0.5);
assert!(residual.abs() < f64::EPSILON);
// Non-divergence-free should have non-zero residual
let residual = ns.continuity_residual(0.5, 0.3);
assert!((residual - 0.8).abs() < f64::EPSILON);
}
#[test]
fn test_poiseuille_velocity_profile() {
// Create Poiseuille flow with known parameters
let radius = 0.005; // 5mm pipe
let pressure_drop = 100.0; // 100 Pa drop
let length = 0.1; // 10cm length
let mu = 0.0035; // Blood viscosity
let flow = PoiseuilleFlow::new(radius, pressure_drop, length, mu);
// Maximum velocity at centerline
let u_max = flow.max_velocity();
assert!(u_max > 0.0);
// Zero velocity at wall
let u_wall = flow.velocity(radius);
assert!(u_wall.abs() < 1e-10);
// Velocity at r=0 should be maximum
let u_center = flow.velocity(0.0);
assert!((u_center - u_max).abs() < f64::EPSILON);
// Parabolic profile: u(R/2) = 3/4 * u_max
let u_half = flow.velocity(radius / 2.0);
assert!((u_half - 0.75 * u_max).abs() < 1e-10);
}
#[test]
fn test_poiseuille_wall_shear_stress() {
let half_height = 0.005;
let pressure_drop = 100.0;
let length = 0.1;
let mu = 0.0035;
let flow = PoiseuilleFlow::new(half_height, pressure_drop, length, mu);
let wss = flow.wall_shear_stress();
// For 2D planar flow: τ_w = h * |dp/dx|
let expected = half_height * (pressure_drop / length);
assert!((wss - expected).abs() < 1e-10);
}
#[test]
fn test_poiseuille_validates_ns() {
// The Poiseuille solution should satisfy Navier-Stokes exactly
let radius = 0.005;
let pressure_drop = 100.0;
let length = 0.1;
let mu = 0.0035;
let flow = PoiseuilleFlow::new(radius, pressure_drop, length, mu);
let ns = NavierStokesResidual::new(&FluidProperties::new(1060.0, mu).unwrap(), true);
// Test at multiple radial positions
for i in 0..10 {
let r = radius * (i as f64) / 10.0;
let (rx, ry, rc) = flow.validate_residuals(&ns, r);
// All residuals should be near zero (within numerical precision)
assert!(
rx.abs() < 1e-6,
"X-momentum residual too large at r={}: {}",
r,
rx
);
assert!(
ry.abs() < 1e-10,
"Y-momentum residual too large at r={}: {}",
r,
ry
);
assert!(
rc.abs() < 1e-10,
"Continuity residual too large at r={}: {}",
r,
rc
);
}
}
#[test]
fn test_physics_loss_computation() {
let ns = NavierStokesResidual::blood_steady();
// Perfect solution should have zero loss
let perfect_residuals = vec![(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)];
let loss = ns.physics_loss(&perfect_residuals);
assert!(loss.abs() < f64::EPSILON);
// Non-zero residuals should give positive loss
let residuals = vec![(1.0, 0.0, 0.0), (0.0, 1.0, 0.0)];
let loss = ns.physics_loss(&residuals);
assert!((loss - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_loss_components() {
let ns = NavierStokesResidual::blood_steady();
let residuals = vec![(1.0, 2.0, 3.0), (1.0, 2.0, 3.0)];
let (lx, ly, lc) = ns.loss_components(&residuals);
assert!((lx - 1.0).abs() < f64::EPSILON);
assert!((ly - 4.0).abs() < f64::EPSILON);
assert!((lc - 9.0).abs() < f64::EPSILON);
}
#[test]
fn test_poiseuille_flow_rate() {
let half_height = 0.005;
let pressure_drop = 100.0;
let length = 0.1;
let mu = 0.0035;
let flow = PoiseuilleFlow::new(half_height, pressure_drop, length, mu);
// 2D planar flow rate per unit depth: Q = (2h³ / 3μ) * |dp/dx|
let dp_dx = pressure_drop / length;
let expected_q = 2.0 * half_height.powi(3) * dp_dx / (3.0 * mu);
let actual_q = flow.flow_rate();
assert!((actual_q - expected_q).abs() < 1e-15);
}
#[test]
fn test_steady_vs_transient() {
let fluid = FluidProperties::blood();
let steady = NavierStokesResidual::new(&fluid, true);
let transient = NavierStokesResidual::new(&fluid, false);
// With non-zero du/dt, steady should ignore it
let r_steady = steady.x_momentum_residual(
1.0, 0.0, // u, v
10.0, // du_dt (should be ignored)
0.0, 0.0, // du_dx, du_dy
0.0, 0.0, // d2u_dx2, d2u_dy2
0.0, // dp_dx
);
let r_transient = transient.x_momentum_residual(
1.0, 0.0, // u, v
10.0, // du_dt (should be included)
0.0, 0.0, // du_dx, du_dy
0.0, 0.0, // d2u_dx2, d2u_dy2
0.0, // dp_dx
);
// Steady should have zero (no time term)
assert!(r_steady.abs() < f64::EPSILON);
// Transient should have ρ * du/dt = 1060 * 10 = 10600
assert!((r_transient - 1060.0 * 10.0).abs() < 0.1);
}
}