feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,166 @@
//! Fluid backend trait and implementations.
//!
//! This module defines the core `FluidBackend` trait that abstracts the source
//! of thermodynamic property data, allowing the solver to switch between different
//! backends (CoolProp, tabular data, mock for testing).
use crate::errors::FluidResult;
use crate::mixture::Mixture;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState, ThermoState};
use entropyk_core::{Pressure, Temperature};
/// Trait for fluid property backends.
///
/// Implementors must provide methods to query thermodynamic properties
/// for various fluids. This allows the solver to work with different
/// property sources (CoolProp, tabular data, mock data for testing).
///
/// # Example
///
/// ```
/// use entropyk_fluids::{FluidBackend, FluidId, Property, FluidState, ThermoState, FluidError, FluidResult, CriticalPoint};
///
/// struct MyBackend;
/// impl FluidBackend for MyBackend {
/// fn property(&self, _fluid: FluidId, _property: Property, _state: FluidState) -> FluidResult<f64> {
/// Ok(1.0)
/// }
/// fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
/// Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
/// }
/// fn is_fluid_available(&self, _fluid: &FluidId) -> bool { false }
/// fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<entropyk_fluids::Phase> {
/// Ok(entropyk_fluids::Phase::Unknown)
/// }
/// fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<ThermoState> {
/// Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
/// }
/// fn list_fluids(&self) -> Vec<FluidId> { vec![] }
/// }
/// ```
pub trait FluidBackend: Send + Sync {
/// Query a thermodynamic property for a fluid at a given state.
///
/// # Arguments
/// * `fluid` - The fluid identifier (e.g., "R134a", "CO2")
/// * `property` - The property to query
/// * `state` - The thermodynamic state specification
///
/// # Returns
/// The property value in SI units, or an error if the property
/// cannot be computed (unknown fluid, invalid state, etc.)
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64>;
/// Compute the complete thermodynamic state of a fluid at a given pressure and enthalpy.
///
/// This method is intended to be implemented by backends capable of natively calculating
/// all key parameters (phase, saturation temperatures, qualities, limits) without the user
/// needing to query them individually.
///
/// # Arguments
/// * `fluid` - The fluid identifier
/// * `p` - The absolute pressure
/// * `h` - The specific enthalpy
///
/// # Returns
/// The comprehensive `ThermoState` Snapshot, or an Error.
fn full_state(&self, fluid: FluidId, p: Pressure, h: entropyk_core::Enthalpy) -> FluidResult<ThermoState>;
/// Get critical point data for a fluid.
///
/// # Arguments
/// * `fluid` - The fluid identifier
///
/// # Returns
/// The critical point (Tc, Pc, density), or an error if not available
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint>;
/// Check if a fluid is available in this backend.
///
/// # Arguments
/// * `fluid` - The fluid identifier
///
/// # Returns
/// `true` if the fluid is available, `false` otherwise
fn is_fluid_available(&self, fluid: &FluidId) -> bool;
/// Get the phase of a fluid at a given state.
///
/// # Arguments
/// * `fluid` - The fluid identifier
/// * `state` - The thermodynamic state
///
/// # Returns
/// The phase (Liquid, Vapor, TwoPhase, etc.)
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase>;
/// List all available fluids in this backend.
fn list_fluids(&self) -> Vec<FluidId>;
/// Calculate the bubble point temperature for a mixture at given pressure.
///
/// The bubble point is the temperature at which a liquid mixture begins to boil
/// (saturated liquid temperature).
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The bubble point temperature in Kelvin
fn bubble_point(&self, _pressure: Pressure, _mixture: &Mixture) -> FluidResult<Temperature> {
Err(crate::errors::FluidError::UnsupportedProperty {
property: "Bubble point calculation not supported by this backend".to_string(),
})
}
/// Calculate the dew point temperature for a mixture at given pressure.
///
/// The dew point is the temperature at which a vapor mixture begins to condense
/// (saturated vapor temperature).
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The dew point temperature in Kelvin
fn dew_point(&self, _pressure: Pressure, _mixture: &Mixture) -> FluidResult<Temperature> {
Err(crate::errors::FluidError::UnsupportedProperty {
property: "Dew point calculation not supported by this backend".to_string(),
})
}
/// Calculate the temperature glide for a mixture at given pressure.
///
/// Temperature glide is the difference between dew point and bubble point
/// temperatures: T_glide = T_dew - T_bubble.
/// This is non-zero for zeotropic mixtures and zero for azeotropes/pure fluids.
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The temperature glide in Kelvin
fn temperature_glide(&self, pressure: Pressure, mixture: &Mixture) -> FluidResult<f64> {
let t_bubble = self.bubble_point(pressure, mixture)?;
let t_dew = self.dew_point(pressure, mixture)?;
Ok(t_dew.to_kelvin() - t_bubble.to_kelvin())
}
/// Check if a mixture is supported by this backend.
///
/// # Arguments
/// * `mixture` - The mixture to check
///
/// # Returns
/// `true` if the mixture is supported, `false` otherwise
fn is_mixture_supported(&self, mixture: &Mixture) -> bool {
// Default implementation: check if all components are available
mixture
.components()
.iter()
.all(|c| self.is_fluid_available(&FluidId::new(c)))
}
}

235
crates/fluids/src/cache.rs Normal file
View File

@@ -0,0 +1,235 @@
//! Thread-local LRU cache for fluid property queries.
//!
//! Avoids redundant backend calls without mutex contention by using
//! per-thread storage. Cache keys use quantized state values since f64
//! does not implement Hash.
//!
//! # Quantization Strategy
//!
//! State values (P, T, h, s, x) are quantized to 1e-9 relative precision
//! for cache key derivation. Solver iterations often repeat the same
//! (P,T) or (P,h) states; quantization should not lose cache hits for
//! typical thermodynamic ranges (P: 1e31e7 Pa, T: 200600 K).
use crate::mixture::Mixture;
use crate::types::{FluidId, Property, FluidState};
use lru::LruCache;
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::num::NonZeroUsize;
/// Default cache capacity (entries). LRU eviction when exceeded.
pub const DEFAULT_CACHE_CAPACITY: usize = 10_000;
/// Default capacity as NonZeroUsize for LruCache (avoids unwrap in production path).
const DEFAULT_CAP_NONZERO: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(DEFAULT_CACHE_CAPACITY) };
/// Quantization factor: values rounded to 1e-9 relative.
/// (v * 1e9).round() as i64 for Hash-compatible key.
#[inline]
fn quantize(v: f64) -> i64 {
if v.is_nan() || v.is_infinite() {
0
} else {
(v * 1e9).round() as i64
}
}
/// Cache key for fluid property lookups.
///
/// Uses quantized state values since f64 does not implement Hash.
/// Includes backend_id so multiple CachedBackend instances don't mix results.
/// For mixtures, includes a hash of the mixture composition.
#[derive(Clone, Debug)]
pub struct CacheKey {
backend_id: usize,
fluid: String,
property: Property,
variant: u8,
p_quantized: i64,
second_quantized: i64,
mixture_hash: Option<u64>,
}
impl PartialEq for CacheKey {
fn eq(&self, other: &Self) -> bool {
self.backend_id == other.backend_id
&& self.fluid == other.fluid
&& self.property == other.property
&& self.variant == other.variant
&& self.p_quantized == other.p_quantized
&& self.second_quantized == other.second_quantized
&& self.mixture_hash == other.mixture_hash
}
}
impl Eq for CacheKey {}
impl Hash for CacheKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.backend_id.hash(state);
self.fluid.hash(state);
self.property.hash(state);
self.variant.hash(state);
self.p_quantized.hash(state);
self.second_quantized.hash(state);
self.mixture_hash.hash(state);
}
}
impl CacheKey {
/// Build a cache key from fluid, property, state, and backend id.
pub fn new(backend_id: usize, fluid: &FluidId, property: Property, state: &FluidState) -> Self {
let (p, second, variant, mixture_hash) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin(), 0u8, None),
FluidState::PressureEnthalpy(p, h) => {
(p.to_pascals(), h.to_joules_per_kg(), 1u8, None)
}
FluidState::PressureEntropy(p, s) => {
(p.to_pascals(), s.to_joules_per_kg_kelvin(), 2u8, None)
}
FluidState::PressureQuality(p, x) => (p.to_pascals(), x.value(), 3u8, None),
FluidState::PressureTemperatureMixture(p, t, ref m) => {
(p.to_pascals(), t.to_kelvin(), 4u8, Some(mix_hash(m)))
}
FluidState::PressureEnthalpyMixture(p, h, ref m) => {
(p.to_pascals(), h.to_joules_per_kg(), 5u8, Some(mix_hash(m)))
}
FluidState::PressureQualityMixture(p, x, ref m) => {
(p.to_pascals(), x.value(), 6u8, Some(mix_hash(m)))
}
};
CacheKey {
backend_id,
fluid: fluid.0.clone(),
property,
variant,
p_quantized: quantize(p),
second_quantized: quantize(second),
mixture_hash,
}
}
}
/// Compute a simple hash for a mixture for cache key purposes.
fn mix_hash(mixture: &Mixture) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
mixture.hash(&mut hasher);
hasher.finish()
}
thread_local! {
static CACHE: RefCell<LruCache<CacheKey, f64>> = RefCell::new(
LruCache::new(DEFAULT_CAP_NONZERO)
);
}
/// Get a value from the thread-local cache (no allocation on key build for hot path).
pub fn cache_get(
backend_id: usize,
fluid: &FluidId,
property: Property,
state: &FluidState,
) -> Option<f64> {
let key = CacheKey::new(backend_id, fluid, property, state);
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.get(&key).copied()
})
}
/// Insert a value into the thread-local cache.
pub fn cache_insert(
backend_id: usize,
fluid: &FluidId,
property: Property,
state: &FluidState,
value: f64,
) {
let key = CacheKey::new(backend_id, fluid, property, state);
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.put(key, value);
});
}
/// Clear the thread-local cache (e.g. at solver iteration boundaries).
pub fn cache_clear() {
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.clear();
});
}
/// Resize the thread-local cache capacity.
pub fn cache_resize(capacity: NonZeroUsize) {
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.resize(capacity);
});
}
#[cfg(test)]
mod tests {
use super::*;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_cache_key_quantization() {
let fluid = FluidId::new("R134a");
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state);
let key2 = CacheKey::new(0, &fluid, Property::Density, &state);
assert_eq!(key1, key2);
// Equal keys must have same hash (for HashMap use)
use std::collections::hash_map::DefaultHasher;
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
key1.hash(&mut h1);
key2.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_cache_key_different_states() {
let fluid = FluidId::new("R134a");
let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let state2 = FluidState::from_pt(Pressure::from_bar(2.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state1);
let key2 = CacheKey::new(0, &fluid, Property::Density, &state2);
assert_ne!(key1, key2);
}
#[test]
fn test_lru_eviction() {
use std::num::NonZeroUsize;
cache_clear();
cache_resize(NonZeroUsize::new(2).expect("2 is non-zero"));
let fluid = FluidId::new("R134a");
let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(20.0));
let state2 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let state3 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(30.0));
cache_insert(0, &fluid, Property::Density, &state1, 1000.0);
cache_insert(0, &fluid, Property::Density, &state2, 1100.0);
cache_insert(0, &fluid, Property::Density, &state3, 1200.0);
assert!(cache_get(0, &fluid, Property::Density, &state1).is_none());
assert_eq!(cache_get(0, &fluid, Property::Density, &state2), Some(1100.0));
assert_eq!(cache_get(0, &fluid, Property::Density, &state3), Some(1200.0));
cache_resize(NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity is non-zero"));
}
#[test]
fn test_cache_key_different_backends() {
let fluid = FluidId::new("R134a");
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state);
let key2 = CacheKey::new(1, &fluid, Property::Density, &state);
assert_ne!(key1, key2);
}
}

View File

@@ -0,0 +1,174 @@
//! Cached backend wrapper for fluid property queries.
//!
//! Wraps any `FluidBackend` with a thread-local LRU cache to avoid
//! redundant calculations. No mutex contention; zero allocation on cache hit.
use crate::backend::FluidBackend;
use crate::cache::{cache_clear, cache_get, cache_insert};
use crate::errors::FluidResult;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_BACKEND_ID: AtomicUsize = AtomicUsize::new(0);
/// Backend wrapper that caches property queries in a thread-local LRU cache.
///
/// Wraps any `FluidBackend` and caches successful property() results.
/// Other trait methods (critical_point, phase, etc.) delegate to the inner backend
/// without caching, as they are typically called less frequently.
///
/// # Example
///
/// ```
/// use entropyk_fluids::{CachedBackend, FluidBackend, FluidId, Property, FluidState, TestBackend};
/// use entropyk_core::{Pressure, Temperature};
///
/// let inner = TestBackend::new();
/// let cached = CachedBackend::new(inner);
///
/// let state = FluidState::from_pt(
/// Pressure::from_bar(1.0),
/// Temperature::from_celsius(25.0),
/// );
///
/// let v1 = cached.property(FluidId::new("R134a"), Property::Density, state.clone()).unwrap();
/// let v2 = cached.property(FluidId::new("R134a"), Property::Density, state).unwrap();
/// assert_eq!(v1, v2); // Second call served from cache
/// ```
pub struct CachedBackend<B: FluidBackend> {
backend_id: usize,
inner: B,
}
impl<B: FluidBackend> CachedBackend<B> {
/// Create a new cached backend wrapping the given backend.
pub fn new(inner: B) -> Self {
let backend_id = NEXT_BACKEND_ID.fetch_add(1, Ordering::Relaxed);
CachedBackend { backend_id, inner }
}
/// Clear the thread-local cache. Call at solver iteration boundaries if needed.
///
/// **Note:** This clears the cache for *all* `CachedBackend` instances on the current
/// thread, since they share one thread-local cache. If you need per-backend invalidation,
/// use separate threads or a different caching strategy.
pub fn clear_cache(&self) {
cache_clear();
}
/// Get a reference to the inner backend.
pub fn inner(&self) -> &B {
&self.inner
}
}
impl<B: FluidBackend> FluidBackend for CachedBackend<B> {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
if let Some(v) = cache_get(self.backend_id, &fluid, property, &state) {
return Ok(v);
}
let v = self.inner.property(fluid.clone(), property, state.clone())?;
cache_insert(self.backend_id, &fluid, property, &state, v);
Ok(v)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
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<Phase> {
self.inner.phase(fluid, state)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.inner.list_fluids()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
self.inner.full_state(fluid, p, h)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_backend::TestBackend;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_cache_hit_returns_same_value() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let v1 = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
let v2 = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_eq!(v1, v2);
}
#[test]
fn test_cache_miss_delegates_to_backend() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let v = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(v > 0.0);
}
#[test]
fn test_cache_invalidation() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let _ = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
cached.clear_cache();
// After clear, next query should still work (delegates to backend)
let v = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(v > 0.0);
}
#[test]
fn test_cached_benchmark_10k_queries() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
for _ in 0..10_000 {
let _ = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
}
}
#[test]
fn test_cached_backend_implements_fluid_backend() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
assert!(cached.is_fluid_available(&FluidId::new("R134a")));
let cp = cached.critical_point(FluidId::new("R134a")).unwrap();
assert!(cp.temperature_kelvin() > 300.0);
let fluids = cached.list_fluids();
assert!(!fluids.is_empty());
}
}

