452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
//! 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, ¶ms);
|
||
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, ¶ms);
|
||
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, ¶ms);
|
||
|
||
// 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, ¶ms);
|
||
|
||
// 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, ¶ms);
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|