Files
Entropyk/crates/fluids/src/damping.rs

452 lines
15 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.
//! Critical point damping for thermodynamic properties.
//!
//! This module provides functionality to detect near-critical regions and apply
//! C1-continuous damping to prevent NaN values in derivative properties (Cp, Cv, etc.)
//! that diverge near the critical point.
use crate::types::{CriticalPoint, FluidId, FluidState, Property};
/// Parameters for critical point damping.
#[derive(Debug, Clone)]
pub struct DampingParams {
/// Reduced temperature threshold (default: 0.05 = 5%)
pub reduced_temp_threshold: f64,
/// Reduced pressure threshold (default: 0.05 = 5%)
pub reduced_pressure_threshold: f64,
/// Smoothness parameter for sigmoid transition (default: 0.01)
pub smoothness: f64,
/// Maximum allowed Cp value in J/(kg·K) (default: 1e6)
pub cp_max: f64,
/// Maximum allowed Cv value in J/(kg·K) (default: 1e6)
pub cv_max: f64,
/// Maximum allowed derivative value (default: 1e10)
pub derivative_max: f64,
}
impl Default for DampingParams {
fn default() -> Self {
DampingParams {
reduced_temp_threshold: 0.05,
reduced_pressure_threshold: 0.05,
smoothness: 0.01,
cp_max: 1e6,
cv_max: 1e6,
derivative_max: 1e10,
}
}
}
/// Extracts pressure and temperature from a FluidState.
/// Returns None if state cannot be converted to (P, T).
pub fn state_to_pt(state: &FluidState) -> Option<(f64, f64)> {
match state {
FluidState::PressureTemperature(p, t) => Some((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpy(_, _) => None,
FluidState::PressureEntropy(_, _) => None,
FluidState::PressureQuality(_, _) => None,
FluidState::PressureTemperatureMixture(p, t, _) => Some((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpyMixture(_, _, _) => None,
FluidState::PressureQualityMixture(_, _, _) => None,
}
}
/// Calculate reduced coordinates (Tr, Pr) from absolute values and critical point.
///
/// - Tr = T / Tc
/// - Pr = P / Pc
pub fn reduced_coordinates(
temperature_kelvin: f64,
pressure_pascals: f64,
cp: &CriticalPoint,
) -> (f64, f64) {
let tr = temperature_kelvin / cp.temperature_kelvin();
let pr = pressure_pascals / cp.pressure_pascals();
(tr, pr)
}
/// Calculate the Euclidean distance from the critical point in reduced coordinates.
///
/// Distance = sqrt((Tr - 1)^2 + (Pr - 1)^2)
pub fn reduced_distance(temperature_kelvin: f64, pressure_pascals: f64, cp: &CriticalPoint) -> f64 {
let (tr, pr) = reduced_coordinates(temperature_kelvin, pressure_pascals, cp);
((tr - 1.0).powi(2) + (pr - 1.0).powi(2)).sqrt()
}
/// Check if a state is within the near-critical region.
///
/// A state is "near critical" if:
/// |Tr - 1| < threshold AND |Pr - 1| < threshold
pub fn near_critical_point(
temperature_kelvin: f64,
pressure_pascals: f64,
cp: &CriticalPoint,
threshold: f64,
) -> bool {
let (tr, pr) = reduced_coordinates(temperature_kelvin, pressure_pascals, cp);
(tr - 1.0).abs() < threshold && (pr - 1.0).abs() < threshold
}
/// C1-continuous sigmoid blend factor.
///
/// Blend factor α: 0 = far from critical (use raw), 1 = at critical (use damped).
/// C1-continuous: α and dα/d(distance) are continuous.
///
/// - distance < threshold => near critical => α → 1
/// - distance > threshold + width => far => α → 0
pub fn sigmoid_blend(distance: f64, threshold: f64, width: f64) -> f64 {
// α = 0.5 * (1 + tanh((threshold - distance) / width))
// At distance = 0 (critical): α ≈ 1
// At distance = threshold: α = 0.5
// At distance >> threshold: α → 0
let x = (threshold - distance) / width;
0.5 * (1.0 + x.tanh())
}
/// Derivative of sigmoid blend factor with respect to distance.
///
/// This is used to ensure C1 continuity when applying damping.
pub fn sigmoid_blend_derivative(distance: f64, threshold: f64, width: f64) -> f64 {
// derivative of 0.5 * (1 + tanh((threshold - distance) / width)) with respect to distance
// = 0.5 * sech^2((threshold - distance) / width) * (-1 / width)
// = -0.5 * sech^2(x) / width where x = (threshold - distance) / width
let x = (threshold - distance) / width;
let sech = 1.0 / x.cosh();
-0.5 * sech * sech / width
}
/// Apply damping to a property value.
///
/// Returns the damped value using sigmoid blending between raw and capped values.
pub fn damp_property(value: f64, max_value: f64, blend_factor: f64) -> f64 {
let capped = value.abs().min(max_value) * value.signum();
blend_factor * capped + (1.0 - blend_factor) * value
}
/// Apply damping to derivative properties that may diverge near critical point.
///
/// Properties like Cp, Cv, and (∂ρ/∂P)_T can diverge near the critical point.
/// This function applies a smooth cap to prevent NaN values.
pub fn damp_derivative(value: f64, params: &DampingParams) -> f64 {
let blend = sigmoid_blend(0.0, params.reduced_temp_threshold, params.smoothness);
damp_property(value, params.derivative_max, blend)
}
/// Check if a property should be damped.
///
/// Derivative properties (Cp, Cv, etc.) may diverge near critical point.
pub fn should_damp_property(property: Property) -> bool {
matches!(
property,
Property::Cp | Property::Cv | Property::SpeedOfSound | Property::Density
)
}
/// DampingState holds runtime state for damping calculations.
#[derive(Debug, Clone)]
pub struct DampingState {
/// Whether damping is active for the current query
pub is_damping: bool,
/// The blend factor (0 = no damping, 1 = full damping)
pub blend_factor: f64,
/// Distance from critical point
pub distance: f64,
}
impl DampingState {
/// Create a new DampingState with no damping
pub fn none() -> Self {
DampingState {
is_damping: false,
blend_factor: 0.0,
distance: f64::MAX,
}
}
}
/// Calculate damping state for a given fluid and state.
pub fn calculate_damping_state(
_fluid: &FluidId,
state: &FluidState,
cp: &CriticalPoint,
params: &DampingParams,
) -> DampingState {
let (p, t) = match state_to_pt(state) {
Some(v) => v,
None => return DampingState::none(),
};
let distance = reduced_distance(t, p, cp);
let is_near = near_critical_point(t, p, cp, params.reduced_temp_threshold);
if !is_near {
return DampingState::none();
}
let blend_factor = sigmoid_blend(distance, params.reduced_temp_threshold, params.smoothness);
DampingState {
is_damping: true,
blend_factor,
distance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::FluidState;
use entropyk_core::{Pressure, Temperature};
fn make_co2_critical_point() -> CriticalPoint {
CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.6,
)
}
#[test]
fn test_reduced_coordinates() {
let cp = make_co2_critical_point();
// At critical point: Tr = 1, Pr = 1
let (tr, pr) = reduced_coordinates(304.13, 7.3773e6, &cp);
assert!((tr - 1.0).abs() < 1e-10);
assert!((pr - 1.0).abs() < 1e-10);
// At 5% above critical
let (tr, pr) = reduced_coordinates(319.3365, 7.746165e6, &cp);
assert!((tr - 1.05).abs() < 1e-6);
assert!((pr - 1.05).abs() < 1e-6);
}
#[test]
fn test_reduced_distance_at_critical() {
let cp = make_co2_critical_point();
// At critical point, distance should be 0
let dist = reduced_distance(304.13, 7.3773e6, &cp);
assert!(dist.abs() < 1e-10);
}
#[test]
fn test_near_critical_point_true() {
let cp = make_co2_critical_point();
// At critical point
assert!(near_critical_point(304.13, 7.3773e6, &cp, 0.05));
// 5% from critical
let t = 304.13 * 1.03;
let p = 7.3773e6 * 1.03;
assert!(near_critical_point(t, p, &cp, 0.05));
}
#[test]
fn test_near_critical_point_false() {
let cp = make_co2_critical_point();
// Far from critical (room temperature, 1 bar)
assert!(!near_critical_point(298.15, 1e5, &cp, 0.05));
// Outside 5% threshold
let t = 304.13 * 1.10;
let p = 7.3773e6 * 1.10;
assert!(!near_critical_point(t, p, &cp, 0.05));
}
#[test]
fn test_sigmoid_blend_at_critical() {
let threshold = 0.05;
let width = 0.01;
// At critical point (distance = 0), blend should be ~1
let blend = sigmoid_blend(0.0, threshold, width);
assert!(
blend > 0.99,
"Expected blend > 0.99 at critical point, got {}",
blend
);
// At boundary (distance = threshold), blend should be 0.5
let blend = sigmoid_blend(threshold, threshold, width);
assert!(
(blend - 0.5).abs() < 0.001,
"Expected blend ~0.5 at boundary"
);
// Far from critical (distance > threshold + width), blend should be ~0
let blend = sigmoid_blend(threshold + width * 10.0, threshold, width);
assert!(blend < 0.001);
}
#[test]
fn test_sigmoid_blend_derivative() {
let threshold = 0.05;
let width = 0.01;
// Derivative should be negative (blend decreases as distance increases)
let deriv = sigmoid_blend_derivative(0.0, threshold, width);
assert!(deriv < 0.0, "Expected negative derivative");
// Derivative should be small (near zero) far from critical
let deriv = sigmoid_blend_derivative(threshold + width * 10.0, threshold, width);
assert!(deriv.abs() < 1e-6);
}
#[test]
fn test_sigmoid_c1_continuous() {
let threshold = 0.05;
let width = 0.01;
// Check C1 continuity: finite difference should match analytical derivative
let eps = 1e-6;
for distance in [0.0, 0.02, 0.04, 0.06, 0.08] {
let deriv_analytical = sigmoid_blend_derivative(distance, threshold, width);
let deriv_numerical = (sigmoid_blend(distance + eps, threshold, width)
- sigmoid_blend(distance - eps, threshold, width))
/ (2.0 * eps);
assert!(
(deriv_analytical - deriv_numerical).abs() < 1e-4,
"C1 continuity failed at distance {}: analytical={}, numerical={}",
distance,
deriv_analytical,
deriv_numerical
);
}
}
#[test]
fn test_damp_property() {
// Large value should be capped
let damped = damp_property(1e8, 1e6, 1.0);
assert!(damped.abs() < 1e6 + 1.0);
// Small value should remain unchanged
let damped = damp_property(1000.0, 1e6, 1.0);
assert!((damped - 1000.0).abs() < 1.0);
// Partial blend
let damped = damp_property(1e8, 1e6, 0.5);
assert!(damped > 1e6 && damped < 1e8);
}
#[test]
fn test_state_to_pt() {
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let (p, t) = state_to_pt(&state).unwrap();
assert!((p - 1e5).abs() < 1.0);
assert!((t - 298.15).abs() < 1.0);
// Enthalpy state should return None
let state = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(400.0),
);
assert!(state_to_pt(&state).is_none());
}
#[test]
fn test_should_damp_property() {
assert!(should_damp_property(Property::Cp));
assert!(should_damp_property(Property::Cv));
assert!(should_damp_property(Property::Density));
assert!(should_damp_property(Property::SpeedOfSound));
assert!(!should_damp_property(Property::Enthalpy));
assert!(!should_damp_property(Property::Entropy));
assert!(!should_damp_property(Property::Pressure));
assert!(!should_damp_property(Property::Temperature));
}
#[test]
fn test_calculate_damping_state_near_critical() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// At critical point
let state = FluidState::from_pt(
Pressure::from_pascals(7.3773e6),
Temperature::from_kelvin(304.13),
);
let fluid = FluidId::new("CO2");
let damping = calculate_damping_state(&fluid, &state, &cp, &params);
assert!(damping.is_damping);
assert!(damping.blend_factor > 0.9);
}
#[test]
fn test_calculate_damping_state_far_from_critical() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// Room temperature, 1 bar - far from critical
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let fluid = FluidId::new("CO2");
let damping = calculate_damping_state(&fluid, &state, &cp, &params);
assert!(!damping.is_damping);
}
#[test]
fn test_damping_region_boundary_smooth_transition() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// 4.9% from critical - inside region
let t_near = 304.13 * (1.0 + 0.049);
let p_near = 7.3773e6 * (1.0 + 0.049);
let state_near = FluidState::from_pt(
Pressure::from_pascals(p_near),
Temperature::from_kelvin(t_near),
);
let damping_near = calculate_damping_state(&FluidId::new("CO2"), &state_near, &cp, &params);
// 5.1% from critical - outside region
let t_far = 304.13 * (1.0 + 0.051);
let p_far = 7.3773e6 * (1.0 + 0.051);
let state_far = FluidState::from_pt(
Pressure::from_pascals(p_far),
Temperature::from_kelvin(t_far),
);
let damping_far = calculate_damping_state(&FluidId::new("CO2"), &state_far, &cp, &params);
// Should transition smoothly
assert!(damping_near.is_damping, "4.9% should be in damping region");
assert!(
!damping_far.is_damping,
"5.1% should be outside damping region"
);
}
#[test]
fn test_damping_transition_is_smooth() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// Test at various distances around the boundary
let distances = [0.03, 0.04, 0.045, 0.05, 0.055, 0.06];
let mut previous_blend = 1.0;
for d in distances {
let t = 304.13 * (1.0 + d);
let p = 7.3773e6 * (1.0 + d);
let state = FluidState::from_pt(Pressure::from_pascals(p), Temperature::from_kelvin(t));
let damping = calculate_damping_state(&FluidId::new("CO2"), &state, &cp, &params);
let blend = damping.blend_factor;
// Blend should decrease smoothly (no sudden jumps)
assert!(
blend <= previous_blend + 0.1,
"Blend should decrease smoothly: prev={}, curr={}",
previous_blend,
blend
);
previous_blend = blend;
}
}
}