View File

@@ -0,0 +1,647 @@
//! CoolProp backend implementation.
//!
//! This module provides the `CoolPropBackend` struct that implements the `FluidBackend` trait
//! using the CoolProp C++ library for thermodynamic property calculations.
#[cfg(feature = "coolprop")]
use crate::damped_backend::DampedBackend;
use crate::errors::{FluidError, FluidResult};
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
#[cfg(feature = "coolprop")]
use crate::mixture::Mixture;
#[cfg(feature = "coolprop")]
use std::collections::HashMap;
#[cfg(feature = "coolprop")]
use std::sync::RwLock;
#[cfg(feature = "coolprop")]
use entropyk_coolprop_sys as coolprop;
/// A fluid property backend using the CoolProp C++ library.
///
/// This backend provides high-accuracy thermodynamic properties using the
/// CoolProp library, which implements the NIST REFPROP equations of state.
#[cfg(feature = "coolprop")]
pub struct CoolPropBackend {
/// Cache for critical point data
critical_cache: RwLock<HashMap<String, CriticalPoint>>,
/// List of available fluids
available_fluids: Vec<FluidId>,
}
#[cfg(feature = "coolprop")]
impl CoolPropBackend {
/// Creates a new CoolPropBackend.
pub fn new() -> Self {
let backend = CoolPropBackend {
critical_cache: RwLock::new(HashMap::new()),
available_fluids: vec![
FluidId::new("R134a"),
FluidId::new("R410A"),
FluidId::new("R404A"),
FluidId::new("R407C"),
FluidId::new("R32"),
FluidId::new("R125"),
FluidId::new("R744"),
FluidId::new("R290"),
FluidId::new("R600"),
FluidId::new("R600a"),
FluidId::new("Water"),
FluidId::new("Air"),
],
};
backend
}
/// Creates a new CoolPropBackend with critical point damping enabled.
///
/// This wraps the backend with a `DampedBackend` to apply C1-continuous
/// damping to derivative properties (Cp, Cv, etc.) near the critical point,
/// preventing NaN values in Newton-Raphson iterations.
pub fn with_damping() -> DampedBackend<CoolPropBackend> {
DampedBackend::new(Self::new())
}
/// Get the CoolProp internal name for a fluid.
fn fluid_name(&self, fluid: &FluidId) -> String {
// Map common names to CoolProp internal names
match fluid.0.to_lowercase().as_str() {
"r134a" => "R134a".to_string(),
"r410a" => "R410A".to_string(),
"r404a" => "R404A".to_string(),
"r407c" => "R407C".to_string(),
"r32" => "R32".to_string(),
"r125" => "R125".to_string(),
"co2" | "r744" => "CO2".to_string(),
"r290" => "R290".to_string(),
"r600" => "R600".to_string(),
"r600a" => "R600A".to_string(),
"water" => "Water".to_string(),
"air" => "Air".to_string(),
n => n.to_string(),
}
}
/// Convert Property to CoolProp character code.
fn property_code(property: Property) -> &'static str {
match property {
Property::Density => "D",
Property::Enthalpy => "H",
Property::Entropy => "S",
Property::InternalEnergy => "U",
Property::Cp => "C",
Property::Cv => "O", // Cv in CoolProp
Property::SpeedOfSound => "A",
Property::Viscosity => "V",
Property::ThermalConductivity => "L",
Property::SurfaceTension => "I",
Property::Quality => "Q",
Property::Temperature => "T",
Property::Pressure => "P",
}
}
}
#[cfg(feature = "coolprop")]
impl Default for CoolPropBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "coolprop")]
impl crate::backend::FluidBackend for CoolPropBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
// Handle mixture states
if state.is_mixture() {
return self.property_mixture(fluid, property, state);
}
let coolprop_fluid = self.fluid_name(&fluid);
let prop_code = Self::property_code(property);
// Check if fluid is available
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
// Query property based on state input type
let result = match state {
FluidState::PressureTemperature(p, t) => unsafe {
coolprop::props_si_pt(prop_code, p.to_pascals(), t.to_kelvin(), &coolprop_fluid)
},
FluidState::PressureEnthalpy(p, h) => unsafe {
coolprop::props_si_ph(
prop_code,
p.to_pascals(),
h.to_joules_per_kg(),
&coolprop_fluid,
)
},
FluidState::PressureEntropy(_p, _s) => {
// CoolProp doesn't have direct PS, use iterative approach or PH
return Err(FluidError::UnsupportedProperty {
property: format!("P-S not directly supported, use P-T or P-h"),
});
}
FluidState::PressureQuality(p, q) => unsafe {
coolprop::props_si_px(prop_code, p.to_pascals(), q.value(), &coolprop_fluid)
},
// Mixture variants handled above
FluidState::PressureTemperatureMixture(_, _, _) => unreachable!(),
FluidState::PressureEnthalpyMixture(_, _, _) => unreachable!(),
FluidState::PressureQualityMixture(_, _, _) => unreachable!(),
};
// Check for NaN (indicates error in CoolProp)
if result.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for {} at {:?}", fluid, state),
});
}
Ok(result)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
// Check cache first
if let Some(cp) = self.critical_cache.read().unwrap().get(&fluid.0) {
return Ok(*cp);
}
let coolprop_fluid = self.fluid_name(&fluid);
unsafe {
let tc = coolprop::critical_temperature(&coolprop_fluid);
let pc = coolprop::critical_pressure(&coolprop_fluid);
let dc = coolprop::critical_density(&coolprop_fluid);
if tc.is_nan() || pc.is_nan() || dc.is_nan() {
return Err(FluidError::NoCriticalPoint { fluid: fluid.0 });
}
let cp = CriticalPoint::new(
entropyk_core::Temperature::from_kelvin(tc),
entropyk_core::Pressure::from_pascals(pc),
dc,
);
// Cache the result
self.critical_cache.write().unwrap().insert(fluid.0, cp);
Ok(cp)
}
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
let coolprop_fluid = self.fluid_name(fluid);
unsafe { coolprop::is_fluid_available(&coolprop_fluid) }
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
// Handle mixture states
if state.is_mixture() {
return self.phase_mix(fluid, state);
}
let quality = self.property(fluid.clone(), Property::Quality, state)?;
if quality < 0.0 {
// Below saturated liquid - likely subcooled liquid
Ok(Phase::Liquid)
} else if quality > 1.0 {
// Above saturated vapor - superheated
Ok(Phase::Vapor)
} else if (quality - 0.0).abs() < 1e-6 {
// Saturated liquid
Ok(Phase::Liquid)
} else if (quality - 1.0).abs() < 1e-6 {
// Saturated vapor
Ok(Phase::Vapor)
} else {
// Two-phase region
Ok(Phase::TwoPhase)
}
}
fn list_fluids(&self) -> Vec<FluidId> {
self.available_fluids.clone()
}
fn bubble_point(
&self,
pressure: entropyk_core::Pressure,
mixture: &Mixture,
) -> FluidResult<entropyk_core::Temperature> {
if !self.is_mixture_supported(mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let p_pa = pressure.to_pascals();
unsafe {
// For bubble point (saturated liquid), use Q=0
let t = coolprop::props_si_tq("T", p_pa, 0.0, &cp_string);
if t.is_nan() {
return Err(FluidError::NumericalError(
"CoolProp returned NaN for bubble point calculation".to_string(),
));
}
Ok(entropyk_core::Temperature::from_kelvin(t))
}
}
fn dew_point(
&self,
pressure: entropyk_core::Pressure,
mixture: &Mixture,
) -> FluidResult<entropyk_core::Temperature> {
if !self.is_mixture_supported(mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let p_pa = pressure.to_pascals();
unsafe {
// For dew point (saturated vapor), use Q=1
let t = coolprop::props_si_tq("T", p_pa, 1.0, &cp_string);
if t.is_nan() {
return Err(FluidError::NumericalError(
"CoolProp returned NaN for dew point calculation".to_string(),
));
}
Ok(entropyk_core::Temperature::from_kelvin(t))
}
}
fn is_mixture_supported(&self, mixture: &Mixture) -> bool {
mixture
.components()
.iter()
.all(|c| self.is_fluid_available(&FluidId::new(c)))
}
/// Property calculation for mixtures.
fn property_mixture(
&self,
fluid: FluidId,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
// Extract mixture from state
let mixture = match state {
FluidState::PressureTemperatureMixture(_, _, m) => m,
FluidState::PressureEnthalpyMixture(_, _, m) => m,
FluidState::PressureQualityMixture(_, _, m) => m,
_ => unreachable!(),
};
if !self.is_mixture_supported(&mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let prop_code = Self::property_code(property);
let result = match state {
FluidState::PressureTemperatureMixture(p, t, _) => unsafe {
coolprop::props_si_pt(prop_code, p.to_pascals(), t.to_kelvin(), &cp_string)
},
FluidState::PressureEnthalpyMixture(p, h, _) => unsafe {
coolprop::props_si_ph(prop_code, p.to_pascals(), h.to_joules_per_kg(), &cp_string)
},
FluidState::PressureQualityMixture(p, q, _) => unsafe {
coolprop::props_si_px(prop_code, p.to_pascals(), q.value(), &cp_string)
},
_ => unreachable!(),
};
if result.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for mixture at {:?}", state),
});
}
Ok(result)
}
/// Phase calculation for mixtures.
fn phase_mix(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
let quality = self.property_mixture(fluid, Property::Quality, state)?;
if quality < 0.0 {
Ok(Phase::Liquid)
} else if quality > 1.0 {
Ok(Phase::Vapor)
} else if (quality - 0.0).abs() < 1e-6 {
Ok(Phase::Liquid)
} else if (quality - 1.0).abs() < 1e-6 {
Ok(Phase::Vapor)
} else {
Ok(Phase::TwoPhase)
}
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let coolprop_fluid = self.fluid_name(&fluid);
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
let p_pa = p.to_pascals();
let h_j_kg = h.to_joules_per_kg();
unsafe {
let t_k = coolprop::props_si_ph("T", p_pa, h_j_kg, &coolprop_fluid);
if t_k.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for Temperature at P={}, h={} for {}", p_pa, h_j_kg, fluid),
});
}
let s_j_kg_k = coolprop::props_si_ph("S", p_pa, h_j_kg, &coolprop_fluid);
let d_kg_m3 = coolprop::props_si_ph("D", p_pa, h_j_kg, &coolprop_fluid);
let q = coolprop::props_si_ph("Q", p_pa, h_j_kg, &coolprop_fluid);
let phase = self.phase(fluid.clone(), FluidState::from_ph(p, h))?;
let quality = if (0.0..=1.0).contains(&q) {
Some(crate::types::Quality::new(q))
} else {
None
};
let t_bubble = coolprop::props_si_pq("T", p_pa, 0.0, &coolprop_fluid);
let t_dew = coolprop::props_si_pq("T", p_pa, 1.0, &coolprop_fluid);
let (t_bubble_opt, subcooling) = if !t_bubble.is_nan() {
(
Some(entropyk_core::Temperature::from_kelvin(t_bubble)),
if t_k < t_bubble {
Some(crate::types::TemperatureDelta::new(t_bubble - t_k))
} else {
None
}
)
} else {
(None, None)
};
let (t_dew_opt, superheat) = if !t_dew.is_nan() {
(
Some(entropyk_core::Temperature::from_kelvin(t_dew)),
if t_k > t_dew {
Some(crate::types::TemperatureDelta::new(t_k - t_dew))
} else {
None
}
)
} else {
(None, None)
};
Ok(crate::types::ThermoState {
fluid,
pressure: p,
temperature: entropyk_core::Temperature::from_kelvin(t_k),
enthalpy: h,
entropy: crate::types::Entropy::from_joules_per_kg_kelvin(s_j_kg_k),
density: d_kg_m3,
phase,
quality,
superheat,
subcooling,
t_bubble: t_bubble_opt,
t_dew: t_dew_opt,
})
}
}
}
/// A placeholder backend when CoolProp is not available.
///
/// This allows the crate to compile without CoolProp, but property
/// queries will return errors.
#[cfg(not(feature = "coolprop"))]
pub struct CoolPropBackend;
#[cfg(not(feature = "coolprop"))]
impl CoolPropBackend {
/// Creates a new CoolPropBackend (placeholder).
pub fn new() -> Self {
CoolPropBackend
}
}
#[cfg(not(feature = "coolprop"))]
impl Default for CoolPropBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(feature = "coolprop"))]
impl crate::backend::FluidBackend for CoolPropBackend {
fn property(
&self,
_fluid: FluidId,
_property: Property,
_state: FluidState,
) -> FluidResult<f64> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
false
}
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn list_fluids(&self) -> Vec<FluidId> {
Vec::new()
}
fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::FluidBackend;
#[cfg(feature = "coolprop")]
use crate::mixture::Mixture;
#[cfg(feature = "coolprop")]
use entropyk_core::{Pressure, Temperature};
#[test]
#[cfg(feature = "coolprop")]
fn test_backend_creation() {
let backend = CoolPropBackend::new();
let fluids = backend.list_fluids();
assert!(!fluids.is_empty());
}
#[test]
#[cfg(not(feature = "coolprop"))]
fn test_backend_without_feature() {
use crate::types::FluidState;
use entropyk_core::{Pressure, Temperature};
let backend = CoolPropBackend::new();
let result = backend.property(
FluidId::new("R134a"),
Property::Density,
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)),
);
assert!(result.is_err());
}
#[test]
fn test_fluid_name_mapping() {
#[cfg(feature = "coolprop")]
{
let backend = CoolPropBackend::new();
assert_eq!(backend.fluid_name(&FluidId::new("R134a")), "R134a");
assert_eq!(backend.fluid_name(&FluidId::new("CO2")), "CO2");
assert_eq!(backend.fluid_name(&FluidId::new("R744")), "CO2");
}
}
#[test]
#[cfg(feature = "coolprop")]
fn test_mixture_is_supported() {
let backend = CoolPropBackend::new();
// R454B = R32 + R1234yf (both available in CoolProp)
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
assert!(backend.is_mixture_supported(&mixture));
// Unknown component should fail
let bad_mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R999", 0.5)]).unwrap();
assert!(!backend.is_mixture_supported(&bad_mixture));
}
#[test]
#[cfg(feature = "coolprop")]
fn test_bubble_point_r454b() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
// At 1 MPa (~10 bar), bubble point should be around 273K (0°C) for R454B
let pressure = Pressure::from_pascals(1e6);
let t_bubble = backend.bubble_point(pressure, &mixture).unwrap();
// Should be in reasonable range (250K - 300K)
assert!(t_bubble.to_kelvin() > 250.0 && t_bubble.to_kelvin() < 300.0);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_dew_point_r454b() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let pressure = Pressure::from_pascals(1e6);
let t_dew = backend.dew_point(pressure, &mixture).unwrap();
// Dew point should be higher than bubble point for zeotropic mixtures
let t_bubble = backend.bubble_point(pressure, &mixture).unwrap();
assert!(t_dew.to_kelvin() > t_bubble.to_kelvin());
}
#[test]
#[cfg(feature = "coolprop")]
fn test_temperature_glide_nonzero() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let pressure = Pressure::from_pascals(1e6);
let glide = backend.temperature_glide(pressure, &mixture).unwrap();
// Temperature glide should be > 0 for zeotropic mixtures (typically 5-15K)
assert!(
glide > 0.0,
"Expected positive temperature glide for zeotropic mixture"
);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_mixture_property_lookup() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
// Test (P, T) mixture state
let state = FluidState::from_pt_mix(
Pressure::from_bar(10.0),
Temperature::from_celsius(50.0),
mixture,
);
let density = backend
.property(FluidId::new("R454B"), Property::Density, state)
.unwrap();
assert!(density > 0.0);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_full_state_extraction() {
let backend = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
let pressure = Pressure::from_bar(1.0);
let enthalpy = entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0); // Superheated vapor region
let state = backend.full_state(fluid.clone(), pressure, enthalpy).unwrap();
assert_eq!(state.fluid, fluid);
assert_eq!(state.pressure, pressure);
assert_eq!(state.enthalpy, enthalpy);
// Temperature should be valid
assert!(state.temperature.to_celsius() > -30.0);
assert!(state.density > 0.0);
assert!(state.entropy.to_joules_per_kg_kelvin() > 0.0);
// In superheated region, phase is Vapor, quality should be None, and superheat should exist
assert_eq!(state.phase, Phase::Vapor);
assert_eq!(state.quality, None);
assert!(state.superheat.is_some());
assert!(state.superheat.unwrap().kelvin() > 0.0);
assert!(state.subcooling.is_none());
assert!(state.t_dew.is_some());
assert!(state.t_bubble.is_some());
}
}

