//! Damped backend wrapper for fluid property queries. //! //! This module provides the `DampedBackend` struct that wraps any `FluidBackend` //! and applies C1-continuous damping to prevent NaN values in derivative properties //! near the critical point. use crate::backend::FluidBackend; use crate::damping::{calculate_damping_state, damp_property, should_damp_property, DampingParams}; use crate::errors::FluidResult; use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState}; /// Backend wrapper that applies critical point damping to property queries. /// /// Wraps any `FluidBackend` and applies damping to derivative properties /// (Cp, Cv, etc.) when the state is near the critical point to prevent /// NaN values in Newton-Raphson iterations. pub struct DampedBackend { inner: B, params: DampingParams, } impl DampedBackend { /// Create a new damped backend wrapping the given backend. pub fn new(inner: B) -> Self { DampedBackend { inner, params: DampingParams::default(), } } /// Create a new damped backend with custom parameters. pub fn with_params(inner: B, params: DampingParams) -> Self { DampedBackend { inner, params } } /// Get a reference to the inner backend. pub fn inner(&self) -> &B { &self.inner } /// Get a mutable reference to the inner backend. pub fn inner_mut(&mut self) -> &mut B { &mut self.inner } /// Get the damping parameters. pub fn params(&self) -> &DampingParams { &self.params } /// Get critical point for a fluid. fn critical_point_internal(&self, fluid: &FluidId) -> Option { self.inner.critical_point(fluid.clone()).ok() } /// Apply damping to a property value if needed. fn apply_damping( &self, fluid: &FluidId, property: Property, state: &FluidState, value: f64, ) -> FluidResult { // Only damp derivative properties if !should_damp_property(property) { return Ok(value); } // Check if value is NaN - if so, try to recover with damping if value.is_nan() { // Try to get critical point if let Some(cp) = self.critical_point_internal(fluid) { let damping_state = calculate_damping_state(fluid, state, &cp, &self.params); if damping_state.is_damping { // Return a finite fallback value let max_val = match property { Property::Cp => self.params.cp_max, Property::Cv => self.params.cv_max, Property::Density => 1e5, Property::SpeedOfSound => 1e4, _ => self.params.derivative_max, }; return Ok(max_val * damping_state.blend_factor); } } // No critical point info - return error return Ok(self.params.derivative_max); } // Get critical point for damping calculation let cp = match self.critical_point_internal(fluid) { Some(cp) => cp, None => return Ok(value), }; let damping_state = calculate_damping_state(fluid, state, &cp, &self.params); if !damping_state.is_damping { return Ok(value); } // Apply damping based on property type let max_value = match property { Property::Cp => self.params.cp_max, Property::Cv => self.params.cv_max, Property::Density => 1e5, Property::SpeedOfSound => 1e4, _ => self.params.derivative_max, }; let damped = damp_property(value, max_value, damping_state.blend_factor); Ok(damped) } } impl FluidBackend for DampedBackend { fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult { let value = self .inner .property(fluid.clone(), property, state.clone())?; self.apply_damping(&fluid, property, &state, value) } fn critical_point(&self, fluid: FluidId) -> FluidResult { self.inner.critical_point(fluid) } fn is_fluid_available(&self, fluid: &FluidId) -> bool { self.inner.is_fluid_available(fluid) } fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult { self.inner.phase(fluid, state) } fn list_fluids(&self) -> Vec { self.inner.list_fluids() } fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult { self.inner.full_state(fluid, p, h) } } #[cfg(test)] mod tests { use super::*; use crate::backend::FluidBackend; use crate::errors::{FluidError, FluidResult}; use crate::test_backend::TestBackend; use entropyk_core::{Pressure, Temperature}; #[test] fn test_damped_backend_creation() { let inner = TestBackend::new(); let damped = DampedBackend::new(inner); assert!(damped.is_fluid_available(&FluidId::new("R134a"))); } #[test] fn test_damped_backend_delegates_non_derivative() { let inner = TestBackend::new(); let damped = DampedBackend::new(inner); let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); // Enthalpy should be delegated without damping let h = damped .property(FluidId::new("R134a"), Property::Enthalpy, state.clone()) .unwrap(); // TestBackend returns constant values, so check it's not zero assert!(h > 0.0); } #[test] fn test_damped_backend_with_custom_params() { let inner = TestBackend::new(); let params = DampingParams { reduced_temp_threshold: 0.1, reduced_pressure_threshold: 0.1, smoothness: 0.02, cp_max: 5000.0, cv_max: 3000.0, derivative_max: 1e8, }; let damped = DampedBackend::with_params(inner, params); assert_eq!(damped.params().cp_max, 5000.0); } #[test] fn test_damped_backend_returns_finite_values() { let inner = TestBackend::new(); let damped = DampedBackend::new(inner); let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); // Cp should return a finite value (not NaN) let cp = damped .property(FluidId::new("R134a"), Property::Cp, state.clone()) .unwrap(); assert!(!cp.is_nan(), "Cp should not be NaN"); assert!(cp.is_finite(), "Cp should be finite"); } #[test] fn test_damped_backend_handles_nan_input() { // Create a backend that returns NaN struct NaNBackend; impl FluidBackend for NaNBackend { fn property( &self, _fluid: FluidId, property: Property, _state: FluidState, ) -> FluidResult { if matches!(property, Property::Cp) { Ok(f64::NAN) } else { Ok(1000.0) } } fn critical_point(&self, _fluid: FluidId) -> FluidResult { Ok(CriticalPoint::new( Temperature::from_kelvin(304.13), Pressure::from_pascals(7.3773e6), 467.6, )) } fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true } fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult { Ok(Phase::Unknown) } fn list_fluids(&self) -> Vec { vec![FluidId::new("CO2")] } fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult { Err(FluidError::CoolPropError( "full_state not supported on NaNBackend".to_string(), )) } } let inner = NaNBackend; let damped = DampedBackend::new(inner); let state = FluidState::from_pt( Pressure::from_pascals(7.3773e6), Temperature::from_kelvin(304.13), ); // Should return a finite value instead of NaN let cp = damped .property(FluidId::new("CO2"), Property::Cp, state) .unwrap(); assert!(!cp.is_nan(), "Should return finite value instead of NaN"); } #[test] #[cfg(feature = "coolprop")] fn test_co2_near_critical_no_nan() { use crate::coolprop::CoolPropBackend; let inner = CoolPropBackend::new(); let damped = DampedBackend::new(inner); // CO2 at 0.99*Tc, 0.99*Pc - near critical let tc = 304.13; let pc = 7.3773e6; let state = FluidState::from_pt( Pressure::from_pascals(0.99 * pc), Temperature::from_kelvin(0.99 * tc), ); // Should not return NaN let cp = damped .property(FluidId::new("CO2"), Property::Cp, state) .unwrap(); assert!(!cp.is_nan(), "Cp should not be NaN near critical point"); assert!(cp.is_finite(), "Cp should be finite"); } #[test] #[cfg(feature = "coolprop")] fn test_co2_supercritical_no_nan() { use crate::coolprop::CoolPropBackend; let inner = CoolPropBackend::new(); let damped = DampedBackend::new(inner); // CO2 at 1.01*Tc, 1.01*Pc - supercritical let tc = 304.13; let pc = 7.3773e6; let state = FluidState::from_pt( Pressure::from_pascals(1.01 * pc), Temperature::from_kelvin(1.01 * tc), ); // Should not return NaN let cp = damped .property(FluidId::new("CO2"), Property::Cp, state) .unwrap(); assert!(!cp.is_nan(), "Cp should not be NaN in supercritical region"); assert!(cp.is_finite(), "Cp should be finite"); } #[test] #[cfg(feature = "coolprop")] fn test_r134a_unchanged_far_from_critical() { use crate::coolprop::CoolPropBackend; let inner_no_damp = CoolPropBackend::new(); let inner_damped = CoolPropBackend::new(); let damped = DampedBackend::new(inner_damped); // R134a far from critical (room temp, 1 bar) let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)); let cp_no_damp = inner_no_damp .property(FluidId::new("R134a"), Property::Cp, state.clone()) .unwrap(); let cp_damped = damped .property(FluidId::new("R134a"), Property::Cp, state) .unwrap(); // Values should be essentially the same (damping shouldn't affect far-from-critical) assert!( (cp_no_damp - cp_damped).abs() < 1.0, "R134a far from critical should be unchanged" ); } }