feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
28
crates/fluids/Cargo.toml
Normal file
28
crates/fluids/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "entropyk-fluids"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Fluid properties backend for Entropyk thermodynamic simulation library"
|
||||
|
||||
[dependencies]
|
||||
entropyk-core = { path = "../core" }
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1.0"
|
||||
lru = "0.12"
|
||||
entropyk-coolprop-sys = { path = "coolprop-sys", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
coolprop = ["entropyk-coolprop-sys"]
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
criterion = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "cache_10k"
|
||||
harness = false
|
||||
54
crates/fluids/benches/cache_10k.rs
Normal file
54
crates/fluids/benches/cache_10k.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Benchmark: 10k repeated (P,T) queries — cached vs uncached (Story 2.4 AC#4).
|
||||
//!
|
||||
//! Compares throughput of CachedBackend vs raw backend for repeated same-state queries.
|
||||
//! Cached path should show significant speedup when the backend is expensive (e.g. CoolProp).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use entropyk_fluids::{
|
||||
CachedBackend, FluidBackend, FluidId, Property, ThermoState, TestBackend,
|
||||
};
|
||||
use entropyk_core::{Pressure, Temperature};
|
||||
|
||||
const N_QUERIES: u32 = 10_000;
|
||||
|
||||
fn bench_uncached_10k(c: &mut Criterion) {
|
||||
let backend = TestBackend::new();
|
||||
let state = ThermoState::from_pt(
|
||||
Pressure::from_bar(1.0),
|
||||
Temperature::from_celsius(25.0),
|
||||
);
|
||||
let fluid = FluidId::new("R134a");
|
||||
|
||||
c.bench_function("uncached_10k_same_state", |b| {
|
||||
b.iter(|| {
|
||||
for _ in 0..N_QUERIES {
|
||||
black_box(
|
||||
backend.property(fluid.clone(), Property::Density, state.clone()).unwrap(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_cached_10k(c: &mut Criterion) {
|
||||
let inner = TestBackend::new();
|
||||
let cached = CachedBackend::new(inner);
|
||||
let state = ThermoState::from_pt(
|
||||
Pressure::from_bar(1.0),
|
||||
Temperature::from_celsius(25.0),
|
||||
);
|
||||
let fluid = FluidId::new("R134a");
|
||||
|
||||
c.bench_function("cached_10k_same_state", |b| {
|
||||
b.iter(|| {
|
||||
for _ in 0..N_QUERIES {
|
||||
black_box(
|
||||
cached.property(fluid.clone(), Property::Density, state.clone()).unwrap(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_uncached_10k, bench_cached_10k);
|
||||
criterion_main!(benches);
|
||||
18
crates/fluids/build.rs
Normal file
18
crates/fluids/build.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! Build script for entropyk-fluids crate.
|
||||
//!
|
||||
//! This build script can optionally compile CoolProp C++ library when the
|
||||
//! "coolprop" feature is enabled.
|
||||
|
||||
use std::env;
|
||||
|
||||
fn main() {
|
||||
let coolprop_enabled = env::var("CARGO_FEATURE_COOLPROP").is_ok();
|
||||
|
||||
if coolprop_enabled {
|
||||
println!("cargo:rustc-link-lib=dylib=coolprop");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
// Tell Cargo to rerun this script if any source files change
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
64
crates/fluids/coolprop-sys/build.rs
Normal file
64
crates/fluids/coolprop-sys/build.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Build script for coolprop-sys.
|
||||
//!
|
||||
//! This compiles the CoolProp C++ library statically.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn coolprop_src_path() -> Option<PathBuf> {
|
||||
// Try to find CoolProp source in common locations
|
||||
let possible_paths = vec![
|
||||
// Vendor directory (recommended)
|
||||
PathBuf::from("vendor/coolprop"),
|
||||
// External directory
|
||||
PathBuf::from("external/coolprop"),
|
||||
// System paths
|
||||
PathBuf::from("/usr/local/src/CoolProp"),
|
||||
PathBuf::from("/opt/CoolProp"),
|
||||
];
|
||||
|
||||
for path in possible_paths {
|
||||
if path.join("CMakeLists.txt").exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let static_linking = env::var("CARGO_FEATURE_STATIC").is_ok();
|
||||
|
||||
// Check if CoolProp source is available
|
||||
if let Some(coolprop_path) = coolprop_src_path() {
|
||||
println!("cargo:rerun-if-changed={}", coolprop_path.display());
|
||||
|
||||
// Configure build for CoolProp
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}/build",
|
||||
coolprop_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Link against CoolProp
|
||||
if static_linking {
|
||||
// Static linking - find libCoolProp.a
|
||||
println!("cargo:rustc-link-lib=static=CoolProp");
|
||||
} else {
|
||||
// Dynamic linking
|
||||
println!("cargo:rustc-link-lib=dylib=CoolProp");
|
||||
}
|
||||
|
||||
// Link required system libraries
|
||||
println!("cargo:rustc-link-lib=dylib=m");
|
||||
println!("cargo:rustc-link-lib=dylib=stdc++");
|
||||
|
||||
// Tell Cargo to rerun if build.rs changes
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
println!(
|
||||
"cargo:warning=CoolProp source not found in vendor/.
|
||||
For full static build, run:
|
||||
git clone https://github.com/CoolProp/CoolProp.git vendor/coolprop"
|
||||
);
|
||||
}
|
||||
336
crates/fluids/coolprop-sys/src/lib.rs
Normal file
336
crates/fluids/coolprop-sys/src/lib.rs
Normal file
@@ -0,0 +1,336 @@
|
||||
//! FFI bindings to CoolProp C++ library.
|
||||
//!
|
||||
//! This module provides low-level FFI bindings to the CoolProp library.
|
||||
//! All functions are unsafe and require proper error handling.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use libc::{c_char, c_double, c_int};
|
||||
use std::ffi::CString;
|
||||
|
||||
/// Error codes returned by CoolProp
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum CoolPropError {
|
||||
/// No error occurred
|
||||
NoError = 0,
|
||||
/// Input error code
|
||||
InputError = 1,
|
||||
/// Library not loaded
|
||||
LibraryNotLoaded = 2,
|
||||
/// Unknown property value
|
||||
UnknownPropertyValue = 3,
|
||||
/// Unknown fluid
|
||||
UnknownFluid = 4,
|
||||
/// Unknown parameter
|
||||
UnknownParameter = 5,
|
||||
/// Not implemented
|
||||
NotImplemented = 6,
|
||||
/// Invalid number of parameters
|
||||
InvalidNumber = 7,
|
||||
/// Could not load library
|
||||
CouldNotLoadLibrary = 8,
|
||||
/// Invalid fluid pair
|
||||
InvalidFluidPair = 9,
|
||||
/// Version mismatch
|
||||
VersionMismatch = 10,
|
||||
/// Internal error
|
||||
InternalError = 11,
|
||||
}
|
||||
|
||||
impl CoolPropError {
|
||||
/// Convert CoolProp error code to Rust result
|
||||
pub fn from_code(code: i32) -> Result<(), CoolPropError> {
|
||||
match code {
|
||||
0 => Ok(()),
|
||||
_ => Err(match code {
|
||||
1 => CoolPropError::InputError,
|
||||
2 => CoolPropError::LibraryNotLoaded,
|
||||
3 => CoolPropError::UnknownPropertyValue,
|
||||
4 => CoolPropError::UnknownFluid,
|
||||
5 => CoolPropError::UnknownParameter,
|
||||
6 => CoolPropError::NotImplemented,
|
||||
7 => CoolPropError::InvalidNumber,
|
||||
8 => CoolPropError::CouldNotLoadLibrary,
|
||||
9 => CoolPropError::InvalidFluidPair,
|
||||
10 => CoolPropError::VersionMismatch,
|
||||
_ => CoolPropError::InternalError,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output parameters for CoolProp
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum CoolPropParam {
|
||||
/// Nothing
|
||||
Nothing = 0,
|
||||
/// Pressure [Pa]
|
||||
Pressure = 1,
|
||||
/// Temperature [K]
|
||||
Temperature = 2,
|
||||
/// Density [kg/m³]
|
||||
Density = 3,
|
||||
/// Specific enthalpy [J/kg]
|
||||
Enthalpy = 4,
|
||||
/// Specific entropy [J/kg/K]
|
||||
Entropy = 5,
|
||||
/// Specific internal energy [J/kg]
|
||||
InternalEnergy = 6,
|
||||
/// Specific heat at constant pressure [J/kg/K]
|
||||
Cv = 7,
|
||||
/// Specific heat at constant pressure [J/kg/K]
|
||||
Cp = 8,
|
||||
/// Quality [-]
|
||||
Quality = 9,
|
||||
/// Viscosity [Pa·s]
|
||||
Viscosity = 10,
|
||||
/// Thermal conductivity [W/m/K]
|
||||
Conductivity = 11,
|
||||
/// Surface tension [N/m]
|
||||
SurfaceTension = 12,
|
||||
/// Prandtl number [-]
|
||||
Prandtl = 13,
|
||||
}
|
||||
|
||||
/// Input parameters for CoolProp
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum CoolPropInputPair {
|
||||
/// No input
|
||||
None = 0,
|
||||
/// Pressure & Temperature
|
||||
PT = 1,
|
||||
/// Pressure & Density
|
||||
PD = 2,
|
||||
/// Pressure & Enthalpy
|
||||
PH = 3,
|
||||
/// Pressure & Entropy
|
||||
PS = 4,
|
||||
/// Pressure & Internal Energy
|
||||
PU = 5,
|
||||
/// Temperature & Density
|
||||
TD = 6,
|
||||
/// Temperature & Enthalpy
|
||||
TH = 7,
|
||||
/// Temperature & Entropy
|
||||
TS = 8,
|
||||
/// Temperature & Internal Energy
|
||||
TU = 9,
|
||||
/// Enthalpy & Entropy
|
||||
HS = 10,
|
||||
/// Density & Internal Energy
|
||||
DU = 11,
|
||||
/// Pressure & Quality
|
||||
PQ = 12,
|
||||
/// Temperature & Quality
|
||||
TQ = 13,
|
||||
}
|
||||
|
||||
// CoolProp C functions
|
||||
extern "C" {
|
||||
/// Get a property value using pressure and temperature
|
||||
fn CoolProp_PropsSI(
|
||||
Output: c_char,
|
||||
Name1: c_char,
|
||||
Value1: c_double,
|
||||
Name2: c_char,
|
||||
Value2: c_double,
|
||||
Fluid: *const c_char,
|
||||
) -> c_double;
|
||||
|
||||
/// Get a property value using input pair
|
||||
fn CoolProp_Props1SI(Fluid: *const c_char, Output: c_char) -> c_double;
|
||||
|
||||
/// Get CoolProp version string
|
||||
fn CoolProp_get_global_param_string(
|
||||
Param: *const c_char,
|
||||
Output: *mut c_char,
|
||||
OutputLength: c_int,
|
||||
) -> c_int;
|
||||
|
||||
/// Get fluid info
|
||||
fn CoolProp_get_fluid_param_string(
|
||||
Fluid: *const c_char,
|
||||
Param: *const c_char,
|
||||
Output: *mut c_char,
|
||||
OutputLength: c_int,
|
||||
) -> c_int;
|
||||
|
||||
/// Check if fluid exists
|
||||
fn CoolProp_isfluid(Fluid: *const c_char) -> c_int;
|
||||
|
||||
/// Get saturation temperature
|
||||
fn CoolProp_Saturation_T(Fluid: *const c_char, Par: c_char, Value: c_double) -> c_double;
|
||||
|
||||
/// Get critical point
|
||||
fn CoolProp_CriticalPoint(Fluid: *const c_char, Output: c_char) -> c_double;
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and temperature.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `property` - The property to retrieve (e.g., "D" for density, "H" for enthalpy)
|
||||
/// * `p` - Pressure in Pa
|
||||
/// * `t` - Temperature in K
|
||||
/// * `fluid` - Fluid name (e.g., "R134a")
|
||||
///
|
||||
/// # Returns
|
||||
/// The property value in SI units, or NaN if an error occurs
|
||||
pub unsafe fn props_si_pt(property: &str, p: f64, t: f64, fluid: &str) -> f64 {
|
||||
let prop = property.as_bytes()[0] as c_char;
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
CoolProp_PropsSI(prop, b'P' as c_char, p, b'T' as c_char, t, fluid_c.as_ptr())
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and enthalpy.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `property` - The property to retrieve
|
||||
/// * `p` - Pressure in Pa
|
||||
/// * `h` - Specific enthalpy in J/kg
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// The property value in SI units, or NaN if an error occurs
|
||||
pub unsafe fn props_si_ph(property: &str, p: f64, h: f64, fluid: &str) -> f64 {
|
||||
let prop = property.as_bytes()[0] as c_char;
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
CoolProp_PropsSI(prop, b'P' as c_char, p, b'H' as c_char, h, fluid_c.as_ptr())
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using temperature and quality (saturation).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `property` - The property to retrieve (D, H, S, P, etc.)
|
||||
/// * `t` - Temperature in K
|
||||
/// * `q` - Quality (0 = saturated liquid, 1 = saturated vapor)
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// The property value in SI units, or NaN if an error occurs
|
||||
pub unsafe fn props_si_tq(property: &str, t: f64, q: f64, fluid: &str) -> f64 {
|
||||
let prop = property.as_bytes()[0] as c_char;
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
CoolProp_PropsSI(prop, b'T' as c_char, t, b'Q' as c_char, q, fluid_c.as_ptr())
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and quality.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `property` - The property to retrieve
|
||||
/// * `p` - Pressure in Pa
|
||||
/// * `x` - Quality (0-1)
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// The property value in SI units, or NaN if an error occurs
|
||||
pub unsafe fn props_si_px(property: &str, p: f64, x: f64, fluid: &str) -> f64 {
|
||||
let prop = property.as_bytes()[0] as c_char;
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
CoolProp_PropsSI(
|
||||
prop,
|
||||
b'P' as c_char,
|
||||
p,
|
||||
b'Q' as c_char, // Q for quality
|
||||
x,
|
||||
fluid_c.as_ptr(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get critical point temperature for a fluid.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// Critical temperature in K, or NaN if unavailable
|
||||
pub unsafe fn critical_temperature(fluid: &str) -> f64 {
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'T' as c_char)
|
||||
}
|
||||
|
||||
/// Get critical point pressure for a fluid.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// Critical pressure in Pa, or NaN if unavailable
|
||||
pub unsafe fn critical_pressure(fluid: &str) -> f64 {
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'P' as c_char)
|
||||
}
|
||||
|
||||
/// Get critical point density for a fluid.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// Critical density in kg/m³, or NaN if unavailable
|
||||
pub unsafe fn critical_density(fluid: &str) -> f64 {
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'D' as c_char)
|
||||
}
|
||||
|
||||
/// Check if a fluid is available in CoolProp.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the fluid is available
|
||||
pub unsafe fn is_fluid_available(fluid: &str) -> bool {
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
CoolProp_isfluid(fluid_c.as_ptr()) != 0
|
||||
}
|
||||
|
||||
/// Get CoolProp version string.
|
||||
///
|
||||
/// # Returns
|
||||
/// Version string (e.g., "6.14.0")
|
||||
pub fn get_version() -> String {
|
||||
unsafe {
|
||||
let mut buffer = vec![0u8; 32];
|
||||
let result = CoolProp_get_global_param_string(
|
||||
b"version\0".as_ptr() as *const c_char,
|
||||
buffer.as_mut_ptr() as *mut c_char,
|
||||
buffer.len() as c_int,
|
||||
);
|
||||
|
||||
if result == 0 {
|
||||
String::from_utf8_lossy(&buffer)
|
||||
.trim_end_matches('\0')
|
||||
.to_string()
|
||||
} else {
|
||||
String::from("Unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let version = get_version();
|
||||
assert!(!version.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fluid_available() {
|
||||
// Test some common refrigerants
|
||||
unsafe {
|
||||
assert!(is_fluid_available("R134a"));
|
||||
assert!(is_fluid_available("R410A"));
|
||||
assert!(is_fluid_available("CO2"));
|
||||
}
|
||||
}
|
||||
}
|
||||
63
crates/fluids/data/r134a.json
Normal file
63
crates/fluids/data/r134a.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"critical_point": {
|
||||
"tc": 374.21,
|
||||
"pc": 4059000,
|
||||
"rho_c": 512
|
||||
},
|
||||
"single_phase": {
|
||||
"pressure": [100000, 200000, 500000, 1000000, 2000000, 3000000],
|
||||
"temperature": [250, 270, 290, 298.15, 320, 350],
|
||||
"density": [
|
||||
5.2, 4.9, 4.5, 4.4, 4.0, 3.6,
|
||||
12.0, 10.5, 9.0, 8.5, 7.5, 6.5,
|
||||
35.0, 28.0, 22.0, 20.0, 16.0, 12.0,
|
||||
75.0, 55.0, 40.0, 35.0, 25.0, 18.0,
|
||||
150.0, 100.0, 65.0, 55.0, 38.0, 25.0,
|
||||
220.0, 140.0, 85.0, 70.0, 48.0, 30.0
|
||||
],
|
||||
"enthalpy": [
|
||||
380000, 395000, 410000, 415000, 430000, 450000,
|
||||
370000, 388000, 405000, 412000, 428000, 448000,
|
||||
355000, 378000, 398000, 406000, 424000, 445000,
|
||||
340000, 365000, 390000, 400000, 420000, 442000,
|
||||
320000, 350000, 378000, 392000, 415000, 438000,
|
||||
300000, 335000, 368000, 384000, 410000, 435000
|
||||
],
|
||||
"entropy": [
|
||||
1750, 1780, 1810, 1820, 1850, 1890,
|
||||
1720, 1760, 1795, 1805, 1840, 1880,
|
||||
1680, 1730, 1775, 1788, 1825, 1870,
|
||||
1630, 1695, 1750, 1765, 1810, 1860,
|
||||
1570, 1650, 1715, 1735, 1790, 1845,
|
||||
1510, 1605, 1685, 1710, 1770, 1830
|
||||
],
|
||||
"cp": [
|
||||
900, 920, 950, 960, 1000, 1050,
|
||||
880, 910, 940, 950, 990, 1040,
|
||||
850, 890, 925, 940, 980, 1030,
|
||||
820, 870, 910, 928, 970, 1020,
|
||||
790, 850, 900, 920, 965, 1015,
|
||||
765, 835, 890, 915, 962, 1010
|
||||
],
|
||||
"cv": [
|
||||
750, 770, 800, 810, 850, 900,
|
||||
730, 760, 790, 800, 840, 890,
|
||||
700, 740, 775, 790, 830, 880,
|
||||
670, 720, 760, 778, 820, 870,
|
||||
640, 700, 745, 765, 812, 862,
|
||||
615, 680, 730, 752, 805, 855
|
||||
]
|
||||
},
|
||||
"saturation": {
|
||||
"temperature": [250, 260, 270, 280, 290, 298.15, 310, 320, 330, 340, 350],
|
||||
"pressure": [164000, 232000, 320000, 430000, 565000, 666000, 890000, 1165000, 1500000, 1900000, 2370000],
|
||||
"h_liq": [200000, 215000, 230000, 245000, 260000, 272000, 288000, 305000, 322000, 340000, 358000],
|
||||
"h_vap": [395000, 402000, 408000, 413000, 417000, 420000, 423000, 425000, 426000, 427000, 427500],
|
||||
"rho_liq": [1350, 1320, 1290, 1255, 1218, 1188, 1145, 1098, 1045, 985, 915],
|
||||
"rho_vap": [8.2, 11.2, 15.0, 19.8, 25.8, 30.5, 39.5, 50.5, 64.0, 80.5, 101.0],
|
||||
"s_liq": [950, 1000, 1050, 1095, 1140, 1175, 1225, 1275, 1325, 1375, 1425],
|
||||
"s_vap": [1720, 1710, 1700, 1690, 1680, 1675, 1668, 1660, 1652, 1643, 1633
|
||||
]
|
||||
}
|
||||
}
|
||||
166
crates/fluids/src/backend.rs
Normal file
166
crates/fluids/src/backend.rs
Normal 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
235
crates/fluids/src/cache.rs
Normal 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: 1e3–1e7 Pa, T: 200–600 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);
|
||||
}
|
||||
}
|
||||
174
crates/fluids/src/cached_backend.rs
Normal file
174
crates/fluids/src/cached_backend.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
647
crates/fluids/src/coolprop.rs
Normal file
647
crates/fluids/src/coolprop.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
341
crates/fluids/src/damped_backend.rs
Normal file
341
crates/fluids/src/damped_backend.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
452
crates/fluids/src/damping.rs
Normal file
452
crates/fluids/src/damping.rs
Normal 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, ¶ms);
|
||||
assert!(damping.is_damping);
|
||||
assert!(damping.blend_factor > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_damping_state_far_from_critical() {
|
||||
let cp = make_co2_critical_point();
|
||||
let params = DampingParams::default();
|
||||
|
||||
// Room temperature, 1 bar - far from critical
|
||||
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
|
||||
let fluid = FluidId::new("CO2");
|
||||
|
||||
let damping = calculate_damping_state(&fluid, &state, &cp, ¶ms);
|
||||
assert!(!damping.is_damping);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_damping_region_boundary_smooth_transition() {
|
||||
let cp = make_co2_critical_point();
|
||||
let params = DampingParams::default();
|
||||
|
||||
// 4.9% from critical - inside region
|
||||
let t_near = 304.13 * (1.0 + 0.049);
|
||||
let p_near = 7.3773e6 * (1.0 + 0.049);
|
||||
let state_near = FluidState::from_pt(
|
||||
Pressure::from_pascals(p_near),
|
||||
Temperature::from_kelvin(t_near),
|
||||
);
|
||||
let damping_near = calculate_damping_state(&FluidId::new("CO2"), &state_near, &cp, ¶ms);
|
||||
|
||||
// 5.1% from critical - outside region
|
||||
let t_far = 304.13 * (1.0 + 0.051);
|
||||
let p_far = 7.3773e6 * (1.0 + 0.051);
|
||||
let state_far = FluidState::from_pt(
|
||||
Pressure::from_pascals(p_far),
|
||||
Temperature::from_kelvin(t_far),
|
||||
);
|
||||
let damping_far = calculate_damping_state(&FluidId::new("CO2"), &state_far, &cp, ¶ms);
|
||||
|
||||
// Should transition smoothly
|
||||
assert!(damping_near.is_damping, "4.9% should be in damping region");
|
||||
assert!(
|
||||
!damping_far.is_damping,
|
||||
"5.1% should be outside damping region"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_damping_transition_is_smooth() {
|
||||
let cp = make_co2_critical_point();
|
||||
let params = DampingParams::default();
|
||||
|
||||
// Test at various distances around the boundary
|
||||
let distances = [0.03, 0.04, 0.045, 0.05, 0.055, 0.06];
|
||||
let mut previous_blend = 1.0;
|
||||
|
||||
for d in distances {
|
||||
let t = 304.13 * (1.0 + d);
|
||||
let p = 7.3773e6 * (1.0 + d);
|
||||
let state =
|
||||
FluidState::from_pt(Pressure::from_pascals(p), Temperature::from_kelvin(t));
|
||||
let damping = calculate_damping_state(&FluidId::new("CO2"), &state, &cp, ¶ms);
|
||||
|
||||
let blend = damping.blend_factor;
|
||||
// Blend should decrease smoothly (no sudden jumps)
|
||||
assert!(
|
||||
blend <= previous_blend + 0.1,
|
||||
"Blend should decrease smoothly: prev={}, curr={}",
|
||||
previous_blend,
|
||||
blend
|
||||
);
|
||||
previous_blend = blend;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
crates/fluids/src/errors.rs
Normal file
104
crates/fluids/src/errors.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
578
crates/fluids/src/incompressible.rs
Normal file
578
crates/fluids/src/incompressible.rs
Normal 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.0–0.6 mass fraction
|
||||
/// - PropyleneGlycol with concentration 0.0–0.6 mass fraction
|
||||
/// - HumidAir
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum IncompFluid {
|
||||
/// Pure water (liquid phase)
|
||||
Water,
|
||||
/// Ethylene glycol aqueous solution, concentration = mass fraction (0.0–0.6)
|
||||
EthyleneGlycol(f64),
|
||||
/// Propylene glycol aqueous solution, concentration = mass fraction (0.0–0.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 273–373 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 0–100°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
69
crates/fluids/src/lib.rs
Normal 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};
|
||||
357
crates/fluids/src/mixture.rs
Normal file
357
crates/fluids/src/mixture.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
273
crates/fluids/src/tabular/generator.rs
Normal file
273
crates/fluids/src/tabular/generator.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
152
crates/fluids/src/tabular/interpolate.rs
Normal file
152
crates/fluids/src/tabular/interpolate.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
11
crates/fluids/src/tabular/mod.rs
Normal file
11
crates/fluids/src/tabular/mod.rs
Normal 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};
|
||||
286
crates/fluids/src/tabular/table.rs
Normal file
286
crates/fluids/src/tabular/table.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
543
crates/fluids/src/tabular_backend.rs
Normal file
543
crates/fluids/src/tabular_backend.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
430
crates/fluids/src/test_backend.rs
Normal file
430
crates/fluids/src/test_backend.rs
Normal 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
369
crates/fluids/src/types.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user