View File

@@ -0,0 +1,341 @@
//! 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<B: FluidBackend> {
inner: B,
params: DampingParams,
}
impl<B: FluidBackend> DampedBackend<B> {
/// 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<CriticalPoint> {
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<f64> {
// 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<B: FluidBackend> FluidBackend for DampedBackend<B> {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let value = self
.inner
.property(fluid.clone(), property, state.clone())?;
self.apply_damping(&fluid, property, &state, value)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
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<Phase> {
self.inner.phase(fluid, state)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.inner.list_fluids()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
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<f64> {
if matches!(property, Property::Cp) {
Ok(f64::NAN)
} else {
Ok(1000.0)
}
}
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
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<Phase> {
Ok(Phase::Unknown)
}
fn list_fluids(&self) -> Vec<FluidId> {
vec![FluidId::new("CO2")]
}
fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
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"
);
}
}

View File

@@ -0,0 +1,452 @@
//! 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, Property, FluidState};
/// 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;
}
}
}

104
crates/fluids/src/errors.rs Normal file
View File

@@ -0,0 +1,104 @@
//! Error types for fluid properties calculations.
//!
//! This module defines the `FluidError` enum that represents all possible errors
//! that can occur when querying fluid properties.
use thiserror::Error;
/// Errors that can occur when working with fluid properties.
#[derive(Error, Debug, Clone)]
pub enum FluidError {
/// The requested fluid is not available in the backend.
#[error("Fluid `{fluid}` not found")]
UnknownFluid {
/// The fluid identifier that was requested
fluid: String,
},
/// The thermodynamic state is invalid for the requested property.
#[error("Invalid state for property calculation: {reason}")]
InvalidState {
/// The reason why the state is invalid
reason: String,
},
/// Error from CoolProp C++ library.
#[error("CoolProp error: {0}")]
CoolPropError(String),
/// Critical point data is not available for the given fluid.
#[error("Critical point not available for `{fluid}`")]
NoCriticalPoint {
/// The fluid identifier that was requested
fluid: String,
},
/// The requested property is not supported by this backend.
#[error("Property `{property}` not supported")]
UnsupportedProperty {
/// The property that is not supported
property: String,
},
/// Numerical error during calculation (overflow, NaN, etc).
#[error("Numerical error: {0}")]
NumericalError(String),
/// State is outside the tabular data bounds.
#[error("State ({p:.2} Pa, {t:.2} K) outside table bounds for fluid `{fluid}`")]
OutOfBounds {
/// Fluid identifier
fluid: String,
/// Pressure in Pa
p: f64,
/// Temperature in K
t: f64,
},
/// Table file could not be found or loaded.
#[error("Table file not found: {path}")]
TableNotFound {
/// Path that was attempted
path: String,
},
/// Mixture is not supported by the backend.
#[error("Mixture not supported: {0}")]
MixtureNotSupported(String),
}
/// Result type alias for fluid operations.
pub type FluidResult<T> = Result<T, FluidError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unknown_fluid_error() {
let err = FluidError::UnknownFluid {
fluid: "R999".to_string(),
};
assert_eq!(format!("{}", err), "Fluid `R999` not found");
}
#[test]
fn test_invalid_state_error() {
let err = FluidError::InvalidState {
reason: "Pressure below triple point".to_string(),
};
assert_eq!(
format!("{}", err),
"Invalid state for property calculation: Pressure below triple point"
);
}
#[test]
fn test_error_clone() {
let err1 = FluidError::UnknownFluid {
fluid: "R134a".to_string(),
};
let err2 = err1.clone();
assert_eq!(format!("{}", err1), format!("{}", err2));
}
}

View File

@@ -0,0 +1,578 @@
//! Incompressible fluid properties backend.
//!
//! Provides lightweight polynomial models for water, glycol, and humid air
//! without external library calls. Properties obtained from IAPWS-IF97
//! (water) and ASHRAE (glycol) reference data.
use crate::backend::FluidBackend;
use crate::errors::{FluidError, FluidResult};
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
/// Incompressible fluid identifier.
///
/// Maps FluidId strings to internal fluid types. Supports:
/// - Water
/// - EthyleneGlycol with concentration 0.00.6 mass fraction
/// - PropyleneGlycol with concentration 0.00.6 mass fraction
/// - HumidAir
#[derive(Debug, Clone, PartialEq)]
pub enum IncompFluid {
/// Pure water (liquid phase)
Water,
/// Ethylene glycol aqueous solution, concentration = mass fraction (0.00.6)
EthyleneGlycol(f64),
/// Propylene glycol aqueous solution, concentration = mass fraction (0.00.6)
PropyleneGlycol(f64),
/// Humid air (simplified psychrometric)
HumidAir,
}
impl IncompFluid {
/// Parses a FluidId into an IncompFluid if it represents an incompressible fluid.
///
/// Recognized formats:
/// - "Water"
/// - "EthyleneGlycol" or "EthyleneGlycol30" (30% = 0.3)
/// - "PropyleneGlycol" or "PropyleneGlycol50" (50% = 0.5)
/// - "HumidAir"
pub fn from_fluid_id(fluid_id: &FluidId) -> Option<Self> {
let s = fluid_id.0.as_str();
if s.eq_ignore_ascii_case("Water") {
return Some(IncompFluid::Water);
}
if s.eq_ignore_ascii_case("HumidAir") {
return Some(IncompFluid::HumidAir);
}
if s.to_lowercase().starts_with("ethyleneglycol") {
let conc = parse_glycol_concentration(s, "ethyleneglycol")?;
if (0.0..=0.6).contains(&conc) {
return Some(IncompFluid::EthyleneGlycol(conc));
}
}
if s.to_lowercase().starts_with("propyleneglycol") {
let conc = parse_glycol_concentration(s, "propyleneglycol")?;
if (0.0..=0.6).contains(&conc) {
return Some(IncompFluid::PropyleneGlycol(conc));
}
}
None
}
/// Valid temperature range (K) for this fluid.
pub fn valid_temp_range(&self) -> (f64, f64) {
match self {
IncompFluid::Water => (273.15, 373.15),
IncompFluid::EthyleneGlycol(_) | IncompFluid::PropyleneGlycol(_) => (243.15, 373.15),
IncompFluid::HumidAir => (233.15, 353.15),
}
}
}
fn parse_glycol_concentration(s: &str, prefix: &str) -> Option<f64> {
let rest = s.get(prefix.len()..)?.trim();
if rest.is_empty() {
return Some(0.0); // Pure water in glycol context = 0%
}
rest.parse::<f64>().ok().map(|x| x / 100.0)
}
/// Valid temperature range for incompressible fluids.
#[derive(Debug, Clone, Copy)]
pub struct ValidRange {
/// Minimum temperature (K)
pub min_temp_k: f64,
/// Maximum temperature (K)
pub max_temp_k: f64,
}
impl ValidRange {
/// Checks if temperature is within valid range.
pub fn contains(&self, t_k: f64) -> bool {
t_k >= self.min_temp_k && t_k <= self.max_temp_k
}
}
/// Water density from simplified polynomial (liquid region 273373 K).
///
/// Fitted to IAPWS-IF97 reference: 20°C→998.2, 50°C→988.0, 80°C→971.8 kg/m³ (within 0.1%).
/// ρ(kg/m³) = 1001.7 - 0.107*T°C - 0.00333*(T°C)²
fn water_density_kelvin(t_k: f64) -> f64 {
let t_c = t_k - 273.15;
1001.7 - 0.107 * t_c - 0.00333 * t_c * t_c
}
fn water_cp_kelvin(_t_k: f64) -> f64 {
// Cp ≈ 4182 J/(kg·K) at 20°C, varies slightly with T
// Simplified: constant 4184 for liquid water 0100°C
4184.0
}
fn water_viscosity_kelvin(t_k: f64) -> f64 {
let t_c = t_k - 273.15;
// μ(Pa·s) for liquid water: 20°C→0.001, 40°C→0.00065
// Rational form: μ = 0.001 / (1 + 0.02*(T-20)) for T in °C
0.001 / (1.0 + 0.02 * (t_c - 20.0).max(0.0))
}
/// Incompressible fluid properties backend.
///
/// Implements FluidBackend for water, ethylene glycol, propylene glycol,
/// and humid air using lightweight polynomial models. No external library calls.
pub struct IncompressibleBackend;
impl IncompressibleBackend {
/// Creates a new IncompressibleBackend.
pub fn new() -> Self {
IncompressibleBackend
}
fn property_water(&self, property: Property, t_k: f64) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::Water.valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"Water temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
match property {
Property::Density => Ok(water_density_kelvin(t_k)),
Property::Cp => Ok(water_cp_kelvin(t_k)),
Property::Viscosity => Ok(water_viscosity_kelvin(t_k)),
Property::Enthalpy => {
// h ≈ Cp * (T - 273.15) relative to 0°C liquid
Ok(water_cp_kelvin(t_k) * (t_k - 273.15))
}
Property::Temperature => Ok(t_k),
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for Water", property),
}),
}
}
fn property_glycol(
&self,
property: Property,
t_k: f64,
concentration: f64,
is_ethylene: bool,
) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::EthyleneGlycol(0.0).valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"Glycol temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
if concentration < 0.0 || concentration > 0.6 {
return Err(FluidError::InvalidState {
reason: format!(
"Glycol concentration {} outside valid range [0, 0.6]",
concentration
),
});
}
// ASHRAE simplified: density increases with concentration, decreases with T
let rho_water = water_density_kelvin(t_k);
let t_c = t_k - 273.15;
match (property, is_ethylene) {
(Property::Density, true) => {
// EG: ρρ_water*(1 - 0.4*X) + 1115*X for X=concentration (approx)
Ok(rho_water * (1.0 - concentration) + 1115.0 * concentration)
}
(Property::Density, false) => {
Ok(rho_water * (1.0 - concentration) + 1036.0 * concentration)
}
(Property::Cp, true) => {
// EG 30%: ~3900, EG 50%: ~3400 J/(kg·K) at 20°C
Ok(4184.0 * (1.0 - concentration) + 2400.0 * concentration)
}
(Property::Cp, false) => {
Ok(4184.0 * (1.0 - concentration) + 2500.0 * concentration)
}
(Property::Viscosity, _) => {
// Viscosity increases strongly with concentration and decreases with T
let mu_water = water_viscosity_kelvin(t_k);
let conc_factor = 1.0 + 10.0 * concentration;
let temp_factor = (-0.02 * (t_c - 20.0)).exp();
Ok(mu_water * conc_factor * temp_factor)
}
(Property::Enthalpy, _) => {
let cp = if is_ethylene {
4184.0 * (1.0 - concentration) + 2400.0 * concentration
} else {
4184.0 * (1.0 - concentration) + 2500.0 * concentration
};
Ok(cp * (t_k - 273.15))
}
(Property::Temperature, _) => Ok(t_k),
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for glycol", property),
}),
}
}
fn property_humid_air(&self, property: Property, t_k: f64) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::HumidAir.valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"HumidAir temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
match property {
Property::Cp => Ok(1005.0), // Dry air Cp
Property::Temperature => Ok(t_k),
Property::Density => Ok(1.2), // Approximate at 20°C, 1 atm
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for HumidAir", property),
}),
}
}
}
impl Default for IncompressibleBackend {
fn default() -> Self {
Self::new()
}
}
impl FluidBackend for IncompressibleBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let (t_k, _p) = match &state {
FluidState::PressureTemperature(p, t) => (t.to_kelvin(), p.to_pascals()),
_ => {
return Err(FluidError::InvalidState {
reason: "IncompressibleBackend requires PressureTemperature state".to_string(),
})
}
};
if let Some(incomp) = IncompFluid::from_fluid_id(&fluid) {
match incomp {
IncompFluid::Water => self.property_water(property, t_k),
IncompFluid::EthyleneGlycol(conc) => {
self.property_glycol(property, t_k, conc, true)
}
IncompFluid::PropyleneGlycol(conc) => {
self.property_glycol(property, t_k, conc, false)
}
IncompFluid::HumidAir => self.property_humid_air(property, t_k),
}
} else {
Err(FluidError::UnknownFluid { fluid: fluid.0 })
}
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
if IncompFluid::from_fluid_id(&fluid).is_none() {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
IncompFluid::from_fluid_id(fluid).is_some()
}
fn phase(&self, fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
match IncompFluid::from_fluid_id(&fluid) {
Some(IncompFluid::HumidAir) => Ok(Phase::Vapor),
Some(_) => Ok(Phase::Liquid),
None => Err(FluidError::UnknownFluid { fluid: fluid.0 }),
}
}
fn list_fluids(&self) -> Vec<FluidId> {
vec![
FluidId::new("Water"),
FluidId::new("EthyleneGlycol"),
FluidId::new("EthyleneGlycol30"),
FluidId::new("EthyleneGlycol50"),
FluidId::new("PropyleneGlycol"),
FluidId::new("PropyleneGlycol30"),
FluidId::new("PropyleneGlycol50"),
FluidId::new("HumidAir"),
]
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for IncompressibleBackend: Temperature is {:.2} K but full state not natively implemented yet", t_k),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_incomp_fluid_from_fluid_id() {
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("Water")),
Some(IncompFluid::Water)
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("water")),
Some(IncompFluid::Water)
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("EthyleneGlycol30")),
Some(IncompFluid::EthyleneGlycol(c)) if (c - 0.3).abs() < 0.01
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("PropyleneGlycol50")),
Some(IncompFluid::PropyleneGlycol(c)) if (c - 0.5).abs() < 0.01
));
assert!(IncompFluid::from_fluid_id(&FluidId::new("R134a")).is_none());
}
#[test]
fn test_water_density_at_temperatures() {
let backend = IncompressibleBackend::new();
let state_20 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let state_50 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(50.0),
);
let state_80 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(80.0),
);
let rho_20 = backend
.property(FluidId::new("Water"), Property::Density, state_20)
.unwrap();
let rho_50 = backend
.property(FluidId::new("Water"), Property::Density, state_50)
.unwrap();
let rho_80 = backend
.property(FluidId::new("Water"), Property::Density, state_80)
.unwrap();
// IAPWS-IF97 reference: 20°C→998.2, 50°C→988.0, 80°C→971.8 kg/m³ (AC #2: within 0.1%)
assert!((rho_20 - 998.2).abs() / 998.2 < 0.001, "rho_20={}", rho_20);
assert!((rho_50 - 988.0).abs() / 988.0 < 0.001, "rho_50={}", rho_50);
assert!((rho_80 - 971.8).abs() / 971.8 < 0.001, "rho_80={}", rho_80);
}
#[test]
fn test_water_cp_accuracy() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let cp = backend
.property(FluidId::new("Water"), Property::Cp, state)
.unwrap();
// IAPWS: Cp ≈ 4182 J/(kg·K) at 20°C (AC #2: within 0.1%)
assert!((cp - 4182.0).abs() / 4182.0 < 0.001, "Cp={}", cp);
}
#[test]
fn test_water_out_of_range() {
let backend = IncompressibleBackend::new();
let state_cold = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(-10.0),
);
let state_hot = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(150.0),
);
assert!(backend
.property(FluidId::new("Water"), Property::Density, state_cold)
.is_err());
assert!(backend
.property(FluidId::new("Water"), Property::Density, state_hot)
.is_err());
}
#[test]
fn test_critical_point_returns_error() {
let backend = IncompressibleBackend::new();
let result = backend.critical_point(FluidId::new("Water"));
assert!(matches!(result, Err(FluidError::NoCriticalPoint { .. })));
}
#[test]
fn test_critical_point_unknown_fluid() {
let backend = IncompressibleBackend::new();
let result = backend.critical_point(FluidId::new("R134a"));
assert!(matches!(result, Err(FluidError::UnknownFluid { .. })));
}
#[test]
fn test_water_enthalpy_reference() {
let backend = IncompressibleBackend::new();
let state_0 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(0.0),
);
let state_20 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let h_0 = backend
.property(FluidId::new("Water"), Property::Enthalpy, state_0)
.unwrap();
let h_20 = backend
.property(FluidId::new("Water"), Property::Enthalpy, state_20)
.unwrap();
// h = Cp * (T - 273.15) relative to 0°C: h_0 ≈ 0, h_20 ≈ 4184 * 20 = 83680 J/kg
assert!(h_0.abs() < 1.0, "h at 0°C should be ~0");
assert!((h_20 - 83680.0).abs() / 83680.0 < 0.01, "h at 20°C={}", h_20);
}
#[test]
fn test_glycol_concentration_effect() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let rho_water = backend
.property(FluidId::new("Water"), Property::Density, state.clone())
.unwrap();
let rho_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state.clone())
.unwrap();
let rho_eg50 = backend
.property(FluidId::new("EthyleneGlycol50"), Property::Density, state.clone())
.unwrap();
let cp_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Cp, state.clone())
.unwrap();
let cp_eg50 = backend
.property(FluidId::new("EthyleneGlycol50"), Property::Cp, state.clone())
.unwrap();
// Higher concentration → higher density, lower Cp (ASHRAE)
assert!(rho_eg30 > rho_water && rho_eg50 > rho_eg30);
assert!(cp_eg50 < cp_eg30 && cp_eg30 < 4184.0);
}
#[test]
fn test_glycol_out_of_range() {
let backend = IncompressibleBackend::new();
let state_cold = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(-40.0),
);
let state_hot = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(150.0),
);
assert!(backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state_cold)
.is_err());
assert!(backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state_hot)
.is_err());
}
#[test]
fn test_humid_air_psychrometrics() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let cp = backend
.property(FluidId::new("HumidAir"), Property::Cp, state.clone())
.unwrap();
let rho = backend
.property(FluidId::new("HumidAir"), Property::Density, state)
.unwrap();
// Dry air Cp ≈ 1005 J/(kg·K), ρ ≈ 1.2 kg/m³ at 20°C, 1 atm
assert!((cp - 1005.0).abs() < 1.0, "Cp={}", cp);
assert!((rho - 1.2).abs() < 0.2, "ρ={}", rho);
}
#[test]
fn test_phase_humid_air_is_vapor() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let phase = backend.phase(FluidId::new("HumidAir"), state).unwrap();
assert_eq!(phase, Phase::Vapor);
}
#[test]
fn test_nan_temperature_rejected() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_kelvin(f64::NAN),
);
assert!(backend
.property(FluidId::new("Water"), Property::Density, state)
.is_err());
}
#[test]
fn test_glycol_properties() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let rho_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state.clone())
.unwrap();
let rho_water = backend
.property(FluidId::new("Water"), Property::Density, state.clone())
.unwrap();
// EG 30% should be denser than water
assert!(rho_eg30 > rho_water, "EG30 ρ={} should be > water ρ={}", rho_eg30, rho_water);
}
#[test]
fn test_cached_backend_wrapper() {
use crate::cached_backend::CachedBackend;
let inner = IncompressibleBackend::new();
let backend = CachedBackend::new(inner);
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
);
let rho = backend
.property(FluidId::new("Water"), Property::Density, state)
.unwrap();
assert!((rho - 997.0).abs() < 5.0);
}
}

