Initial commit
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user