//! 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; } } }