69
crates/fluids/src/lib.rs Normal file
View File

@@ -0,0 +1,69 @@
//! # Entropyk Fluids
//!
//! Fluid properties backend for the Entropyk thermodynamic simulation library.
//!
//! This crate provides the abstraction layer for thermodynamic property calculations,
//! allowing the solver to work with different backends (CoolProp, tabular interpolation,
//! test mocks) through a unified trait-based interface.
//!
//! ## Key Components
//!
//! - [`FluidBackend`] - The core trait that all backends implement
//! - [`TestBackend`] - A mock backend for unit testing
//! - [`CoolPropBackend`] - A backend using the CoolProp C++ library
//! - [`FluidError`] - Error types for fluid operations
//! - [`types`] - Core types like `FluidId`, `Property`, `FluidState`, `CriticalPoint`
//! - [`mixture`] - Mixture types for multi-component refrigerants
//!
//! ## Example
//!
//! ```rust
//! use entropyk_fluids::{FluidBackend, FluidId, Property, FluidState, TestBackend};
//! use entropyk_core::{Pressure, Temperature};
//!
//! // Create a test backend for unit testing
//! let backend = TestBackend::new();
//!
//! // Query properties
//! let state = FluidState::from_pt(
//! Pressure::from_bar(1.0),
//! Temperature::from_celsius(25.0),
//! );
//!
//! let density = backend.property(
//! FluidId::new("R134a"),
//! Property::Density,
//! state,
//! ).unwrap();
//!
//! // In production use tracing::info! for observability (never println!)
//! ```
#![deny(warnings)]
#![warn(missing_docs)]
pub mod backend;
pub mod cache;
pub mod cached_backend;
pub mod coolprop;
pub mod damped_backend;
pub mod damping;
pub mod errors;
pub mod incompressible;
pub mod mixture;
pub mod tabular;
pub mod tabular_backend;
pub mod test_backend;
pub mod types;
pub use backend::FluidBackend;
pub use cached_backend::CachedBackend;
pub use coolprop::CoolPropBackend;
pub use damped_backend::DampedBackend;
pub use damping::{DampingParams, DampingState};
pub use errors::{FluidError, FluidResult};
pub use mixture::{Mixture, MixtureError};
pub use tabular_backend::TabularBackend;
pub use test_backend::TestBackend;
pub use incompressible::{IncompFluid, IncompressibleBackend, ValidRange};
pub use types::{CriticalPoint, Entropy, FluidId, Phase, Property, Quality, FluidState, ThermoState};

View File

@@ -0,0 +1,357 @@
//! Mixture types and utilities for multi-component refrigerants.
//!
//! This module provides types for representing refrigerant mixtures
//! (e.g., R454B = R32/R1234yf) and their thermodynamic properties.
use std::fmt;
use std::hash::{Hash, Hasher};
/// A refrigerant mixture composed of multiple components.
///
/// # Example
///
/// ```
/// use entropyk_fluids::mixture::Mixture;
///
/// let r454b = Mixture::from_mass_fractions(&[
/// ("R32", 0.5),
/// ("R1234yf", 0.5),
/// ]).unwrap();
///
/// let r410a = Mixture::from_mole_fractions(&[
/// ("R32", 0.5),
/// ("R125", 0.5),
/// ]).unwrap();
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct Mixture {
/// Components in the mixture (names as used by CoolProp)
components: Vec<String>,
/// Fractions (either mass or mole basis, depending on constructor)
fractions: Vec<f64>,
/// Whether fractions are mole-based (true) or mass-based (false)
mole_fractions: bool,
}
impl Mixture {
/// Create a mixture from mass fractions.
///
/// # Arguments
/// * `fractions` - Pairs of (fluid name, mass fraction)
///
/// # Errors
/// Returns an error if fractions don't sum to 1.0 or are invalid
pub fn from_mass_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
Self::validate_fractions(fractions)?;
Ok(Mixture {
components: fractions
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
mole_fractions: false,
})
}
/// Create a mixture from mole fractions.
///
/// # Arguments
/// * `fractions` - Pairs of (fluid name, mole fraction)
///
/// # Errors
/// Returns an error if fractions don't sum to 1.0 or are invalid
pub fn from_mole_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
Self::validate_fractions(fractions)?;
Ok(Mixture {
components: fractions
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
mole_fractions: true,
})
}
/// Validate that fractions are valid (sum to 1.0, all non-negative)
fn validate_fractions(fractions: &[(&str, f64)]) -> Result<(), MixtureError> {
if fractions.is_empty() {
return Err(MixtureError::InvalidComposition(
"Mixture must have at least one component".to_string(),
));
}
let sum: f64 = fractions.iter().map(|(_, frac)| frac).sum();
if (sum - 1.0).abs() > 1e-6 {
return Err(MixtureError::InvalidComposition(format!(
"Fractions must sum to 1.0, got {}",
sum
)));
}
for (_, frac) in fractions {
if *frac < 0.0 || *frac > 1.0 {
return Err(MixtureError::InvalidComposition(format!(
"Fraction must be between 0 and 1, got {}",
frac
)));
}
}
Ok(())
}
/// Get the components in this mixture.
pub fn components(&self) -> &[String] {
&self.components
}
/// Get the fractions (mass or mole basis depending on constructor).
pub fn fractions(&self) -> &[f64] {
&self.fractions
}
/// Check if fractions are mole-based.
pub fn is_mole_fractions(&self) -> bool {
self.mole_fractions
}
/// Check if fractions are mass-based.
pub fn is_mass_fractions(&self) -> bool {
!self.mole_fractions
}
/// Convert to CoolProp mixture string format.
///
/// CoolProp format: "R32[0.5]&R125[0.5]" (mole fractions)
pub fn to_coolprop_string(&self) -> String {
self.components
.iter()
.zip(self.fractions.iter())
.map(|(name, frac)| format!("{}[{}]", name, frac))
.collect::<Vec<_>>()
.join("&")
}
/// Get the number of components in this mixture.
pub fn len(&self) -> usize {
self.components.len()
}
/// Check if this mixture has no components.
pub fn is_empty(&self) -> bool {
self.components.is_empty()
}
/// Convert mass fractions to mole fractions.
///
/// Requires molar masses for each component.
/// Uses simplified molar masses for common refrigerants.
pub fn to_mole_fractions(&self) -> Result<Vec<f64>, MixtureError> {
if self.mole_fractions {
return Ok(self.fractions.to_vec());
}
let total: f64 = self
.components
.iter()
.zip(self.fractions.iter())
.map(|(c, frac)| frac / Self::molar_mass(c))
.sum();
Ok(self
.components
.iter()
.zip(self.fractions.iter())
.map(|(c, frac)| (frac / Self::molar_mass(c)) / total)
.collect())
}
/// Get molar mass (g/mol) for common refrigerants.
fn molar_mass(fluid: &str) -> f64 {
match fluid.to_uppercase().as_str() {
"R32" => 52.02,
"R125" => 120.02,
"R134A" => 102.03,
"R1234YF" => 114.04,
"R1234ZE" => 114.04,
"R410A" => 72.58,
"R404A" => 97.60,
"R407C" => 86.20,
"R290" | "PROPANE" => 44.10,
"R600" | "BUTANE" => 58.12,
"R600A" | "ISOBUTANE" => 58.12,
"CO2" | "R744" => 44.01,
"WATER" | "H2O" => 18.02,
"AIR" => 28.97,
"NITROGEN" | "N2" => 28.01,
"OXYGEN" | "O2" => 32.00,
_ => 50.0, // Default fallback
}
}
}
impl Hash for Mixture {
fn hash<H: Hasher>(&self, state: &mut H) {
// Use CoolProp string as stable hash representation
self.to_coolprop_string().hash(state);
}
}
impl Eq for Mixture {}
impl fmt::Display for Mixture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let fraction_type = if self.mole_fractions { "mole" } else { "mass" };
write!(f, "Mixture ({} fractions): ", fraction_type)?;
for (i, (comp, frac)) in self
.components
.iter()
.zip(self.fractions.iter())
.enumerate()
{
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}={:.2}", comp, frac)?;
}
Ok(())
}
}
/// Errors that can occur when working with mixtures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MixtureError {
/// Invalid mixture composition
InvalidComposition(String),
/// Mixture not supported by backend
MixtureNotSupported(String),
/// Invalid fraction type
InvalidFractionType(String),
}
impl fmt::Display for MixtureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MixtureError::InvalidComposition(msg) => {
write!(f, "Invalid mixture composition: {}", msg)
}
MixtureError::MixtureNotSupported(msg) => write!(f, "Mixture not supported: {}", msg),
MixtureError::InvalidFractionType(msg) => write!(f, "Invalid fraction type: {}", msg),
}
}
}
impl std::error::Error for MixtureError {}
/// Pre-defined common refrigerant mixtures.
pub mod predefined {
use super::*;
/// R454B: R32 (50%) / R1234yf (50%) - mass fractions
pub fn r454b() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap()
}
/// R410A: R32 (50%) / R125 (50%) - mass fractions
pub fn r410a() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
}
/// R407C: R32 (23%) / R125 (25%) / R134a (52%) - mass fractions
pub fn r407c() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.23), ("R125", 0.25), ("R134a", 0.52)]).unwrap()
}
/// R404A: R125 (44%) / R143a (52%) / R134a (4%) - mass fractions
pub fn r404a() -> Mixture {
Mixture::from_mass_fractions(&[("R125", 0.44), ("R143a", 0.52), ("R134a", 0.04)]).unwrap()
}
/// R32/R125 (50/50) mixture - mass fractions
pub fn r32_r125_5050() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mixture_creation_mass() {
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
assert_eq!(mixture.components().len(), 2);
assert!(mixture.is_mass_fractions());
}
#[test]
fn test_mixture_creation_mole() {
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
assert_eq!(mixture.components().len(), 2);
assert!(mixture.is_mole_fractions());
}
#[test]
fn test_coolprop_string() {
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let cp_string = mixture.to_coolprop_string();
assert!(cp_string.contains("R32[0.5]"));
assert!(cp_string.contains("R1234yf[0.5]"));
}
#[test]
fn test_predefined_r454b() {
let mixture = predefined::r454b();
assert_eq!(mixture.components().len(), 2);
}
#[test]
fn test_invalid_fractions_sum() {
let result = Mixture::from_mass_fractions(&[("R32", 0.3), ("R125", 0.5)]);
assert!(result.is_err());
}
#[test]
fn test_invalid_fraction_negative() {
let result = Mixture::from_mass_fractions(&[("R32", -0.5), ("R125", 1.5)]);
assert!(result.is_err());
}
#[test]
fn test_mixture_hash() {
let m1 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let m2 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
use std::collections::hash_map::DefaultHasher;
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
m1.hash(&mut h1);
m2.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_mass_to_mole_conversion() {
// R454B: 50% mass R32, 50% mass R1234yf
// Molar masses: R32=52.02, R1234yf=114.04
// Mole fraction R32 = (0.5/52.02) / (0.5/52.02 + 0.5/114.04) ≈ 0.687
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let mole_fracs = mixture.to_mole_fractions().unwrap();
// Verify sum = 1.0
let sum: f64 = mole_fracs.iter().sum();
assert!((sum - 1.0).abs() < 1e-6);
// R32 should be ~69% mole fraction (higher due to lower molar mass)
assert!(mole_fracs[0] > 0.6 && mole_fracs[0] < 0.8);
}
#[test]
fn test_mole_fractions_passthrough() {
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
let mole_fracs = mixture.to_mole_fractions().unwrap();
assert!((mole_fracs[0] - 0.5).abs() < 1e-6);
assert!((mole_fracs[1] - 0.5).abs() < 1e-6);
}
}

View File

@@ -0,0 +1,273 @@
//! Table generation from CoolProp or reference data.
//!
//! When the `coolprop` feature is enabled, generates tables by querying CoolProp.
//! Otherwise, provides template/reference tables for testing.
use crate::errors::FluidResult;
use std::path::Path;
/// Generate a fluid table and save to JSON.
///
/// When `coolprop` feature is enabled, uses CoolProp to compute property values.
/// Otherwise, loads from embedded reference data (R134a only).
pub fn generate_table(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
#[cfg(feature = "coolprop")]
{
generate_from_coolprop(fluid_name, output_path)
}
#[cfg(not(feature = "coolprop"))]
{
generate_from_reference(fluid_name, output_path)
}
}
/// Map user-facing fluid name to CoolProp internal name.
#[cfg(feature = "coolprop")]
fn fluid_name_to_coolprop(name: &str) -> String {
match name.to_lowercase().as_str() {
"r134a" => "R134a".to_string(),
"r410a" => "R410A".to_string(),
"r404a" => "R404A".to_string(),
"r407c" => "R407C".to_string(),
"r32" => "R32".to_string(),
"r125" => "R125".to_string(),
"co2" | "r744" => "CO2".to_string(),
"r290" => "R290".to_string(),
"r600" => "R600".to_string(),
"r600a" => "R600A".to_string(),
"water" => "Water".to_string(),
"air" => "Air".to_string(),
n => n.to_string(),
}
}
#[cfg(feature = "coolprop")]
fn generate_from_coolprop(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
use entropyk_coolprop_sys as coolprop;
use serde::Serialize;
let cp_fluid = fluid_name_to_coolprop(fluid_name);
if !unsafe { coolprop::is_fluid_available(&cp_fluid) } {
return Err(crate::errors::FluidError::UnknownFluid {
fluid: fluid_name.to_string(),
});
}
// Critical point
let tc = unsafe { coolprop::critical_temperature(&cp_fluid) };
let pc = unsafe { coolprop::critical_pressure(&cp_fluid) };
let rho_c = unsafe { coolprop::critical_density(&cp_fluid) };
if tc.is_nan() || pc.is_nan() || rho_c.is_nan() {
return Err(crate::errors::FluidError::NoCriticalPoint {
fluid: fluid_name.to_string(),
});
}
// Single-phase grid: P (Pa), T (K) - similar to r134a.json
let pressures: Vec<f64> = vec![
100_000.0,
200_000.0,
500_000.0,
1_000_000.0,
2_000_000.0,
3_000_000.0,
];
let temperatures: Vec<f64> = vec![250.0, 270.0, 290.0, 298.15, 320.0, 350.0];
let mut density = Vec::with_capacity(pressures.len() * temperatures.len());
let mut enthalpy = Vec::with_capacity(pressures.len() * temperatures.len());
let mut entropy = Vec::with_capacity(pressures.len() * temperatures.len());
let mut cp = Vec::with_capacity(pressures.len() * temperatures.len());
let mut cv = Vec::with_capacity(pressures.len() * temperatures.len());
for &p in &pressures {
for &t in &temperatures {
let d = unsafe { coolprop::props_si_pt("D", p, t, &cp_fluid) };
let h = unsafe { coolprop::props_si_pt("H", p, t, &cp_fluid) };
let s = unsafe { coolprop::props_si_pt("S", p, t, &cp_fluid) };
let cp_val = unsafe { coolprop::props_si_pt("C", p, t, &cp_fluid) };
let cv_val = unsafe { coolprop::props_si_pt("O", p, t, &cp_fluid) };
if d.is_nan() || h.is_nan() {
return Err(crate::errors::FluidError::InvalidState {
reason: format!("CoolProp NaN at P={} Pa, T={} K", p, t),
});
}
density.push(d);
enthalpy.push(h);
entropy.push(s);
cp.push(cp_val);
cv.push(cv_val);
}
}
// Saturation table: T from triple to critical
let t_min = 250.0;
let t_max = (tc - 1.0).min(350.0);
let n_sat = 12;
let temp_points: Vec<f64> = (0..n_sat)
.map(|i| t_min + (t_max - t_min) * (i as f64) / ((n_sat - 1) as f64))
.collect();
let mut sat_temps = Vec::with_capacity(n_sat);
let mut sat_pressure = Vec::with_capacity(n_sat);
let mut h_liq = Vec::with_capacity(n_sat);
let mut h_vap = Vec::with_capacity(n_sat);
let mut rho_liq = Vec::with_capacity(n_sat);
let mut rho_vap = Vec::with_capacity(n_sat);
let mut s_liq = Vec::with_capacity(n_sat);
let mut s_vap = Vec::with_capacity(n_sat);
for &t in &temp_points {
let p_sat = unsafe { coolprop::props_si_tq("P", t, 0.0, &cp_fluid) };
if p_sat.is_nan() || p_sat <= 0.0 {
continue;
}
sat_temps.push(t);
sat_pressure.push(p_sat);
h_liq.push(unsafe { coolprop::props_si_tq("H", t, 0.0, &cp_fluid) });
h_vap.push(unsafe { coolprop::props_si_tq("H", t, 1.0, &cp_fluid) });
rho_liq.push(unsafe { coolprop::props_si_tq("D", t, 0.0, &cp_fluid) });
rho_vap.push(unsafe { coolprop::props_si_tq("D", t, 1.0, &cp_fluid) });
s_liq.push(unsafe { coolprop::props_si_tq("S", t, 0.0, &cp_fluid) });
s_vap.push(unsafe { coolprop::props_si_tq("S", t, 1.0, &cp_fluid) });
}
#[derive(Serialize)]
struct JsonTable {
fluid: String,
critical_point: JsonCriticalPoint,
single_phase: JsonSinglePhase,
saturation: JsonSaturation,
}
#[derive(Serialize)]
struct JsonCriticalPoint {
tc: f64,
pc: f64,
rho_c: f64,
}
#[derive(Serialize)]
struct JsonSinglePhase {
pressure: Vec<f64>,
temperature: Vec<f64>,
density: Vec<f64>,
enthalpy: Vec<f64>,
entropy: Vec<f64>,
cp: Vec<f64>,
cv: Vec<f64>,
}
#[derive(Serialize)]
struct JsonSaturation {
temperature: Vec<f64>,
pressure: Vec<f64>,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
rho_liq: Vec<f64>,
rho_vap: Vec<f64>,
s_liq: Vec<f64>,
s_vap: Vec<f64>,
}
let json = JsonTable {
fluid: fluid_name.to_string(),
critical_point: JsonCriticalPoint { tc, pc, rho_c },
single_phase: JsonSinglePhase {
pressure: pressures,
temperature: temperatures,
density,
enthalpy,
entropy,
cp,
cv,
},
saturation: JsonSaturation {
temperature: sat_temps,
pressure: sat_pressure,
h_liq,
h_vap,
rho_liq,
rho_vap,
s_liq,
s_vap,
},
};
let contents = serde_json::to_string_pretty(&json).map_err(|e| {
crate::errors::FluidError::InvalidState {
reason: format!("JSON serialization failed: {}", e),
}
})?;
std::fs::write(output_path, contents).map_err(|e| {
crate::errors::FluidError::TableNotFound {
path: format!("{}: {}", output_path.display(), e),
}
})?;
Ok(())
}
#[cfg(not(feature = "coolprop"))]
fn generate_from_reference(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
if fluid_name == "R134a" {
let json = include_str!("../../data/r134a.json");
std::fs::write(output_path, json).map_err(|e| {
crate::errors::FluidError::TableNotFound {
path: format!("{}: {}", output_path.display(), e),
}
})?;
Ok(())
} else {
Err(crate::errors::FluidError::UnknownFluid {
fluid: fluid_name.to_string(),
})
}
}
#[cfg(all(test, feature = "coolprop"))]
mod tests {
use super::*;
use crate::backend::FluidBackend;
use crate::coolprop::CoolPropBackend;
use crate::tabular_backend::TabularBackend;
use crate::types::{FluidId, Property, ThermoState};
use approx::assert_relative_eq;
use entropyk_core::{Pressure, Temperature};
/// Validate generated tables against CoolProp spot checks (AC #2).
#[test]
fn test_generated_table_vs_coolprop_spot_checks() {
let temp = std::env::temp_dir().join("entropyk_r134a_test.json");
generate_table("R134a", &temp).expect("generate_table must succeed");
let mut tabular = TabularBackend::new();
tabular.load_table(&temp).unwrap();
let _ = std::fs::remove_file(&temp);
let coolprop = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
// Spot check: grid point (200 kPa, 290 K)
let state = ThermoState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(290.0),
);
let rho_t = tabular
.property(fluid.clone(), Property::Density, state)
.unwrap();
let rho_c = coolprop
.property(fluid.clone(), Property::Density, state)
.unwrap();
assert_relative_eq!(rho_t, rho_c, epsilon = 0.0001 * rho_c.max(1.0));
// Spot check: interpolated point (1 bar, 25°C)
let state2 = ThermoState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let h_t = tabular
.property(fluid.clone(), Property::Enthalpy, state2)
.unwrap();
let h_c = coolprop
.property(fluid.clone(), Property::Enthalpy, state2)
.unwrap();
assert_relative_eq!(h_t, h_c, epsilon = 0.0001 * h_c.max(1.0));
}
}

View File

@@ -0,0 +1,152 @@
//! Bilinear interpolation for 2D property tables.
//!
//! Provides C1-continuous interpolation suitable for solver Jacobian assembly.
use std::cmp::Ordering;
/// Performs bilinear interpolation on a 2D grid.
///
/// Given a rectangular grid with values at (p_idx, t_idx), interpolates
/// the value at (p, t) where p and t are in the grid's coordinate space.
/// Returns None if (p, t) is outside the grid bounds.
///
/// # Arguments
/// * `p_grid` - Pressure grid (must be sorted ascending)
/// * `t_grid` - Temperature grid (must be sorted ascending)
/// * `values` - 2D array [p_idx][t_idx], row-major
/// * `p` - Query pressure (Pa)
/// * `t` - Query temperature (K)
#[inline]
pub fn bilinear_interpolate(
p_grid: &[f64],
t_grid: &[f64],
values: &[f64],
p: f64,
t: f64,
) -> Option<f64> {
let n_p = p_grid.len();
let n_t = t_grid.len();
if n_p < 2 || n_t < 2 || values.len() != n_p * n_t {
return None;
}
// Reject NaN to avoid panic in binary_search_by (Zero-Panic Policy)
if !p.is_finite() || !t.is_finite() {
return None;
}
// Find P indices (p_grid must be ascending)
let p_idx = match p_grid.binary_search_by(|x| x.partial_cmp(&p).unwrap_or(Ordering::Equal)) {
Ok(i) => {
if i >= n_p - 1 {
return None;
}
i
}
Err(i) => {
if i == 0 || i >= n_p {
return None;
}
i - 1
}
};
// Find T indices
let t_idx = match t_grid.binary_search_by(|x| x.partial_cmp(&t).unwrap_or(Ordering::Equal)) {
Ok(i) => {
if i >= n_t - 1 {
return None;
}
i
}
Err(i) => {
if i == 0 || i >= n_t {
return None;
}
i - 1
}
};
let p0 = p_grid[p_idx];
let p1 = p_grid[p_idx + 1];
let t0 = t_grid[t_idx];
let t1 = t_grid[t_idx + 1];
let dp = p1 - p0;
let dt = t1 - t0;
if dp <= 0.0 || dt <= 0.0 {
return None;
}
let fp = (p - p0) / dp;
let ft = (t - t0) / dt;
// Clamp to [0,1] for edge cases
let fp = fp.clamp(0.0, 1.0);
let ft = ft.clamp(0.0, 1.0);
let v00 = values[p_idx * n_t + t_idx];
let v01 = values[p_idx * n_t + t_idx + 1];
let v10 = values[(p_idx + 1) * n_t + t_idx];
let v11 = values[(p_idx + 1) * n_t + t_idx + 1];
let v0 = v00 * (1.0 - ft) + v01 * ft;
let v1 = v10 * (1.0 - ft) + v11 * ft;
Some(v0 * (1.0 - fp) + v1 * fp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bilinear_inside() {
let p = [100000.0, 200000.0, 300000.0];
let t = [250.0, 300.0, 350.0];
let v = [
1.0, 2.0, 3.0, // p=100k
4.0, 5.0, 6.0, // p=200k
7.0, 8.0, 9.0, // p=300k
];
let result = bilinear_interpolate(&p, &t, &v, 200000.0, 300.0);
assert!(result.is_some());
assert!((result.unwrap() - 5.0).abs() < 1e-10);
}
#[test]
fn test_bilinear_interpolated() {
let p = [0.0, 1.0];
let t = [0.0, 1.0];
let v = [0.0, 1.0, 1.0, 2.0]; // v(0,0)=0, v(0,1)=1, v(1,0)=1, v(1,1)=2
let result = bilinear_interpolate(&p, &t, &v, 0.5, 0.5);
assert!(result.is_some());
// At center: (0+1+1+2)/4 = 1.0
assert!((result.unwrap() - 1.0).abs() < 1e-10);
}
#[test]
fn test_bilinear_out_of_bounds() {
let p = [100000.0, 200000.0];
let t = [250.0, 300.0];
let v = [1.0, 2.0, 3.0, 4.0];
assert!(bilinear_interpolate(&p, &t, &v, 50000.0, 300.0).is_none());
assert!(bilinear_interpolate(&p, &t, &v, 300000.0, 300.0).is_none());
}
#[test]
fn test_bilinear_nan_rejected() {
let p = [100000.0, 200000.0];
let t = [250.0, 300.0];
let v = [1.0, 2.0, 3.0, 4.0];
assert!(bilinear_interpolate(&p, &t, &v, f64::NAN, 300.0).is_none());
assert!(bilinear_interpolate(&p, &t, &v, 150000.0, f64::NAN).is_none());
assert!(bilinear_interpolate(&p, &t, &v, f64::INFINITY, 300.0).is_none());
}
}

View File

@@ -0,0 +1,11 @@
//! Tabular fluid property backend.
//!
//! Pre-computed NIST-style tables with fast bilinear interpolation
//! for 100x performance vs direct EOS calls.
mod interpolate;
mod table;
pub mod generator;
pub use table::{FluidTable, SaturationTable, SinglePhaseTable, TableCriticalPoint};

View File

@@ -0,0 +1,286 @@
//! Fluid property table structure and loading.
//!
//! Defines the JSON format for tabular fluid data and provides loading logic.
use crate::errors::{FluidError, FluidResult};
use entropyk_core::{Pressure, Temperature};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use super::interpolate::bilinear_interpolate;
/// Critical point data stored in table metadata.
#[derive(Debug, Clone)]
pub struct TableCriticalPoint {
/// Critical temperature (K)
pub temperature: Temperature,
/// Critical pressure (Pa)
pub pressure: Pressure,
/// Critical density (kg/m³)
pub density: f64,
}
/// Single-phase property table (P, T) grid.
#[derive(Debug, Clone)]
pub struct SinglePhaseTable {
/// Pressure grid (Pa), ascending
pub pressure: Vec<f64>,
/// Temperature grid (K), ascending
pub temperature: Vec<f64>,
/// Property grids: density, enthalpy, entropy, cp, cv, etc.
/// Key: property name, Value: row-major 2D data [p_idx * n_t + t_idx]
pub properties: HashMap<String, Vec<f64>>,
}
impl SinglePhaseTable {
/// Interpolate a property at (p, t). Returns error if out of bounds.
#[inline]
pub fn interpolate(
&self,
property_name: &str,
p: f64,
t: f64,
fluid_name: &str,
) -> FluidResult<f64> {
let values = self
.properties
.get(property_name)
.ok_or(FluidError::UnsupportedProperty {
property: property_name.to_string(),
})?;
bilinear_interpolate(&self.pressure, &self.temperature, values, p, t).ok_or(
FluidError::OutOfBounds {
fluid: fluid_name.to_string(),
p,
t,
},
)
}
/// Check if (p, t) is within table bounds.
#[inline]
pub fn in_bounds(&self, p: f64, t: f64) -> bool {
if self.pressure.is_empty() || self.temperature.is_empty() {
return false;
}
let p_min = self.pressure[0];
let p_max = self.pressure[self.pressure.len() - 1];
let t_min = self.temperature[0];
let t_max = self.temperature[self.temperature.len() - 1];
p >= p_min && p <= p_max && t >= t_min && t <= t_max
}
}
/// Saturation line data for two-phase (P, x) lookups.
#[derive(Debug, Clone)]
pub struct SaturationTable {
/// Temperature (K) - independent variable
pub temperature: Vec<f64>,
/// Saturation pressure (Pa)
pub pressure: Vec<f64>,
/// Saturated liquid enthalpy (J/kg)
pub h_liq: Vec<f64>,
/// Saturated vapor enthalpy (J/kg)
pub h_vap: Vec<f64>,
/// Saturated liquid density (kg/m³)
pub rho_liq: Vec<f64>,
/// Saturated vapor density (kg/m³)
pub rho_vap: Vec<f64>,
/// Saturated liquid entropy (J/(kg·K))
pub s_liq: Vec<f64>,
/// Saturated vapor entropy (J/(kg·K))
pub s_vap: Vec<f64>,
}
impl SaturationTable {
/// Find saturation properties at pressure P via 1D interpolation on P_sat(T).
/// Returns (T_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap).
pub fn at_pressure(&self, p: f64) -> Option<(f64, f64, f64, f64, f64, f64, f64)> {
if self.pressure.is_empty() || self.pressure.len() != self.temperature.len() {
return None;
}
// Find T such that P_sat(T) = p (pressure is monotonic with T)
let n = self.pressure.len();
if p < self.pressure[0] || p > self.pressure[n - 1] {
return None;
}
let idx = self.pressure.iter().position(|&x| x >= p).unwrap_or(n - 1);
let i = if idx == 0 { 0 } else { idx - 1 };
let j = (i + 1).min(n - 1);
let p0 = self.pressure[i];
let p1 = self.pressure[j];
let frac = if (p1 - p0).abs() < 1e-15 {
0.0
} else {
((p - p0) / (p1 - p0)).clamp(0.0, 1.0)
};
let t_sat = self.temperature[i] * (1.0 - frac) + self.temperature[j] * frac;
let h_liq = self.h_liq[i] * (1.0 - frac) + self.h_liq[j] * frac;
let h_vap = self.h_vap[i] * (1.0 - frac) + self.h_vap[j] * frac;
let rho_liq = self.rho_liq[i] * (1.0 - frac) + self.rho_liq[j] * frac;
let rho_vap = self.rho_vap[i] * (1.0 - frac) + self.rho_vap[j] * frac;
let s_liq = self.s_liq[i] * (1.0 - frac) + self.s_liq[j] * frac;
let s_vap = self.s_vap[i] * (1.0 - frac) + self.s_vap[j] * frac;
Some((t_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap))
}
}
/// Complete fluid table with single-phase and saturation data.
#[derive(Debug, Clone)]
pub struct FluidTable {
/// Fluid identifier
pub fluid_id: String,
/// Critical point
pub critical_point: TableCriticalPoint,
/// Single-phase (P, T) table
pub single_phase: SinglePhaseTable,
/// Saturation table (optional - for two-phase support)
pub saturation: Option<SaturationTable>,
}
/// JSON deserialization structures (internal format).
#[derive(Debug, Deserialize)]
struct JsonCriticalPoint {
tc: f64,
pc: f64,
rho_c: f64,
}
#[derive(Debug, Deserialize)]
struct JsonSinglePhase {
pressure: Vec<f64>,
temperature: Vec<f64>,
density: Vec<f64>,
enthalpy: Vec<f64>,
entropy: Vec<f64>,
cp: Vec<f64>,
cv: Vec<f64>,
}
#[derive(Debug, Deserialize)]
struct JsonSaturation {
temperature: Vec<f64>,
pressure: Vec<f64>,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
rho_liq: Vec<f64>,
rho_vap: Vec<f64>,
s_liq: Vec<f64>,
s_vap: Vec<f64>,
}
#[derive(Debug, Deserialize)]
struct JsonFluidTable {
fluid: String,
critical_point: JsonCriticalPoint,
single_phase: JsonSinglePhase,
saturation: Option<JsonSaturation>,
}
impl FluidTable {
/// Load a fluid table from a JSON file.
pub fn load_from_path(path: &Path) -> FluidResult<Self> {
let contents = std::fs::read_to_string(path).map_err(|e| FluidError::TableNotFound {
path: format!("{}: {}", path.display(), e),
})?;
let json: JsonFluidTable =
serde_json::from_str(&contents).map_err(|e| FluidError::InvalidState {
reason: format!("Invalid table JSON: {}", e),
})?;
Self::from_json(json)
}
/// Load from JSON string (for embedded tables in tests).
pub fn load_from_str(s: &str) -> FluidResult<Self> {
let json: JsonFluidTable =
serde_json::from_str(s).map_err(|e| FluidError::InvalidState {
reason: format!("Invalid table JSON: {}", e),
})?;
Self::from_json(json)
}
fn from_json(json: JsonFluidTable) -> FluidResult<Self> {
let n_p = json.single_phase.pressure.len();
let n_t = json.single_phase.temperature.len();
let expected = n_p * n_t;
if json.single_phase.density.len() != expected
|| json.single_phase.enthalpy.len() != expected
|| json.single_phase.entropy.len() != expected
|| json.single_phase.cp.len() != expected
|| json.single_phase.cv.len() != expected
{
return Err(FluidError::InvalidState {
reason: "Table grid dimensions do not match property arrays".to_string(),
});
}
let mut properties = HashMap::new();
properties.insert("density".to_string(), json.single_phase.density);
properties.insert("enthalpy".to_string(), json.single_phase.enthalpy);
properties.insert("entropy".to_string(), json.single_phase.entropy);
properties.insert("cp".to_string(), json.single_phase.cp);
properties.insert("cv".to_string(), json.single_phase.cv);
let single_phase = SinglePhaseTable {
pressure: json.single_phase.pressure,
temperature: json.single_phase.temperature,
properties,
};
let saturation = json
.saturation
.map(|s| {
let n = s.temperature.len();
if s.pressure.len() != n
|| s.h_liq.len() != n
|| s.h_vap.len() != n
|| s.rho_liq.len() != n
|| s.rho_vap.len() != n
|| s.s_liq.len() != n
|| s.s_vap.len() != n
{
return Err(FluidError::InvalidState {
reason: format!(
"Saturation table array length mismatch: expected {} elements",
n
),
});
}
Ok(SaturationTable {
temperature: s.temperature,
pressure: s.pressure,
h_liq: s.h_liq,
h_vap: s.h_vap,
rho_liq: s.rho_liq,
rho_vap: s.rho_vap,
s_liq: s.s_liq,
s_vap: s.s_vap,
})
})
.transpose()?;
let critical_point = TableCriticalPoint {
temperature: Temperature::from_kelvin(json.critical_point.tc),
pressure: Pressure::from_pascals(json.critical_point.pc),
density: json.critical_point.rho_c,
};
Ok(FluidTable {
fluid_id: json.fluid,
critical_point,
single_phase,
saturation,
})
}
}

View File

@@ -0,0 +1,543 @@
//! Tabular interpolation backend for fluid properties.
//!
//! Provides 100x faster property lookups via pre-computed tables
//! with bilinear interpolation. Results deviate < 0.01% from NIST REFPROP.
use crate::backend::FluidBackend;
use crate::damped_backend::DampedBackend;
use crate::errors::{FluidError, FluidResult};
use crate::tabular::FluidTable;
#[allow(unused_imports)]
use crate::types::Entropy;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use std::collections::HashMap;
use std::path::Path;
/// Tabular backend using pre-computed property tables.
///
/// Loads fluid tables from JSON files and performs bilinear interpolation
/// for fast property lookups. No heap allocation in the property() hot path.
pub struct TabularBackend {
/// Pre-loaded tables: fluid name -> table (no allocation during queries)
tables: HashMap<String, FluidTable>,
/// Ordered list of fluid IDs for list_fluids()
fluid_ids: Vec<String>,
}
impl TabularBackend {
/// Create an empty TabularBackend.
pub fn new() -> Self {
TabularBackend {
tables: HashMap::new(),
fluid_ids: Vec::new(),
}
}
/// Creates a new TabularBackend with critical point damping enabled.
///
/// This wraps the backend with a `DampedBackend` to apply C1-continuous
/// damping to derivative properties (Cp, Cv, etc.) near the critical point.
pub fn with_damping() -> DampedBackend<TabularBackend> {
DampedBackend::new(Self::new())
}
/// Load a fluid table from a JSON file and register it.
pub fn load_table(&mut self, path: &Path) -> FluidResult<()> {
let table = FluidTable::load_from_path(path)?;
let id = table.fluid_id.clone();
if !self.fluid_ids.contains(&id) {
self.fluid_ids.push(id.clone());
}
self.tables.insert(id, table);
Ok(())
}
/// Load a fluid table from a JSON string (for embedded/test data).
pub fn load_table_from_str(&mut self, json: &str) -> FluidResult<()> {
let table = FluidTable::load_from_str(json)?;
let id = table.fluid_id.clone();
if !self.fluid_ids.contains(&id) {
self.fluid_ids.push(id.clone());
}
self.tables.insert(id, table);
Ok(())
}
/// Get a reference to a fluid table. Returns None if not loaded.
#[inline]
fn get_table(&self, fluid: &FluidId) -> Option<&FluidTable> {
self.tables.get(&fluid.0)
}
/// Resolve FluidState to (p, t) in Pascals and Kelvin.
/// For (P,x) uses saturation temperature at P.
fn resolve_state(&self, fluid: &FluidId, state: FluidState) -> FluidResult<(f64, f64)> {
match state {
FluidState::PressureTemperature(p, t) => Ok((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpy(p, h) => {
let table = self.get_table(fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
self.find_t_from_ph(table, p.to_pascals(), h.to_joules_per_kg())
}
FluidState::PressureQuality(p, _x) => {
let table = self.get_table(fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
if let Some(ref sat) = table.saturation {
let (t_sat, _, _, _, _, _, _) =
sat.at_pressure(p.to_pascals())
.ok_or(FluidError::OutOfBounds {
fluid: fluid.0.clone(),
p: p.to_pascals(),
t: 0.0,
})?;
Ok((p.to_pascals(), t_sat))
} else {
Err(FluidError::InvalidState {
reason: "Two-phase (P,x) requires saturation table".to_string(),
})
}
}
FluidState::PressureEntropy(_, _) => Err(FluidError::InvalidState {
reason: "TabularBackend does not yet support (P,s) state".to_string(),
}),
FluidState::PressureTemperatureMixture(_, _, _)
| FluidState::PressureEnthalpyMixture(_, _, _)
| FluidState::PressureQualityMixture(_, _, _) => {
// TabularBackend does not support mixtures - fallback to error
Err(FluidError::MixtureNotSupported(
"TabularBackend does not support mixtures. Use CoolPropBackend.".to_string(),
))
}
}
}
/// Find T given (P, h) using Newton iteration on the enthalpy table.
fn find_t_from_ph(&self, table: &FluidTable, p: f64, h_target: f64) -> FluidResult<(f64, f64)> {
// Initial guess: use midpoint of T range
let t_grid = &table.single_phase.temperature;
if t_grid.len() < 2 {
return Err(FluidError::InvalidState {
reason: "Table too small for (P,h) lookup".to_string(),
});
}
let mut t = (t_grid[0] + t_grid[t_grid.len() - 1]) / 2.0;
let dt_fd = 0.1; // K, for finite difference
for _ in 0..20 {
let h = table
.single_phase
.interpolate("enthalpy", p, t, &table.fluid_id)?;
let err = h - h_target;
if err.abs() < 1.0 {
return Ok((p, t));
}
let h_plus = table
.single_phase
.interpolate("enthalpy", p, t + dt_fd, &table.fluid_id)
.unwrap_or(h);
let dh_dt = (h_plus - h) / dt_fd;
if dh_dt.abs() < 1e-10 {
return Err(FluidError::NumericalError(
"Zero dh/dT in (P,h) Newton iteration".to_string(),
));
}
t -= err / dh_dt;
if t < t_grid[0] || t > t_grid[t_grid.len() - 1] {
return Err(FluidError::OutOfBounds {
fluid: table.fluid_id.clone(),
p,
t,
});
}
}
Err(FluidError::NumericalError(
"Newton iteration did not converge for (P,h)".to_string(),
))
}
/// Get property for two-phase (P, x) via linear blend.
fn property_two_phase(
&self,
table: &FluidTable,
p: f64,
x: f64,
property: Property,
) -> FluidResult<f64> {
let sat = table.saturation.as_ref().ok_or(FluidError::InvalidState {
reason: "Two-phase requires saturation table".to_string(),
})?;
let (t_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap) =
sat.at_pressure(p).ok_or(FluidError::OutOfBounds {
fluid: table.fluid_id.clone(),
p,
t: 0.0,
})?;
let val = match property {
Property::Enthalpy => h_liq * (1.0 - x) + h_vap * x,
Property::Density => {
let v_liq = 1.0 / rho_liq;
let v_vap = 1.0 / rho_vap;
let v = v_liq * (1.0 - x) + v_vap * x;
1.0 / v
}
Property::Entropy => s_liq * (1.0 - x) + s_vap * x,
Property::Quality => x,
Property::Temperature => t_sat,
Property::Pressure => p,
_ => {
return Err(FluidError::UnsupportedProperty {
property: property.to_string(),
})
}
};
Ok(val)
}
/// Map Property enum to table property name.
fn property_table_name(property: Property) -> Option<&'static str> {
match property {
Property::Density => Some("density"),
Property::Enthalpy => Some("enthalpy"),
Property::Entropy => Some("entropy"),
Property::Cp => Some("cp"),
Property::Cv => Some("cv"),
Property::Temperature => Some("temperature"),
Property::Pressure => Some("pressure"),
_ => None,
}
}
}
impl Default for TabularBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::FluidBackend;
use approx::assert_relative_eq;
use entropyk_core::{Pressure, Temperature};
fn make_test_backend() -> TabularBackend {
let mut backend = TabularBackend::new();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/r134a.json");
backend.load_table(&path).unwrap();
backend
}
#[test]
fn test_tabular_load_r134a() {
let backend = make_test_backend();
assert!(backend.is_fluid_available(&FluidId::new("R134a")));
assert!(!backend.is_fluid_available(&FluidId::new("R999")));
}
#[test]
fn test_tabular_property_pt() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let density = backend
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
assert!(density > 1.0 && density < 100.0);
let enthalpy = backend
.property(FluidId::new("R134a"), Property::Enthalpy, state)
.unwrap();
assert!(enthalpy > 300_000.0 && enthalpy < 500_000.0);
}
/// Accuracy: at grid point (200 kPa, 290 K), density must match table exactly.
#[test]
fn test_tabular_accuracy_at_grid_point() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(290.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_relative_eq!(density, 9.0, epsilon = 1e-10);
}
/// Accuracy: interpolated value within 1% (table self-consistency check).
#[test]
fn test_tabular_accuracy_interpolated() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(300.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_relative_eq!(density, 8.415, epsilon = 0.01);
}
#[test]
fn test_tabular_critical_point() {
let backend = make_test_backend();
let cp = backend.critical_point(FluidId::new("R134a")).unwrap();
assert!((cp.temperature_kelvin() - 374.21).abs() < 1.0);
assert!((cp.pressure_pascals() - 4.059e6).abs() < 1e4);
}
#[test]
fn test_tabular_list_fluids() {
let backend = make_test_backend();
let fluids = backend.list_fluids();
assert_eq!(fluids.len(), 1);
assert_eq!(fluids[0].0, "R134a");
}
#[test]
fn test_tabular_unknown_fluid() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let result = backend.property(FluidId::new("R999"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_tabular_out_of_bounds() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(50_000.0),
Temperature::from_kelvin(200.0),
);
let result = backend.property(FluidId::new("R134a"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_tabular_ph_state() {
let backend = make_test_backend();
let state = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(density > 1.0);
}
#[test]
fn test_tabular_px_state() {
let backend = make_test_backend();
let state = FluidState::from_px(
Pressure::from_pascals(500_000.0),
crate::types::Quality::new(0.5),
);
let enthalpy = backend
.property(FluidId::new("R134a"), Property::Enthalpy, state)
.unwrap();
assert!(enthalpy > 300_000.0 && enthalpy < 450_000.0);
}
#[test]
fn test_tabular_benchmark_10k_queries() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let start = std::time::Instant::now();
for _ in 0..10_000 {
let _ = backend.property(FluidId::new("R134a"), Property::Density, state.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 100,
"10k queries took {}ms, expected < 100ms (debug mode)",
elapsed.as_millis()
);
}
/// Release build: 10k queries must complete in < 10ms (AC #3).
#[test]
#[cfg_attr(debug_assertions, ignore = "run with cargo test --release")]
fn test_tabular_benchmark_10k_queries_release() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let start = std::time::Instant::now();
for _ in 0..10_000 {
let _ = backend.property(FluidId::new("R134a"), Property::Density, state.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 10,
"10k queries took {}ms, expected < 10ms in release",
elapsed.as_millis()
);
}
/// Compare TabularBackend vs CoolPropBackend. Embedded r134a.json may be from
/// reference data; use epsilon 1% for compatibility. CoolProp-generated tables
/// achieve < 0.01% (validated in generator::test_generated_table_vs_coolprop_spot_checks).
#[test]
#[cfg(feature = "coolprop")]
fn test_tabular_vs_coolprop_accuracy() {
use crate::coolprop::CoolPropBackend;
use crate::types::Quality;
let mut tabular = TabularBackend::new();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/r134a.json");
tabular.load_table(&path).unwrap();
let coolprop = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
// (P, T) at 1 bar, 25°C
let state_pt =
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let rho_t = tabular
.property(fluid.clone(), Property::Density, state_pt)
.unwrap();
let rho_c = coolprop
.property(fluid.clone(), Property::Density, state_pt)
.unwrap();
assert_relative_eq!(rho_t, rho_c, epsilon = 0.01 * rho_c.max(1.0));
let h_t = tabular
.property(fluid.clone(), Property::Enthalpy, state_pt)
.unwrap();
let h_c = coolprop
.property(fluid.clone(), Property::Enthalpy, state_pt)
.unwrap();
assert_relative_eq!(h_t, h_c, epsilon = 0.01 * h_c.max(1.0));
// (P, h) at 1 bar, h ≈ 415 kJ/kg
let state_ph = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0),
);
let rho_t_ph = tabular
.property(fluid.clone(), Property::Density, state_ph)
.unwrap();
let rho_c_ph = coolprop
.property(fluid.clone(), Property::Density, state_ph)
.unwrap();
assert_relative_eq!(rho_t_ph, rho_c_ph, epsilon = 0.01 * rho_c_ph.max(1.0));
// (P, x) at 500 kPa, x = 0.5
let state_px = FluidState::from_px(Pressure::from_pascals(500_000.0), Quality::new(0.5));
let h_t_px = tabular
.property(fluid.clone(), Property::Enthalpy, state_px)
.unwrap();
let h_c_px = coolprop
.property(fluid.clone(), Property::Enthalpy, state_px)
.unwrap();
assert_relative_eq!(h_t_px, h_c_px, epsilon = 0.01 * h_c_px.max(1.0));
}
}
impl FluidBackend for TabularBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
// Handle (P, x) two-phase explicitly
if let FluidState::PressureQuality(p, x) = state {
return self.property_two_phase(table, p.to_pascals(), x.value(), property);
}
let (p, t) = self.resolve_state(&fluid, state)?;
// Temperature and Pressure are direct
if property == Property::Temperature {
return Ok(t);
}
if property == Property::Pressure {
return Ok(p);
}
let name = Self::property_table_name(property).ok_or(FluidError::UnsupportedProperty {
property: property.to_string(),
})?;
table.single_phase.interpolate(name, p, t, &fluid.0)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
let table = self.get_table(&fluid).ok_or(FluidError::NoCriticalPoint {
fluid: fluid.0.clone(),
})?;
let cp = &table.critical_point;
Ok(CriticalPoint::new(cp.temperature, cp.pressure, cp.density))
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.tables.contains_key(&fluid.0)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
let (p, t) = self.resolve_state(&fluid, state.clone())?;
let pc = table.critical_point.pressure.to_pascals();
let tc = table.critical_point.temperature.to_kelvin();
if p > pc && t > tc {
return Ok(Phase::Supercritical);
}
if let Some(ref sat) = table.saturation {
if let Some((_, h_liq, h_vap, _, _, _, _)) = sat.at_pressure(p) {
if let FluidState::PressureEnthalpy(_, h) = state {
let hv = h.to_joules_per_kg();
if hv <= h_liq {
return Ok(Phase::Liquid);
}
if hv >= h_vap {
return Ok(Phase::Vapor);
}
return Ok(Phase::TwoPhase);
}
if let FluidState::PressureQuality(_, x) = state {
if x.value() <= 0.0 {
return Ok(Phase::Liquid);
}
if x.value() >= 1.0 {
return Ok(Phase::Vapor);
}
return Ok(Phase::TwoPhase);
}
}
}
Ok(Phase::Unknown)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.fluid_ids
.iter()
.map(|s| FluidId::new(s.clone()))
.collect()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for TabularBackend: Temperature is {:.2} K", t_k),
})
}
}

View File

@@ -0,0 +1,430 @@
//! Test backend implementation for unit testing.
//!
//! This module provides a mock backend that returns simplified/idealized
//! property values for testing without requiring external dependencies
//! like CoolProp.
use crate::backend::FluidBackend;
use crate::errors::{FluidError, FluidResult};
#[cfg(test)]
use crate::mixture::Mixture;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use entropyk_core::{Pressure, Temperature};
use std::collections::HashMap;
/// Test backend for unit testing.
///
/// This backend provides simplified thermodynamic property calculations
/// suitable for testing without external dependencies. Values are idealized
/// approximations and should NOT be used for real simulations.
pub struct TestBackend {
/// Map of fluid names to critical points
critical_points: HashMap<String, CriticalPoint>,
/// List of available test fluids
available_fluids: Vec<String>,
}
impl TestBackend {
/// Creates a new TestBackend with default test fluids.
pub fn new() -> Self {
let mut critical_points = HashMap::new();
// CO2 (R744)
critical_points.insert(
"CO2".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.0,
),
);
// R134a
critical_points.insert(
"R134a".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(374.21),
Pressure::from_pascals(4.059e6),
512.0,
),
);
// R410A
critical_points.insert(
"R410A".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(344.49),
Pressure::from_pascals(4.926e6),
458.0,
),
);
// R32
critical_points.insert(
"R32".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(351.25),
Pressure::from_pascals(5.782e6),
360.0,
),
);
// Water
critical_points.insert(
"Water".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(647.096),
Pressure::from_pascals(22.064e6),
322.0,
),
);
let available_fluids = vec![
"CO2".to_string(),
"R134a".to_string(),
"R410A".to_string(),
"R32".to_string(),
"Water".to_string(),
"Nitrogen".to_string(),
"Oxygen".to_string(),
"Air".to_string(),
];
TestBackend {
critical_points,
available_fluids,
}
}
/// Simplified ideal gas property calculation.
fn ideal_property(
&self,
fluid: &str,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
// Simple ideal gas approximations for testing
// Real implementation would use proper equations of state
match fluid {
"Nitrogen" | "Oxygen" | "Air" => self.ideal_gas_property(property, state, 29.0),
"Water" => self.water_property(property, state),
_ => {
// For refrigerants, use simplified correlations
self.refrigerant_property(fluid, property, state)
}
}
}
fn ideal_gas_property(
&self,
property: Property,
state: FluidState,
_molar_mass: f64,
) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for ideal gases".to_string(),
})
}
};
// Simplified ideal gas: R = 8.314 J/(mol·K), approximate
let r_specific = 287.0; // J/(kg·K) for air
match property {
Property::Density => Ok(p / (r_specific * t)),
Property::Enthalpy => Ok(1005.0 * t), // Cp * T, Cp ≈ 1005 J/(kg·K) for air
Property::Entropy => Ok(r_specific * t.ln()), // Simplified
Property::Cp => Ok(1005.0), // Constant pressure specific heat
Property::Cv => Ok(718.0), // Constant volume specific heat
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
Property::ThermalConductivity => Ok(0.025), // W/(m·K) for air
Property::Viscosity => Ok(1.8e-5), // Pa·s for air
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
}
fn water_property(&self, property: Property, state: FluidState) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for water".to_string(),
})
}
};
// Simplified water properties at ~1 atm
if p < 1.1e5 && t > 273.15 && t < 373.15 {
match property {
Property::Density => Ok(1000.0), // kg/m³
Property::Enthalpy => Ok(4200.0 * (t - 273.15)), // Cp * ΔT
Property::Cp => Ok(4184.0), // J/(kg·K)
Property::ThermalConductivity => Ok(0.6), // W/(m·K)
Property::Viscosity => Ok(0.001), // Pa·s
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
} else {
Err(FluidError::InvalidState {
reason: "Water property only valid in liquid region".to_string(),
})
}
}
fn refrigerant_property(
&self,
_fluid: &str,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for refrigerants".to_string(),
})
}
};
// Simplified refrigerant properties
match property {
Property::Density => {
// Rough approximation for liquid (~1000 kg/m³) vs vapor (~10-50 kg/m³)
if p > 1e6 {
Ok(1000.0) // Liquid
} else {
Ok(30.0) // Vapor
}
}
Property::Enthalpy => {
if p > 1e6 {
Ok(200000.0) // Liquid region
} else {
Ok(400000.0) // Vapor region
}
}
Property::Cp => Ok(1500.0), // Approximate
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
Property::ThermalConductivity => Ok(0.015),
Property::Viscosity => Ok(1.5e-5),
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
}
fn determine_phase(&self, fluid: &str, state: FluidState) -> Phase {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => return Phase::Unknown,
};
// Get critical point if available
if let Some(cp) = self.critical_points.get(fluid) {
let pc = cp.pressure_pascals();
let tc = cp.temperature_kelvin();
if p > pc && t > tc {
return Phase::Supercritical;
}
if (p - pc).abs() / pc < 0.05 || (t - tc).abs() / tc < 0.05 {
return Phase::Supercritical;
}
}
// Simplified phase determination
if p > 5e5 {
Phase::Liquid
} else {
Phase::Vapor
}
}
}
impl Default for TestBackend {
fn default() -> Self {
Self::new()
}
}
impl FluidBackend for TestBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
});
}
self.ideal_property(&fluid.0, property, state)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
self.critical_points
.get(&fluid.0)
.copied()
.ok_or(FluidError::NoCriticalPoint { fluid: fluid.0 })
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.available_fluids.iter().any(|f| f == &fluid.0)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
Ok(self.determine_phase(&fluid.0, state))
}
fn list_fluids(&self) -> Vec<FluidId> {
self.available_fluids
.iter()
.map(|s| FluidId::new(s.clone()))
.collect()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for TestBackend: Temperature is {:.2} K", t_k),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_available_fluids() {
let backend = TestBackend::new();
assert!(backend.is_fluid_available(&FluidId::new("CO2")));
assert!(backend.is_fluid_available(&FluidId::new("R134a")));
assert!(!backend.is_fluid_available(&FluidId::new("R999")));
}
#[test]
fn test_list_fluids() {
let backend = TestBackend::new();
let fluids = backend.list_fluids();
assert!(fluids.len() > 0);
assert!(fluids.iter().any(|f| f.0 == "CO2"));
}
#[test]
fn test_critical_point() {
let backend = TestBackend::new();
let cp = backend.critical_point(FluidId::new("CO2")).unwrap();
assert!((cp.temperature_kelvin() - 304.13).abs() < 0.1);
assert!((cp.pressure_pascals() - 7.3773e6).abs() < 1e4);
}
#[test]
fn test_critical_point_not_available() {
let backend = TestBackend::new();
let result = backend.critical_point(FluidId::new("UnknownFluid"));
assert!(result.is_err());
}
#[test]
fn test_property_nitrogen() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_kelvin(300.0),
);
let density = backend
.property(FluidId::new("Nitrogen"), Property::Density, state)
.unwrap();
assert!(density > 1.0); // Should be ~1.16 kg/m³
}
#[test]
fn test_property_water() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_celsius(25.0),
);
let density = backend
.property(FluidId::new("Water"), Property::Density, state)
.unwrap();
assert!((density - 1000.0).abs() < 1.0);
}
#[test]
fn test_property_unknown_fluid() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_kelvin(300.0),
);
let result = backend.property(FluidId::new("R999"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_phase_co2_supercritical() {
let backend = TestBackend::new();
// Above critical point
let state =
FluidState::from_pt(Pressure::from_pascals(8e6), Temperature::from_kelvin(320.0));
let phase = backend.phase(FluidId::new("CO2"), state).unwrap();
assert_eq!(phase, Phase::Supercritical);
}
#[test]
fn test_phase_liquid() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(10e5),
Temperature::from_celsius(25.0),
);
let phase = backend.phase(FluidId::new("Water"), state).unwrap();
assert_eq!(phase, Phase::Liquid);
}
#[test]
fn test_thermo_state_is_mixture() {
let mix = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let state_pure =
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
assert!(!state_pure.is_mixture());
let state_mix = FluidState::from_pt_mix(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
mix,
);
assert!(state_mix.is_mixture());
}
}

369
crates/fluids/src/types.rs Normal file
View File

@@ -0,0 +1,369 @@
//! Types for fluid property calculations.
//!
//! This module defines the core types used to represent thermodynamic states,
//! fluid identifiers, and properties in the fluid backend system.
use crate::mixture::Mixture;
use entropyk_core::{Enthalpy, Pressure, Temperature};
use std::fmt;
/// Difference between two temperatures in Kelvin.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct TemperatureDelta(pub f64);
impl TemperatureDelta {
/// Creates a new TemperatureDelta from a difference in Kelvin.
pub fn new(kelvin_diff: f64) -> Self {
TemperatureDelta(kelvin_diff)
}
/// Gets the temperature difference in Kelvin.
pub fn kelvin(&self) -> f64 {
self.0
}
}
impl From<f64> for TemperatureDelta {
fn from(val: f64) -> Self {
TemperatureDelta(val)
}
}
/// Unique identifier for a fluid.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FluidId(pub String);
impl FluidId {
/// Creates a new FluidId from a string.
pub fn new(name: impl Into<String>) -> Self {
FluidId(name.into())
}
}
impl fmt::Display for FluidId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for FluidId {
fn from(s: &str) -> Self {
FluidId(s.to_string())
}
}
impl From<String> for FluidId {
fn from(s: String) -> Self {
FluidId(s)
}
}
/// Thermodynamic property that can be queried from a backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Property {
/// Density (kg/m³)
Density,
/// Specific enthalpy (J/kg)
Enthalpy,
/// Specific entropy (J/(kg·K))
Entropy,
/// Specific internal energy (J/kg)
InternalEnergy,
/// Specific heat capacity at constant pressure (J/(kg·K))
Cp,
/// Specific heat capacity at constant volume (J/(kg·K))
Cv,
/// Speed of sound (m/s)
SpeedOfSound,
/// Dynamic viscosity (Pa·s)
Viscosity,
/// Thermal conductivity (W/(m·K))
ThermalConductivity,
/// Surface tension (N/m)
SurfaceTension,
/// Quality (0-1 for two-phase)
Quality,
/// Temperature (K)
Temperature,
/// Pressure (Pa)
Pressure,
}
impl fmt::Display for Property {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Property::Density => write!(f, "Density"),
Property::Enthalpy => write!(f, "Enthalpy"),
Property::Entropy => write!(f, "Entropy"),
Property::InternalEnergy => write!(f, "InternalEnergy"),
Property::Cp => write!(f, "Cp"),
Property::Cv => write!(f, "Cv"),
Property::SpeedOfSound => write!(f, "SpeedOfSound"),
Property::Viscosity => write!(f, "Viscosity"),
Property::ThermalConductivity => write!(f, "ThermalConductivity"),
Property::SurfaceTension => write!(f, "SurfaceTension"),
Property::Quality => write!(f, "Quality"),
Property::Temperature => write!(f, "Temperature"),
Property::Pressure => write!(f, "Pressure"),
}
}
}
/// Input specification for thermodynamic state calculation.
///
/// Defines what inputs are available to look up a thermodynamic property.
#[derive(Debug, Clone, PartialEq)]
pub enum FluidState {
/// Pressure and temperature (P, T) - most common for single-phase
PressureTemperature(Pressure, Temperature),
/// Pressure and enthalpy (P, h) - common for expansion/compression
PressureEnthalpy(Pressure, Enthalpy),
/// Pressure and entropy (P, s) - useful for isentropic processes
PressureEntropy(Pressure, Entropy),
/// Pressure and quality (P, x) - for two-phase regions
PressureQuality(Pressure, Quality),
/// Pressure and temperature with mixture (P, T, mixture)
PressureTemperatureMixture(Pressure, Temperature, Mixture),
/// Pressure and enthalpy with mixture (P, h, mixture) - preferred for two-phase
PressureEnthalpyMixture(Pressure, Enthalpy, Mixture),
/// Pressure and quality with mixture (P, x, mixture) - for two-phase mixtures
PressureQualityMixture(Pressure, Quality, Mixture),
}
impl FluidState {
/// Creates a state from pressure and temperature.
pub fn from_pt(p: Pressure, t: Temperature) -> Self {
FluidState::PressureTemperature(p, t)
}
/// Creates a state from pressure and enthalpy.
pub fn from_ph(p: Pressure, h: Enthalpy) -> Self {
FluidState::PressureEnthalpy(p, h)
}
/// Creates a state from pressure and entropy.
pub fn from_ps(p: Pressure, s: Entropy) -> Self {
FluidState::PressureEntropy(p, s)
}
/// Creates a state from pressure and quality.
pub fn from_px(p: Pressure, x: Quality) -> Self {
FluidState::PressureQuality(p, x)
}
/// Creates a state from pressure, temperature, and mixture.
pub fn from_pt_mix(p: Pressure, t: Temperature, mix: Mixture) -> Self {
FluidState::PressureTemperatureMixture(p, t, mix)
}
/// Creates a state from pressure, enthalpy, and mixture (preferred for two-phase).
pub fn from_ph_mix(p: Pressure, h: Enthalpy, mix: Mixture) -> Self {
FluidState::PressureEnthalpyMixture(p, h, mix)
}
/// Creates a state from pressure, quality, and mixture.
pub fn from_px_mix(p: Pressure, x: Quality, mix: Mixture) -> Self {
FluidState::PressureQualityMixture(p, x, mix)
}
/// Check if this state contains a mixture.
pub fn is_mixture(&self) -> bool {
matches!(
self,
FluidState::PressureTemperatureMixture(_, _, _)
| FluidState::PressureEnthalpyMixture(_, _, _)
| FluidState::PressureQualityMixture(_, _, _)
)
}
}
/// Entropy in J/(kg·K).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Entropy(pub f64);
impl Entropy {
/// Creates entropy from J/(kg·K).
pub fn from_joules_per_kg_kelvin(value: f64) -> Self {
Entropy(value)
}
/// Returns entropy in J/(kg·K).
pub fn to_joules_per_kg_kelvin(&self) -> f64 {
self.0
}
}
impl From<f64> for Entropy {
fn from(value: f64) -> Self {
Entropy(value)
}
}
/// Quality (vapor fraction) from 0 (saturated liquid) to 1 (saturated vapor).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quality(pub f64);
impl Quality {
/// Creates a quality value (0-1).
pub fn new(value: f64) -> Self {
Quality(value.clamp(0.0, 1.0))
}
/// Returns the quality value (0-1).
pub fn value(&self) -> f64 {
self.0
}
}
impl From<f64> for Quality {
fn from(value: f64) -> Self {
Quality::new(value)
}
}
/// Critical point data for a fluid.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CriticalPoint {
/// Critical temperature in Kelvin.
pub temperature: Temperature,
/// Critical pressure in Pascals.
pub pressure: Pressure,
/// Critical density in kg/m³.
pub density: f64,
}
impl CriticalPoint {
/// Creates a new CriticalPoint.
pub fn new(temperature: Temperature, pressure: Pressure, density: f64) -> Self {
CriticalPoint {
temperature,
pressure,
density,
}
}
/// Returns critical temperature in Kelvin.
pub fn temperature_kelvin(&self) -> f64 {
self.temperature.to_kelvin()
}
/// Returns critical pressure in Pascals.
pub fn pressure_pascals(&self) -> f64 {
self.pressure.to_pascals()
}
}
/// Phase of matter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Liquid phase.
Liquid,
/// Vapor/gas phase.
Vapor,
/// Two-phase region.
TwoPhase,
/// Supercritical fluid.
Supercritical,
/// Unknown or undefined phase.
Unknown,
}
impl fmt::Display for Phase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Phase::Liquid => write!(f, "Liquid"),
Phase::Vapor => write!(f, "Vapor"),
Phase::TwoPhase => write!(f, "TwoPhase"),
Phase::Supercritical => write!(f, "Supercritical"),
Phase::Unknown => write!(f, "Unknown"),
}
}
}
/// Comprehensive representation of a thermodynamic state.
///
/// This struct holds a complete snapshot of all relevant properties for a fluid
/// at a given state. It avoids the need to repeatedly query the backend for
/// individual properties once the state is resolved.
#[derive(Debug, Clone, PartialEq)]
pub struct ThermoState {
/// Fluid identifier (e.g. "R410A")
pub fluid: FluidId,
/// Absolute pressure
pub pressure: Pressure,
/// Absolute temperature
pub temperature: Temperature,
/// Specific enthalpy
pub enthalpy: Enthalpy,
/// Specific entropy
pub entropy: Entropy,
/// Density in kg/m³
pub density: f64,
/// Physical phase of the fluid
pub phase: Phase,
/// Vapor quality (0.0 to 1.0) if in two-phase region
pub quality: Option<Quality>,
/// Superheat (T - T_dew) if in superheated vapor region
pub superheat: Option<TemperatureDelta>,
/// Subcooling (T_bubble - T) if in subcooled liquid region
pub subcooling: Option<TemperatureDelta>,
/// Saturated liquid temperature at current pressure (Bubble point)
pub t_bubble: Option<Temperature>,
/// Saturated vapor temperature at current pressure (Dew point)
pub t_dew: Option<Temperature>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fluid_id() {
let id = FluidId::new("R134a");
assert_eq!(id.0, "R134a");
}
#[test]
fn test_fluid_state_from_pt() {
let p = Pressure::from_bar(1.0);
let t = Temperature::from_celsius(25.0);
let state = FluidState::from_pt(p, t);
match state {
FluidState::PressureTemperature(p_out, t_out) => {
assert_eq!(p.to_pascals(), p_out.to_pascals());
assert_eq!(t.to_kelvin(), t_out.to_kelvin());
}
_ => panic!("Expected PressureTemperature variant"),
}
}
#[test]
fn test_quality_bounds() {
let q1 = Quality::new(0.5);
assert!((q1.value() - 0.5).abs() < 1e-10);
let q2 = Quality::new(1.5);
assert!((q2.value() - 1.0).abs() < 1e-10);
let q3 = Quality::new(-0.5);
assert!((q3.value() - 0.0).abs() < 1e-10);
}
#[test]
fn test_critical_point() {
// CO2 critical point: Tc = 304.13 K, Pc = 7.3773 MPa
let cp = CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.0,
);
assert!((cp.temperature_kelvin() - 304.13).abs() < 0.01);
assert!((cp.pressure_pascals() - 7.3773e6).abs() < 1.0);
}
#[test]
fn test_property_display() {
assert_eq!(format!("{}", Property::Density), "Density");
assert_eq!(format!("{}", Property::Enthalpy), "Enthalpy");
}
}