Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,39 +1,131 @@
|
||||
//! Build script for coolprop-sys.
|
||||
//!
|
||||
//! This compiles the CoolProp C++ library statically.
|
||||
//! Supports macOS, Linux, and Windows.
|
||||
//! Links the CoolProp C++ static library. Order of preference (prudent):
|
||||
//! 1. Prebuilt static library under `vendor/coolprop/install_root/` (Windows MSVC).
|
||||
//! 2. Compile from `vendor/coolprop` sources via CMake (slower; can fail on
|
||||
//! header generation / Python pickle issues on some corporate Windows images).
|
||||
//! 3. System library fallback.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{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")
|
||||
.canonicalize()
|
||||
.unwrap_or(PathBuf::from("../../../vendor/coolprop")),
|
||||
// External directory
|
||||
PathBuf::from("external/coolprop"),
|
||||
// System paths (Unix)
|
||||
PathBuf::from("/usr/local/src/CoolProp"),
|
||||
PathBuf::from("/opt/CoolProp"),
|
||||
fn coolprop_vendor_root() -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
PathBuf::from("../../vendor/coolprop"),
|
||||
PathBuf::from("../../../vendor/coolprop"),
|
||||
PathBuf::from("vendor/coolprop"),
|
||||
];
|
||||
candidates.into_iter().find_map(|p| {
|
||||
p.canonicalize().ok().filter(|abs| abs.join("CMakeLists.txt").exists())
|
||||
})
|
||||
}
|
||||
|
||||
possible_paths
|
||||
.into_iter()
|
||||
.find(|path| path.join("CMakeLists.txt").exists())
|
||||
/// Prefer a prebuilt CoolProp.lib so we do not re-run the fragile
|
||||
/// `generate_headers` CMake custom step on every clean build.
|
||||
fn find_prebuilt_static_lib(vendor: &Path) -> Option<PathBuf> {
|
||||
let root = vendor.join("install_root").join("static_library");
|
||||
if !root.is_dir() {
|
||||
return None;
|
||||
}
|
||||
// Walk shallow: Windows/64bit_MSVC_*/CoolProp.lib
|
||||
let mut matches = Vec::new();
|
||||
if let Ok(os_dirs) = std::fs::read_dir(&root) {
|
||||
for os_entry in os_dirs.flatten() {
|
||||
let os_path = os_entry.path();
|
||||
if !os_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(toolchain_dirs) = std::fs::read_dir(&os_path) {
|
||||
for tc in toolchain_dirs.flatten() {
|
||||
let lib = tc.path().join("CoolProp.lib");
|
||||
if lib.is_file() {
|
||||
matches.push(lib);
|
||||
}
|
||||
// Also accept libCoolProp.a naming on non-MSVC trees
|
||||
let lib_a = tc.path().join("libCoolProp.a");
|
||||
if lib_a.is_file() {
|
||||
matches.push(lib_a);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Direct CoolProp.lib under OS folder
|
||||
let direct = os_path.join("CoolProp.lib");
|
||||
if direct.is_file() {
|
||||
matches.push(direct);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prefer MSVC 64-bit paths when several exist
|
||||
matches.sort_by_key(|p| {
|
||||
let s = p.to_string_lossy().to_lowercase();
|
||||
let score = if s.contains("64bit") || s.contains("x64") {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(score, s)
|
||||
});
|
||||
matches.into_iter().next()
|
||||
}
|
||||
|
||||
fn link_system_libs(target_os: &str) {
|
||||
match target_os {
|
||||
"macos" => {
|
||||
println!("cargo:rustc-link-lib=dylib=c++");
|
||||
}
|
||||
"linux" | "freebsd" | "openbsd" | "netbsd" => {
|
||||
println!("cargo:rustc-link-lib=dylib=stdc++");
|
||||
}
|
||||
"windows" => {
|
||||
// MSVC links the C++ runtime automatically.
|
||||
}
|
||||
_ => {
|
||||
println!("cargo:rustc-link-lib=dylib=stdc++");
|
||||
}
|
||||
}
|
||||
if target_os != "windows" {
|
||||
println!("cargo:rustc-link-lib=dylib=m");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-env-changed=COOLPROP_PYTHON_EXECUTABLE");
|
||||
println!("cargo:rerun-if-env-changed=ENTROPYK_FORCE_COOLPROP_SOURCE_BUILD");
|
||||
|
||||
// Check if CoolProp source is available
|
||||
if let Some(coolprop_path) = coolprop_src_path() {
|
||||
println!("cargo:rerun-if-changed={}", coolprop_path.display());
|
||||
let force_source = env::var("ENTROPYK_FORCE_COOLPROP_SOURCE_BUILD")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Build CoolProp using CMake (always Release to match Rust's CRT)
|
||||
let mut config = cmake::Config::new(&coolprop_path);
|
||||
if let Some(vendor) = coolprop_vendor_root() {
|
||||
println!("cargo:rerun-if-changed={}", vendor.display());
|
||||
|
||||
if !force_source {
|
||||
if let Some(prebuilt) = find_prebuilt_static_lib(&vendor) {
|
||||
let dir = prebuilt.parent().expect("CoolProp.lib has a parent dir");
|
||||
println!(
|
||||
"cargo:warning=coolprop-sys: linking prebuilt static library at {}",
|
||||
prebuilt.display()
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
println!("cargo:rustc-link-lib=static=CoolProp");
|
||||
link_system_libs(&target_os);
|
||||
if target_os == "macos" {
|
||||
println!(
|
||||
"cargo:rustc-link-arg=-Wl,-force_load,{}",
|
||||
prebuilt.display()
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Source build via CMake (Release CRT matches Rust on Windows).
|
||||
let mut config = cmake::Config::new(&vendor);
|
||||
if let Ok(py) = env::var("COOLPROP_PYTHON_EXECUTABLE") {
|
||||
config.define("Python_EXECUTABLE", &py);
|
||||
}
|
||||
config
|
||||
.define("COOLPROP_SHARED_LIBRARY", "OFF")
|
||||
.define("COOLPROP_STATIC_LIBRARY", "ON")
|
||||
@@ -44,18 +136,21 @@ fn main() {
|
||||
let dst = config.build();
|
||||
|
||||
println!("cargo:rustc-link-search=native={}/build", dst.display());
|
||||
println!("cargo:rustc-link-search=native={}/build/Debug", dst.display());
|
||||
println!("cargo:rustc-link-search=native={}/build/Release", dst.display());
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}/build/Debug",
|
||||
dst.display()
|
||||
);
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}/build/Release",
|
||||
dst.display()
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={}/lib", dst.display());
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}/build",
|
||||
coolprop_path.display()
|
||||
); // Fallback
|
||||
|
||||
// Link against CoolProp statically (always Release build, no 'd' suffix)
|
||||
vendor.display()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=static=CoolProp");
|
||||
|
||||
// On macOS, force load the static library so its symbols are exported in the final cdylib
|
||||
if target_os == "macos" {
|
||||
println!(
|
||||
"cargo:rustc-link-arg=-Wl,-force_load,{}/build/libCoolProp.a",
|
||||
@@ -68,46 +163,16 @@ fn main() {
|
||||
For full static build, run: \
|
||||
git clone https://github.com/CoolProp/CoolProp.git vendor/coolprop"
|
||||
);
|
||||
// Fallback for system library
|
||||
if target_os == "windows" {
|
||||
// On Windows, try to find CoolProp as a system library
|
||||
println!("cargo:rustc-link-lib=CoolProp");
|
||||
} else {
|
||||
println!("cargo:rustc-link-lib=static=CoolProp");
|
||||
}
|
||||
}
|
||||
|
||||
// Link required system libraries for C++ standard library
|
||||
match target_os.as_str() {
|
||||
"macos" => {
|
||||
println!("cargo:rustc-link-lib=dylib=c++");
|
||||
}
|
||||
"linux" | "freebsd" | "openbsd" | "netbsd" => {
|
||||
println!("cargo:rustc-link-lib=dylib=stdc++");
|
||||
}
|
||||
"windows" => {
|
||||
// MSVC links the C++ runtime automatically; nothing to do.
|
||||
// For MinGW, stdc++ is needed but MinGW is less common.
|
||||
}
|
||||
_ => {
|
||||
// Best guess for unknown Unix-like targets
|
||||
println!("cargo:rustc-link-lib=dylib=stdc++");
|
||||
}
|
||||
}
|
||||
link_system_libs(&target_os);
|
||||
|
||||
// Link libm (only on Unix; on Windows it's part of the CRT)
|
||||
if target_os != "windows" {
|
||||
println!("cargo:rustc-link-lib=dylib=m");
|
||||
}
|
||||
|
||||
// Force export symbols for Python extension (macOS only)
|
||||
if target_os == "macos" {
|
||||
println!("cargo:rustc-link-arg=-Wl,-all_load");
|
||||
}
|
||||
// Linux equivalent (only for shared library builds, e.g., Python wheels)
|
||||
// Note: --whole-archive must bracket the static lib; the linker handles this
|
||||
// automatically for Rust cdylib targets, so we don't need it here.
|
||||
|
||||
// Tell Cargo to rerun if build.rs changes
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ pub enum CoolPropInputPair {
|
||||
extern "C" {
|
||||
/// Get a property value using pressure and temperature
|
||||
#[cfg_attr(target_os = "macos", link_name = "\x01__Z7PropsSIPKcS0_dS0_dS0_")]
|
||||
#[cfg_attr(all(not(target_os = "macos"), not(target_os = "windows")), link_name = "_Z7PropsSIPKcS0_dS0_dS0_")]
|
||||
#[cfg_attr(
|
||||
all(not(target_os = "macos"), not(target_os = "windows")),
|
||||
link_name = "_Z7PropsSIPKcS0_dS0_dS0_"
|
||||
)]
|
||||
#[cfg_attr(target_os = "windows", link_name = "?PropsSI@@YANPEBD0N0N0@Z")]
|
||||
fn PropsSI(
|
||||
Output: *const c_char,
|
||||
@@ -145,14 +148,26 @@ extern "C" {
|
||||
|
||||
/// Get a property value using input pair
|
||||
#[cfg_attr(target_os = "macos", link_name = "\x01__Z8Props1SIPKcS0_")]
|
||||
#[cfg_attr(all(not(target_os = "macos"), not(target_os = "windows")), link_name = "_Z8Props1SIPKcS0_")]
|
||||
#[cfg_attr(
|
||||
all(not(target_os = "macos"), not(target_os = "windows")),
|
||||
link_name = "_Z8Props1SIPKcS0_"
|
||||
)]
|
||||
#[cfg_attr(target_os = "windows", link_name = "?Props1SI@@YANPEBD0@Z")]
|
||||
fn Props1SI(Fluid: *const c_char, Output: *const c_char) -> c_double;
|
||||
|
||||
/// Get CoolProp version string
|
||||
#[cfg_attr(target_os = "macos", link_name = "\x01__Z23get_global_param_stringPKcPci")]
|
||||
#[cfg_attr(all(not(target_os = "macos"), not(target_os = "windows")), link_name = "get_global_param_string")]
|
||||
#[cfg_attr(target_os = "windows", link_name = "?get_global_param_string@@YAJPEBDPEADH@Z")]
|
||||
#[cfg_attr(
|
||||
target_os = "macos",
|
||||
link_name = "\x01__Z23get_global_param_stringPKcPci"
|
||||
)]
|
||||
#[cfg_attr(
|
||||
all(not(target_os = "macos"), not(target_os = "windows")),
|
||||
link_name = "get_global_param_string"
|
||||
)]
|
||||
#[cfg_attr(
|
||||
target_os = "windows",
|
||||
link_name = "?get_global_param_string@@YAJPEBDPEADH@Z"
|
||||
)]
|
||||
fn get_global_param_string(
|
||||
Param: *const c_char,
|
||||
Output: *mut c_char,
|
||||
@@ -160,9 +175,18 @@ extern "C" {
|
||||
) -> c_int;
|
||||
|
||||
/// Get fluid info
|
||||
#[cfg_attr(target_os = "macos", link_name = "\x01__Z22get_fluid_param_stringPKcS0_Pci")]
|
||||
#[cfg_attr(all(not(target_os = "macos"), not(target_os = "windows")), link_name = "get_fluid_param_string")]
|
||||
#[cfg_attr(target_os = "windows", link_name = "?get_fluid_param_string@@YAJPEBD0PEADH@Z")]
|
||||
#[cfg_attr(
|
||||
target_os = "macos",
|
||||
link_name = "\x01__Z22get_fluid_param_stringPKcS0_Pci"
|
||||
)]
|
||||
#[cfg_attr(
|
||||
all(not(target_os = "macos"), not(target_os = "windows")),
|
||||
link_name = "get_fluid_param_string"
|
||||
)]
|
||||
#[cfg_attr(
|
||||
target_os = "windows",
|
||||
link_name = "?get_fluid_param_string@@YAJPEBD0PEADH@Z"
|
||||
)]
|
||||
fn get_fluid_param_string(
|
||||
Fluid: *const c_char,
|
||||
Param: *const c_char,
|
||||
@@ -194,7 +218,14 @@ pub unsafe fn props_si_pt(property: &str, p: f64, t: f64, fluid: &str) -> f64 {
|
||||
let prop_c = std::ffi::CString::new(property).unwrap();
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
PropsSI(prop_c.as_ptr(), c"P".as_ptr(), p, c"T".as_ptr(), t, fluid_c.as_ptr())
|
||||
PropsSI(
|
||||
prop_c.as_ptr(),
|
||||
c"P".as_ptr(),
|
||||
p,
|
||||
c"T".as_ptr(),
|
||||
t,
|
||||
fluid_c.as_ptr(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and enthalpy.
|
||||
@@ -213,7 +244,14 @@ pub unsafe fn props_si_ph(property: &str, p: f64, h: f64, fluid: &str) -> f64 {
|
||||
let prop_c = std::ffi::CString::new(property).unwrap();
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
PropsSI(prop_c.as_ptr(), c"P".as_ptr(), p, c"H".as_ptr(), h, fluid_c.as_ptr())
|
||||
PropsSI(
|
||||
prop_c.as_ptr(),
|
||||
c"P".as_ptr(),
|
||||
p,
|
||||
c"H".as_ptr(),
|
||||
h,
|
||||
fluid_c.as_ptr(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using temperature and quality (saturation).
|
||||
@@ -232,7 +270,14 @@ pub unsafe fn props_si_tq(property: &str, t: f64, q: f64, fluid: &str) -> f64 {
|
||||
let prop_c = std::ffi::CString::new(property).unwrap();
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
PropsSI(prop_c.as_ptr(), c"T".as_ptr(), t, c"Q".as_ptr(), q, fluid_c.as_ptr())
|
||||
PropsSI(
|
||||
prop_c.as_ptr(),
|
||||
c"T".as_ptr(),
|
||||
t,
|
||||
c"Q".as_ptr(),
|
||||
q,
|
||||
fluid_c.as_ptr(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and quality.
|
||||
@@ -261,6 +306,32 @@ pub unsafe fn props_si_px(property: &str, p: f64, x: f64, fluid: &str) -> f64 {
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a thermodynamic property using pressure and specific entropy.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `property` - The property to retrieve (e.g., "H" for enthalpy, "T" for temperature)
|
||||
/// * `p` - Pressure in Pa
|
||||
/// * `s` - Specific entropy in J/(kg·K)
|
||||
/// * `fluid` - Fluid name
|
||||
///
|
||||
/// # Returns
|
||||
/// # Safety
|
||||
/// This function calls the CoolProp C++ library and passes a CString pointer.
|
||||
/// The caller must ensure the fluid string is valid.
|
||||
pub unsafe fn props_si_ps(property: &str, p: f64, s: f64, fluid: &str) -> f64 {
|
||||
let prop_c = std::ffi::CString::new(property).unwrap();
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
|
||||
PropsSI(
|
||||
prop_c.as_ptr(),
|
||||
c"P".as_ptr(),
|
||||
p,
|
||||
c"S".as_ptr(),
|
||||
s,
|
||||
fluid_c.as_ptr(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get critical point temperature for a fluid.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -316,7 +387,11 @@ pub unsafe fn is_fluid_available(fluid: &str) -> bool {
|
||||
let fluid_c = CString::new(fluid).unwrap();
|
||||
// CoolProp C API does not expose isfluid, so we try fetching a property
|
||||
let res = Props1SI(fluid_c.as_ptr(), c"Tcrit".as_ptr());
|
||||
if res.is_finite() && res != 0.0 { true } else { false }
|
||||
if res.is_finite() && res != 0.0 {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get CoolProp version string.
|
||||
|
||||
@@ -1,63 +1,330 @@
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"critical_point": {
|
||||
"tc": 374.21,
|
||||
"pc": 4059000,
|
||||
"rho_c": 512
|
||||
"tc": 374.2119665849513,
|
||||
"pc": 4059276.3737910665,
|
||||
"rho_c": 511.9451132818318
|
||||
},
|
||||
"single_phase": {
|
||||
"pressure": [100000, 200000, 500000, 1000000, 2000000, 3000000],
|
||||
"temperature": [250, 270, 290, 298.15, 320, 350],
|
||||
"pressure": [
|
||||
100000.0,
|
||||
200000.0,
|
||||
500000.0,
|
||||
1000000.0,
|
||||
2000000.0,
|
||||
3000000.0
|
||||
],
|
||||
"temperature": [
|
||||
250.0,
|
||||
270.0,
|
||||
290.0,
|
||||
298.15,
|
||||
320.0,
|
||||
350.0
|
||||
],
|
||||
"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
|
||||
5.114431685636282,
|
||||
4.683353123118987,
|
||||
4.328850451003437,
|
||||
4.20097283251613,
|
||||
3.8955348952374025,
|
||||
3.5459112742839527,
|
||||
1368.0980186601096,
|
||||
9.681584510627323,
|
||||
8.870318857079596,
|
||||
8.585891692021239,
|
||||
7.919535033947271,
|
||||
7.174461458822227,
|
||||
1368.9481111118573,
|
||||
1306.0088778446975,
|
||||
24.162976157780722,
|
||||
23.12523142245124,
|
||||
20.893098398279882,
|
||||
18.609801305836292,
|
||||
1370.3531257311067,
|
||||
1307.8634855444054,
|
||||
1239.2653099751717,
|
||||
1208.7317786026779,
|
||||
46.78583750994063,
|
||||
39.927060181669425,
|
||||
1373.1202373257313,
|
||||
1311.4940194912294,
|
||||
1244.274720033223,
|
||||
1214.5671990152737,
|
||||
1124.3315338489504,
|
||||
97.5182863064974,
|
||||
1375.8324932762935,
|
||||
1315.0255621348276,
|
||||
1249.0857282470888,
|
||||
1220.1274540078284,
|
||||
1133.2694579667668,
|
||||
966.21788137877
|
||||
],
|
||||
"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
|
||||
385146.7442281072,
|
||||
401168.18899366795,
|
||||
417663.82994626166,
|
||||
424549.83909265586,
|
||||
443509.2502824767,
|
||||
470753.12676260195,
|
||||
169594.9938919024,
|
||||
398517.76702197525,
|
||||
415608.85754233703,
|
||||
422669.1965974061,
|
||||
441989.4831612803,
|
||||
469572.36034639826,
|
||||
169692.6269709049,
|
||||
195840.09714305282,
|
||||
408557.89501766016,
|
||||
416398.7877437356,
|
||||
437121.5720017539,
|
||||
465882.7194392182,
|
||||
169856.3553436097,
|
||||
195964.4056051839,
|
||||
223111.08469321657,
|
||||
234557.1432998888,
|
||||
427466.984333152,
|
||||
459128.1564053731,
|
||||
170187.46743238682,
|
||||
196219.98844314707,
|
||||
223240.72444308564,
|
||||
234609.6399384865,
|
||||
266523.0882465626,
|
||||
441616.9436628892,
|
||||
170523.24470211015,
|
||||
196484.32799192722,
|
||||
223388.4355671476,
|
||||
234687.43221498615,
|
||||
266279.10971085844,
|
||||
315394.0272988041
|
||||
],
|
||||
"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
|
||||
1757.7490963644016,
|
||||
1819.3914680451644,
|
||||
1878.317830317919,
|
||||
1901.7343795644872,
|
||||
1963.0883588165689,
|
||||
2044.4362467715066,
|
||||
883.9878583219048,
|
||||
1755.537568986703,
|
||||
1816.5980219342368,
|
||||
1840.6076341732528,
|
||||
1903.1321563736162,
|
||||
1985.4957284248762,
|
||||
883.501533187194,
|
||||
984.0956352064416,
|
||||
1723.4446007589722,
|
||||
1750.1103423173092,
|
||||
1817.1881501106895,
|
||||
1903.0839576556361,
|
||||
882.6962214425214,
|
||||
983.1390978214034,
|
||||
1080.1073084875245,
|
||||
1119.0300184155205,
|
||||
1737.5345394663402,
|
||||
1832.1414342791036,
|
||||
881.1046672280473,
|
||||
981.2577693225631,
|
||||
1077.777473402298,
|
||||
1116.4380023806525,
|
||||
1219.6895289194333,
|
||||
1736.223986983503,
|
||||
879.5375856147664,
|
||||
979.4165832179034,
|
||||
1075.5208869715025,
|
||||
1113.943778631082,
|
||||
1216.1588058362904,
|
||||
1362.640947006448
|
||||
],
|
||||
"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
|
||||
793.6475837638649,
|
||||
811.4355017558754,
|
||||
838.8763122284527,
|
||||
850.9977938926509,
|
||||
884.6085526145774,
|
||||
931.7141545972718,
|
||||
1286.1942705749414,
|
||||
850.4356050828194,
|
||||
862.0477722535695,
|
||||
870.7664816283624,
|
||||
898.4352814099967,
|
||||
940.9319749818374,
|
||||
1285.2480600912713,
|
||||
1331.4240291898557,
|
||||
972.2837105787893,
|
||||
954.4095771156854,
|
||||
948.5466459484555,
|
||||
971.8606122691408,
|
||||
1283.7020930246838,
|
||||
1328.9275894211523,
|
||||
1389.0522679582934,
|
||||
1420.7164740970513,
|
||||
1091.6339073809727,
|
||||
1039.224266101138,
|
||||
1280.7212755498388,
|
||||
1324.1744579006815,
|
||||
1380.7667637704023,
|
||||
1409.9158653138315,
|
||||
1522.1471095047507,
|
||||
1339.1441870920023,
|
||||
1277.8794818366716,
|
||||
1319.7143514975455,
|
||||
1373.1954812103772,
|
||||
1400.206958049531,
|
||||
1500.0116655500663,
|
||||
1866.2100579420141
|
||||
],
|
||||
"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
|
||||
690.0975695908081,
|
||||
715.5137791020643,
|
||||
746.9613484621274,
|
||||
760.2423075531158,
|
||||
796.171301857269,
|
||||
845.3043343246896,
|
||||
851.4915421808691,
|
||||
735.0640640573855,
|
||||
757.6959266037023,
|
||||
769.2393850817974,
|
||||
802.2684806084416,
|
||||
849.2304516025284,
|
||||
851.5074032359738,
|
||||
875.1594288026905,
|
||||
806.8727303718707,
|
||||
805.2234963726562,
|
||||
822.8033865327284,
|
||||
861.6158754183845,
|
||||
851.5378621864114,
|
||||
875.0875811276736,
|
||||
900.5187090213045,
|
||||
911.6044949863759,
|
||||
873.271853414874,
|
||||
885.1647302099409,
|
||||
851.6130874992002,
|
||||
874.9722549065135,
|
||||
900.0545710947936,
|
||||
910.9111391095264,
|
||||
942.8427033318274,
|
||||
958.2321167408633,
|
||||
851.7060485746197,
|
||||
874.8914386387426,
|
||||
899.6656637889242,
|
||||
910.3260145878637,
|
||||
941.265858969436,
|
||||
997.9560276129291
|
||||
]
|
||||
},
|
||||
"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
|
||||
"temperature": [
|
||||
250.0,
|
||||
259.09090909090907,
|
||||
268.1818181818182,
|
||||
277.27272727272725,
|
||||
286.3636363636364,
|
||||
295.45454545454544,
|
||||
304.54545454545456,
|
||||
313.6363636363636,
|
||||
322.72727272727275,
|
||||
331.8181818181818,
|
||||
340.9090909090909,
|
||||
350.0
|
||||
],
|
||||
"pressure": [
|
||||
115612.22881910387,
|
||||
170404.22835582937,
|
||||
243635.16960420858,
|
||||
339117.76129784196,
|
||||
460960.1989193689,
|
||||
613549.5951025893,
|
||||
801549.5157416787,
|
||||
1029918.2645986993,
|
||||
1303958.154143472,
|
||||
1629414.2447535396,
|
||||
2012660.0075577358,
|
||||
2461054.5532331755
|
||||
],
|
||||
"h_liq": [
|
||||
169567.6133778951,
|
||||
181367.38018747047,
|
||||
193358.49995547015,
|
||||
205562.3070620737,
|
||||
218004.8063253546,
|
||||
230718.34382316252,
|
||||
243744.1085651622,
|
||||
257136.0856447984,
|
||||
270967.7150057756,
|
||||
285344.1264767935,
|
||||
300427.555829844,
|
||||
316499.8724738989
|
||||
],
|
||||
"h_vap": [
|
||||
384601.3551812879,
|
||||
390202.5665842718,
|
||||
395677.68325284147,
|
||||
400989.77726941387,
|
||||
406097.4625785895,
|
||||
410951.8510416422,
|
||||
415492.13291552424,
|
||||
419638.7736000683,
|
||||
423282.2016218858,
|
||||
426262.22005323495,
|
||||
428326.1347709371,
|
||||
429029.6000445165
|
||||
],
|
||||
"rho_liq": [
|
||||
1367.8579215745046,
|
||||
1339.8990239265972,
|
||||
1311.0147435057756,
|
||||
1281.0294639731596,
|
||||
1249.7248418245406,
|
||||
1216.8238638163666,
|
||||
1181.9666884550452,
|
||||
1144.6723783612288,
|
||||
1104.2743594361552,
|
||||
1059.8010214455235,
|
||||
1009.7233743625571,
|
||||
951.3190116021533
|
||||
],
|
||||
"rho_vap": [
|
||||
5.954551937553797,
|
||||
8.597009069706335,
|
||||
12.091090616973307,
|
||||
16.62949990332455,
|
||||
22.442955857362893,
|
||||
29.814548085338558,
|
||||
39.10279306483501,
|
||||
50.779933267109946,
|
||||
65.49895027775776,
|
||||
84.21970998454984,
|
||||
108.47186731477426,
|
||||
140.99042590517433
|
||||
],
|
||||
"s_liq": [
|
||||
884.1250881026259,
|
||||
930.3251816203352,
|
||||
975.601544298256,
|
||||
1020.0806152274379,
|
||||
1063.8912160884101,
|
||||
1107.1697083583092,
|
||||
1150.066981530715,
|
||||
1192.758863758459,
|
||||
1235.4631464072384,
|
||||
1278.4705768229642,
|
||||
1322.2094803393145,
|
||||
1367.4062346980663
|
||||
],
|
||||
"s_vap": [
|
||||
1744.2600553161974,
|
||||
1736.3557256079882,
|
||||
1730.012058288454,
|
||||
1724.9009995817787,
|
||||
1720.7227141155788,
|
||||
1717.1908097131634,
|
||||
1714.0157182035457,
|
||||
1710.883376079609,
|
||||
1707.4235274712412,
|
||||
1703.1552423958015,
|
||||
1697.3786452331876,
|
||||
1688.9197420426879
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,4 +168,37 @@ pub trait FluidBackend: Send + Sync {
|
||||
.iter()
|
||||
.all(|c| self.is_fluid_available(&FluidId::new(c)))
|
||||
}
|
||||
|
||||
/// Get the saturation pressure for a pure refrigerant at a given temperature.
|
||||
///
|
||||
/// Equivalent to `PropsSI("P", "T", t_k, "Q", 0, fluid)`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - The fluid identifier (e.g., "R410A")
|
||||
/// * `t_k` - Saturation temperature in Kelvin
|
||||
///
|
||||
/// # Returns
|
||||
/// Saturation pressure in Pa, or an error if not supported by this backend.
|
||||
fn saturation_pressure_t(&self, _fluid: FluidId, _t_k: f64) -> FluidResult<f64> {
|
||||
Err(crate::errors::FluidError::UnsupportedProperty {
|
||||
property: "saturation_pressure_t (T,Q query) not supported by this backend".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the specific enthalpy for a pure refrigerant at a given temperature and quality.
|
||||
///
|
||||
/// Equivalent to `PropsSI("H", "T", t_k, "Q", quality, fluid)`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `fluid` - The fluid identifier
|
||||
/// * `t_k` - Temperature in Kelvin
|
||||
/// * `quality` - Vapor quality (0.0 = saturated liquid, 1.0 = saturated vapor)
|
||||
///
|
||||
/// # Returns
|
||||
/// Specific enthalpy in J/kg, or an error if not supported by this backend.
|
||||
fn saturation_enthalpy_t(&self, _fluid: FluidId, _t_k: f64, _quality: f64) -> FluidResult<f64> {
|
||||
Err(crate::errors::FluidError::UnsupportedProperty {
|
||||
property: "saturation_enthalpy_t (T,Q query) not supported by this backend".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::damped_backend::DampedBackend;
|
||||
use crate::errors::{FluidError, FluidResult};
|
||||
use crate::types::{CriticalPoint, FluidId, FluidState, Phase, Property};
|
||||
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::mixture::Mixture;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::backend::FluidBackend;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::mixture::Mixture;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use std::collections::HashMap;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use std::sync::RwLock;
|
||||
@@ -284,7 +284,6 @@ impl CoolPropBackend {
|
||||
Ok(Phase::TwoPhase)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[cfg(feature = "coolprop")]
|
||||
@@ -323,12 +322,15 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
&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::PressureEntropy(p, s) => unsafe {
|
||||
// CoolProp supports PropsSI(output, "P", p, "S", s, fluid)
|
||||
coolprop::props_si_ps(
|
||||
prop_code,
|
||||
p.to_pascals(),
|
||||
s.to_joules_per_kg_kelvin(),
|
||||
&coolprop_fluid,
|
||||
)
|
||||
},
|
||||
FluidState::PressureQuality(p, q) => unsafe {
|
||||
coolprop::props_si_px(prop_code, p.to_pascals(), q.value(), &coolprop_fluid)
|
||||
},
|
||||
@@ -389,22 +391,69 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
return self.phase_mix(fluid, state);
|
||||
}
|
||||
|
||||
let quality = self.property(fluid.clone(), Property::Quality, state)?;
|
||||
let coolprop_fluid = self.fluid_name(&fluid);
|
||||
|
||||
if quality < 0.0 {
|
||||
// Below saturated liquid - likely subcooled liquid
|
||||
// Pressure is the first field of every non-mixture state variant.
|
||||
let p_pa = match &state {
|
||||
FluidState::PressureTemperature(p, _)
|
||||
| FluidState::PressureEnthalpy(p, _)
|
||||
| FluidState::PressureEntropy(p, _)
|
||||
| FluidState::PressureQuality(p, _) => p.to_pascals(),
|
||||
_ => unreachable!("mixture states handled above"),
|
||||
};
|
||||
|
||||
// CoolProp reports a quality in [0, 1] only inside the two-phase dome;
|
||||
// outside it returns a sentinel (typically -1) for BOTH subcooled liquid
|
||||
// and superheated vapor, so quality alone cannot classify single-phase states.
|
||||
let quality = self.property(fluid.clone(), Property::Quality, state.clone())?;
|
||||
|
||||
if (0.0..=1.0).contains(&quality) {
|
||||
if (quality - 0.0).abs() < 1e-6 {
|
||||
return Ok(Phase::Liquid);
|
||||
} else if (quality - 1.0).abs() < 1e-6 {
|
||||
return Ok(Phase::Vapor);
|
||||
}
|
||||
return Ok(Phase::TwoPhase);
|
||||
}
|
||||
|
||||
// Single-phase (or undefined quality): classify by temperature relative to
|
||||
// the saturation temperatures at this pressure, with a supercritical guard.
|
||||
let t_k = self.property(fluid.clone(), Property::Temperature, state)?;
|
||||
|
||||
let critical = self.critical_point(fluid.clone()).ok();
|
||||
if let Some(cp) = critical {
|
||||
if p_pa >= cp.pressure.to_pascals() && t_k >= cp.temperature.to_kelvin() {
|
||||
return Ok(Phase::Supercritical);
|
||||
}
|
||||
}
|
||||
|
||||
let (t_bubble, t_dew) = unsafe {
|
||||
(
|
||||
coolprop::props_si_px("T", p_pa, 0.0, &coolprop_fluid),
|
||||
coolprop::props_si_px("T", p_pa, 1.0, &coolprop_fluid),
|
||||
)
|
||||
};
|
||||
|
||||
// Saturation undefined (e.g. pressure at/above the critical pressure):
|
||||
// split by critical temperature when available, otherwise report Unknown.
|
||||
if t_bubble.is_nan() || t_dew.is_nan() {
|
||||
if let Some(cp) = critical {
|
||||
if p_pa >= cp.pressure.to_pascals() {
|
||||
return Ok(if t_k >= cp.temperature.to_kelvin() {
|
||||
Phase::Supercritical
|
||||
} else {
|
||||
Phase::Liquid
|
||||
});
|
||||
}
|
||||
}
|
||||
return Ok(Phase::Unknown);
|
||||
}
|
||||
|
||||
if t_k <= t_bubble {
|
||||
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
|
||||
} else if t_k >= t_dew {
|
||||
Ok(Phase::Vapor)
|
||||
} else {
|
||||
// Two-phase region
|
||||
Ok(Phase::TwoPhase)
|
||||
}
|
||||
}
|
||||
@@ -429,8 +478,8 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
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);
|
||||
// For bubble point (saturated liquid), use a (P, Q=0) flash.
|
||||
let t = coolprop::props_si_px("T", p_pa, 0.0, &cp_string);
|
||||
if t.is_nan() {
|
||||
return Err(FluidError::NumericalError(
|
||||
"CoolProp returned NaN for bubble point calculation".to_string(),
|
||||
@@ -456,8 +505,8 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
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);
|
||||
// For dew point (saturated vapor), use a (P, Q=1) flash.
|
||||
let t = coolprop::props_si_px("T", p_pa, 1.0, &cp_string);
|
||||
if t.is_nan() {
|
||||
return Err(FluidError::NumericalError(
|
||||
"CoolProp returned NaN for dew point calculation".to_string(),
|
||||
@@ -557,6 +606,40 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn saturation_pressure_t(&self, fluid: FluidId, t_k: f64) -> FluidResult<f64> {
|
||||
if !self.is_fluid_available(&fluid) {
|
||||
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
|
||||
}
|
||||
let coolprop_fluid = self.fluid_name(&fluid);
|
||||
let result = unsafe { coolprop::props_si_tq("P", t_k, 0.0, &coolprop_fluid) };
|
||||
if result.is_nan() {
|
||||
return Err(FluidError::InvalidState {
|
||||
reason: format!(
|
||||
"CoolProp returned NaN for P_sat at T={:.2}K for {}",
|
||||
t_k, fluid
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn saturation_enthalpy_t(&self, fluid: FluidId, t_k: f64, quality: f64) -> FluidResult<f64> {
|
||||
if !self.is_fluid_available(&fluid) {
|
||||
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
|
||||
}
|
||||
let coolprop_fluid = self.fluid_name(&fluid);
|
||||
let result = unsafe { coolprop::props_si_tq("H", t_k, quality, &coolprop_fluid) };
|
||||
if result.is_nan() {
|
||||
return Err(FluidError::InvalidState {
|
||||
reason: format!(
|
||||
"CoolProp returned NaN for H_sat at T={:.2}K, Q={} for {}",
|
||||
t_k, quality, fluid
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// A placeholder backend when CoolProp is not available.
|
||||
@@ -633,8 +716,22 @@ mod tests {
|
||||
#[cfg(feature = "coolprop")]
|
||||
use crate::mixture::Mixture;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use approx::assert_abs_diff_eq;
|
||||
#[cfg(feature = "coolprop")]
|
||||
use entropyk_core::{Pressure, Temperature};
|
||||
|
||||
#[cfg(feature = "coolprop")]
|
||||
const R454B_REFERENCE_PRESSURE_PA: f64 = 1e6;
|
||||
#[cfg(feature = "coolprop")]
|
||||
const R454B_REFERENCE_BUBBLE_POINT_K: f64 = 288.145_565_440_567_5;
|
||||
#[cfg(feature = "coolprop")]
|
||||
const R454B_REFERENCE_DEW_POINT_K: f64 = 295.328_418_405_157_1;
|
||||
#[cfg(feature = "coolprop")]
|
||||
const R454B_REFERENCE_GLIDE_K: f64 =
|
||||
R454B_REFERENCE_DEW_POINT_K - R454B_REFERENCE_BUBBLE_POINT_K;
|
||||
#[cfg(feature = "coolprop")]
|
||||
const R454B_REFERENCE_TOLERANCE_K: f64 = 0.5;
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "coolprop")]
|
||||
fn test_backend_creation() {
|
||||
@@ -690,12 +787,14 @@ mod tests {
|
||||
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 pressure = Pressure::from_pascals(R454B_REFERENCE_PRESSURE_PA);
|
||||
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);
|
||||
assert_abs_diff_eq!(
|
||||
t_bubble.to_kelvin(),
|
||||
R454B_REFERENCE_BUBBLE_POINT_K,
|
||||
epsilon = R454B_REFERENCE_TOLERANCE_K
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -704,12 +803,14 @@ mod tests {
|
||||
let backend = CoolPropBackend::new();
|
||||
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
||||
|
||||
let pressure = Pressure::from_pascals(1e6);
|
||||
let pressure = Pressure::from_pascals(R454B_REFERENCE_PRESSURE_PA);
|
||||
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());
|
||||
assert_abs_diff_eq!(
|
||||
t_dew.to_kelvin(),
|
||||
R454B_REFERENCE_DEW_POINT_K,
|
||||
epsilon = R454B_REFERENCE_TOLERANCE_K
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -718,10 +819,14 @@ mod tests {
|
||||
let backend = CoolPropBackend::new();
|
||||
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
||||
|
||||
let pressure = Pressure::from_pascals(1e6);
|
||||
let pressure = Pressure::from_pascals(R454B_REFERENCE_PRESSURE_PA);
|
||||
let glide = backend.temperature_glide(pressure, &mixture).unwrap();
|
||||
|
||||
// Temperature glide should be > 0 for zeotropic mixtures (typically 5-15K)
|
||||
assert_abs_diff_eq!(
|
||||
glide,
|
||||
R454B_REFERENCE_GLIDE_K,
|
||||
epsilon = R454B_REFERENCE_TOLERANCE_K
|
||||
);
|
||||
assert!(
|
||||
glide > 0.0,
|
||||
"Expected positive temperature glide for zeotropic mixture"
|
||||
@@ -778,4 +883,24 @@ mod tests {
|
||||
assert!(state.t_dew.is_some());
|
||||
assert!(state.t_bubble.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "coolprop")]
|
||||
fn test_phase_superheated_vapor_r134a() {
|
||||
let backend = CoolPropBackend::new();
|
||||
// R134a at 1 bar, 50°C is well into the superheated vapor region.
|
||||
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(50.0));
|
||||
let phase = backend.phase(FluidId::new("R134a"), state).unwrap();
|
||||
assert_eq!(phase, Phase::Vapor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "coolprop")]
|
||||
fn test_phase_subcooled_liquid_r134a() {
|
||||
let backend = CoolPropBackend::new();
|
||||
// R134a at 10 bar saturates near 39°C; 10°C is subcooled liquid.
|
||||
let state = FluidState::from_pt(Pressure::from_bar(10.0), Temperature::from_celsius(10.0));
|
||||
let phase = backend.phase(FluidId::new("R134a"), state).unwrap();
|
||||
assert_eq!(phase, Phase::Liquid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +110,13 @@ impl DllBackend {
|
||||
|
||||
// SAFETY: Loading a shared library is inherently unsafe — the library
|
||||
// must be a valid CoolProp-compatible binary for the current platform.
|
||||
let lib = unsafe { Library::new(path) }.map_err(|e| FluidError::CoolPropError(
|
||||
format!("Failed to load shared library '{}': {}", path.display(), e),
|
||||
))?;
|
||||
let lib = unsafe { Library::new(path) }.map_err(|e| {
|
||||
FluidError::CoolPropError(format!(
|
||||
"Failed to load shared library '{}': {}",
|
||||
path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Load PropsSI symbol
|
||||
let props_si: PropsSiFn = unsafe {
|
||||
@@ -286,19 +290,19 @@ impl DllBackend {
|
||||
}
|
||||
|
||||
impl FluidBackend for DllBackend {
|
||||
fn property(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
property: Property,
|
||||
state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
|
||||
let prop_code = Self::property_code(property);
|
||||
let fluid_name = &fluid.0;
|
||||
|
||||
match state {
|
||||
FluidState::PressureTemperature(p, t) => {
|
||||
self.call_props_si(prop_code, "P", p.to_pascals(), "T", t.to_kelvin(), fluid_name)
|
||||
}
|
||||
FluidState::PressureTemperature(p, t) => self.call_props_si(
|
||||
prop_code,
|
||||
"P",
|
||||
p.to_pascals(),
|
||||
"T",
|
||||
t.to_kelvin(),
|
||||
fluid_name,
|
||||
),
|
||||
FluidState::PressureEnthalpy(p, h) => self.call_props_si(
|
||||
prop_code,
|
||||
"P",
|
||||
@@ -316,7 +320,14 @@ impl FluidBackend for DllBackend {
|
||||
// Mixture states: build CoolProp mixture string
|
||||
FluidState::PressureTemperatureMixture(p, t, ref mix) => {
|
||||
let cp_string = mix.to_coolprop_string();
|
||||
self.call_props_si(prop_code, "P", p.to_pascals(), "T", t.to_kelvin(), &cp_string)
|
||||
self.call_props_si(
|
||||
prop_code,
|
||||
"P",
|
||||
p.to_pascals(),
|
||||
"T",
|
||||
t.to_kelvin(),
|
||||
&cp_string,
|
||||
)
|
||||
}
|
||||
FluidState::PressureEnthalpyMixture(p, h, ref mix) => {
|
||||
let cp_string = mix.to_coolprop_string();
|
||||
@@ -373,8 +384,24 @@ impl FluidBackend for DllBackend {
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
// Common refrigerants — we check availability dynamically
|
||||
let candidates = [
|
||||
"R134a", "R410A", "R32", "R1234yf", "R1234ze(E)", "R454B", "R513A", "R290", "R744",
|
||||
"R717", "Water", "Air", "CO2", "Ammonia", "Propane", "R404A", "R407C", "R22",
|
||||
"R134a",
|
||||
"R410A",
|
||||
"R32",
|
||||
"R1234yf",
|
||||
"R1234ze(E)",
|
||||
"R454B",
|
||||
"R513A",
|
||||
"R290",
|
||||
"R744",
|
||||
"R717",
|
||||
"Water",
|
||||
"Air",
|
||||
"CO2",
|
||||
"Ammonia",
|
||||
"Propane",
|
||||
"R404A",
|
||||
"R407C",
|
||||
"R22",
|
||||
];
|
||||
|
||||
candidates
|
||||
@@ -402,10 +429,7 @@ impl FluidBackend for DllBackend {
|
||||
.call_props_si("Q", "P", p_pa, "H", h_j_kg, name)
|
||||
.unwrap_or(f64::NAN);
|
||||
|
||||
let phase = self.phase(
|
||||
fluid.clone(),
|
||||
FluidState::from_ph(p, h),
|
||||
)?;
|
||||
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))
|
||||
|
||||
@@ -62,9 +62,9 @@ pub use backend::FluidBackend;
|
||||
pub use cached_backend::CachedBackend;
|
||||
pub use coolprop::CoolPropBackend;
|
||||
pub use damped_backend::DampedBackend;
|
||||
pub use damping::{DampingParams, DampingState};
|
||||
#[cfg(feature = "dll")]
|
||||
pub use dll_backend::DllBackend;
|
||||
pub use damping::{DampingParams, DampingState};
|
||||
pub use errors::{FluidError, FluidResult};
|
||||
pub use incompressible::{IncompFluid, IncompressibleBackend, ValidRange};
|
||||
pub use mixture::{Mixture, MixtureError};
|
||||
|
||||
@@ -1,11 +1,178 @@
|
||||
//! Bilinear interpolation for 2D property tables.
|
||||
//! Interpolation for 2D property tables.
|
||||
//!
|
||||
//! Provides C1-continuous interpolation suitable for solver Jacobian assembly.
|
||||
//! Provides both bilinear (C0) and bicubic-Hermite (C1) interpolation.
|
||||
//!
|
||||
//! [`bicubic_interpolate`] is the preferred entry point: it is C1-continuous
|
||||
//! (continuous value AND first derivative across cell boundaries), which yields
|
||||
//! smoother property derivatives (e.g. finite-difference cp) and a
|
||||
//! better-conditioned solver Jacobian. It uses centered-difference tangents at
|
||||
//! grid nodes (a non-uniform Catmull-Rom / cubic-Hermite scheme) and degrades
|
||||
//! gracefully to one-sided tangents at the grid edges. On a 2-point grid it
|
||||
//! reduces exactly to linear interpolation, so bilinear behavior is preserved
|
||||
//! in the degenerate case.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// Resolves the base cell index for a query `x` in an ascending `grid`.
|
||||
///
|
||||
/// Returns the index `i` such that `grid[i] <= x <= grid[i+1]`, or `None` if
|
||||
/// `x` is outside the grid or the grid is degenerate.
|
||||
#[inline]
|
||||
fn locate_cell(grid: &[f64], x: f64) -> Option<usize> {
|
||||
let n = grid.len();
|
||||
if n < 2 || !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match grid.binary_search_by(|v| v.partial_cmp(&x).unwrap_or(Ordering::Equal)) {
|
||||
Ok(i) => {
|
||||
if i >= n - 1 {
|
||||
// Exactly on the last node: use the last cell.
|
||||
Some(n - 2)
|
||||
} else {
|
||||
Some(i)
|
||||
}
|
||||
}
|
||||
Err(i) => {
|
||||
if i == 0 || i >= n {
|
||||
None
|
||||
} else {
|
||||
Some(i - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates a 1D cubic Hermite segment on `[x0, x1]` using secant tangents.
|
||||
///
|
||||
/// `xm1`/`vm1` and `x2`/`v2` are the outer stencil points used to build
|
||||
/// centered-difference tangents at `x0` and `x1`. When a neighbor is absent
|
||||
/// (`has_left`/`has_right` false), a one-sided secant tangent is used, which
|
||||
/// makes the scheme reduce to linear interpolation when both are absent.
|
||||
#[inline]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cubic_hermite_1d(
|
||||
xm1: f64,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
x2: f64,
|
||||
vm1: f64,
|
||||
v0: f64,
|
||||
v1: f64,
|
||||
v2: f64,
|
||||
x: f64,
|
||||
has_left: bool,
|
||||
has_right: bool,
|
||||
) -> f64 {
|
||||
let h = x1 - x0;
|
||||
if h <= 0.0 {
|
||||
return v0;
|
||||
}
|
||||
let s = ((x - x0) / h).clamp(0.0, 1.0);
|
||||
let secant = (v1 - v0) / h;
|
||||
let m0 = if has_left && (x1 - xm1) > 0.0 {
|
||||
(v1 - vm1) / (x1 - xm1)
|
||||
} else {
|
||||
secant
|
||||
};
|
||||
let m1 = if has_right && (x2 - x0) > 0.0 {
|
||||
(v2 - v0) / (x2 - x0)
|
||||
} else {
|
||||
secant
|
||||
};
|
||||
let s2 = s * s;
|
||||
let s3 = s2 * s;
|
||||
let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
|
||||
let h10 = s3 - 2.0 * s2 + s;
|
||||
let h01 = -2.0 * s3 + 3.0 * s2;
|
||||
let h11 = s3 - s2;
|
||||
h00 * v0 + h10 * h * m0 + h01 * v1 + h11 * h * m1
|
||||
}
|
||||
|
||||
/// Performs bicubic-Hermite interpolation on a 2D grid.
|
||||
///
|
||||
/// C1-continuous drop-in replacement for [`bilinear_interpolate`] with the same
|
||||
/// signature and bounds semantics. Uses a 4x4 stencil around the query cell
|
||||
/// (clamped at the boundaries) and centered-difference tangents. Reduces to
|
||||
/// linear interpolation on 2-point grids.
|
||||
///
|
||||
/// # 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 bicubic_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;
|
||||
}
|
||||
|
||||
let p_idx = locate_cell(p_grid, p)?;
|
||||
let t_idx = locate_cell(t_grid, t)?;
|
||||
|
||||
// Pressure-direction stencil indices (clamped at boundaries).
|
||||
let p_has_left = p_idx >= 1;
|
||||
let p_has_right = p_idx + 2 <= n_p - 1;
|
||||
let pim1 = if p_has_left { p_idx - 1 } else { p_idx };
|
||||
let pi2 = if p_has_right { p_idx + 2 } else { p_idx + 1 };
|
||||
let p_stencil = [pim1, p_idx, p_idx + 1, pi2];
|
||||
|
||||
// Temperature-direction stencil indices (clamped at boundaries).
|
||||
let t_has_left = t_idx >= 1;
|
||||
let t_has_right = t_idx + 2 <= n_t - 1;
|
||||
let tim1 = if t_has_left { t_idx - 1 } else { t_idx };
|
||||
let ti2 = if t_has_right { t_idx + 2 } else { t_idx + 1 };
|
||||
|
||||
// Interpolate along temperature for each of the four pressure rows.
|
||||
let mut rows = [0.0f64; 4];
|
||||
for (k, &pi) in p_stencil.iter().enumerate() {
|
||||
let base = pi * n_t;
|
||||
rows[k] = cubic_hermite_1d(
|
||||
t_grid[tim1],
|
||||
t_grid[t_idx],
|
||||
t_grid[t_idx + 1],
|
||||
t_grid[ti2],
|
||||
values[base + tim1],
|
||||
values[base + t_idx],
|
||||
values[base + t_idx + 1],
|
||||
values[base + ti2],
|
||||
t,
|
||||
t_has_left,
|
||||
t_has_right,
|
||||
);
|
||||
}
|
||||
|
||||
// Interpolate the four intermediate results along pressure.
|
||||
Some(cubic_hermite_1d(
|
||||
p_grid[pim1],
|
||||
p_grid[p_idx],
|
||||
p_grid[p_idx + 1],
|
||||
p_grid[pi2],
|
||||
rows[0],
|
||||
rows[1],
|
||||
rows[2],
|
||||
rows[3],
|
||||
p,
|
||||
p_has_left,
|
||||
p_has_right,
|
||||
))
|
||||
}
|
||||
|
||||
/// Performs bilinear interpolation on a 2D grid.
|
||||
///
|
||||
/// C0-continuous (value-continuous only). Prefer [`bicubic_interpolate`] for
|
||||
/// solver use where a smooth first derivative matters. Kept for the degenerate
|
||||
/// 2-point case and as a robust fallback.
|
||||
///
|
||||
/// 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.
|
||||
@@ -17,6 +184,7 @@ use std::cmp::Ordering;
|
||||
/// * `p` - Query pressure (Pa)
|
||||
/// * `t` - Query temperature (K)
|
||||
#[inline]
|
||||
#[allow(dead_code)] // Retained as a robust C0 fallback; used by tests.
|
||||
pub fn bilinear_interpolate(
|
||||
p_grid: &[f64],
|
||||
t_grid: &[f64],
|
||||
@@ -149,4 +317,75 @@ mod tests {
|
||||
assert!(bilinear_interpolate(&p, &t, &v, 150000.0, f64::NAN).is_none());
|
||||
assert!(bilinear_interpolate(&p, &t, &v, f64::INFINITY, 300.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bicubic_at_grid_nodes_is_exact() {
|
||||
// Bicubic must reproduce node values exactly.
|
||||
let p = [1.0, 2.0, 3.0, 4.0];
|
||||
let t = [10.0, 20.0, 30.0, 40.0];
|
||||
let mut v = vec![0.0; 16];
|
||||
for (i, &pi) in p.iter().enumerate() {
|
||||
for (j, &tj) in t.iter().enumerate() {
|
||||
v[i * 4 + j] = pi * pi + 0.5 * tj - 3.0 * pi * tj;
|
||||
}
|
||||
}
|
||||
for (i, &pi) in p.iter().enumerate() {
|
||||
for (j, &tj) in t.iter().enumerate() {
|
||||
let got = bicubic_interpolate(&p, &t, &v, pi, tj).unwrap();
|
||||
assert!(
|
||||
(got - v[i * 4 + j]).abs() < 1e-9,
|
||||
"node ({pi},{tj}) got {got} expected {}",
|
||||
v[i * 4 + j]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bicubic_reduces_to_linear_on_2pt_grid() {
|
||||
// On a 2x2 grid there are no interior neighbors, so bicubic must match
|
||||
// bilinear exactly.
|
||||
let p = [0.0, 1.0];
|
||||
let t = [0.0, 1.0];
|
||||
let v = [0.0, 1.0, 1.0, 2.0];
|
||||
for &(qp, qt) in &[(0.25, 0.75), (0.5, 0.5), (0.1, 0.9)] {
|
||||
let lin = bilinear_interpolate(&p, &t, &v, qp, qt).unwrap();
|
||||
let cub = bicubic_interpolate(&p, &t, &v, qp, qt).unwrap();
|
||||
assert!((lin - cub).abs() < 1e-12, "({qp},{qt}) lin={lin} cub={cub}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bicubic_more_accurate_on_cubic_function() {
|
||||
// For a smooth cubic surface, bicubic-Hermite is far more accurate than
|
||||
// bilinear at a cell interior with genuine neighbors.
|
||||
let f = |p: f64, t: f64| p * p * p + 2.0 * t * t * t - p * t;
|
||||
let p: Vec<f64> = (0..6).map(|i| i as f64).collect();
|
||||
let t: Vec<f64> = (0..6).map(|j| j as f64).collect();
|
||||
let mut v = vec![0.0; 36];
|
||||
for (i, &pi) in p.iter().enumerate() {
|
||||
for (j, &tj) in t.iter().enumerate() {
|
||||
v[i * 6 + j] = f(pi, tj);
|
||||
}
|
||||
}
|
||||
let (qp, qt) = (2.5, 3.5);
|
||||
let exact = f(qp, qt);
|
||||
let e_lin = (bilinear_interpolate(&p, &t, &v, qp, qt).unwrap() - exact).abs();
|
||||
let e_cub = (bicubic_interpolate(&p, &t, &v, qp, qt).unwrap() - exact).abs();
|
||||
assert!(
|
||||
e_cub < e_lin * 0.2,
|
||||
"bicubic error {e_cub} not << bilinear error {e_lin}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bicubic_out_of_bounds_and_nan() {
|
||||
let p = [1.0, 2.0, 3.0];
|
||||
let t = [1.0, 2.0, 3.0];
|
||||
let v: Vec<f64> = (0..9).map(|x| x as f64).collect();
|
||||
assert!(bicubic_interpolate(&p, &t, &v, 0.5, 2.0).is_none());
|
||||
assert!(bicubic_interpolate(&p, &t, &v, 4.0, 2.0).is_none());
|
||||
assert!(bicubic_interpolate(&p, &t, &v, f64::NAN, 2.0).is_none());
|
||||
assert!(bicubic_interpolate(&p, &t, &v, 2.0, f64::INFINITY).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use super::interpolate::bilinear_interpolate;
|
||||
use super::interpolate::bicubic_interpolate;
|
||||
|
||||
/// Critical point data stored in table metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -50,7 +50,7 @@ impl SinglePhaseTable {
|
||||
property: property_name.to_string(),
|
||||
})?;
|
||||
|
||||
bilinear_interpolate(&self.pressure, &self.temperature, values, p, t).ok_or(
|
||||
bicubic_interpolate(&self.pressure, &self.temperature, values, p, t).ok_or(
|
||||
FluidError::OutOfBounds {
|
||||
fluid: fluid_name.to_string(),
|
||||
p,
|
||||
|
||||
@@ -264,6 +264,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Accuracy: at grid point (200 kPa, 290 K), density must match table exactly.
|
||||
/// Value is the CoolProp-generated grid entry in data/r134a.json.
|
||||
#[test]
|
||||
fn test_tabular_accuracy_at_grid_point() {
|
||||
let backend = make_test_backend();
|
||||
@@ -274,7 +275,7 @@ mod tests {
|
||||
let density = backend
|
||||
.property(FluidId::new("R134a"), Property::Density, state)
|
||||
.unwrap();
|
||||
assert_relative_eq!(density, 9.0, epsilon = 1e-10);
|
||||
assert_relative_eq!(density, 8.870318857079596, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
/// Accuracy: interpolated value within 1% (table self-consistency check).
|
||||
@@ -288,7 +289,7 @@ mod tests {
|
||||
let density = backend
|
||||
.property(FluidId::new("R134a"), Property::Density, state)
|
||||
.unwrap();
|
||||
assert_relative_eq!(density, 8.415, epsilon = 0.01);
|
||||
assert_relative_eq!(density, 8.53, epsilon = 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -120,16 +120,86 @@ impl TestBackend {
|
||||
let r134a_sat = SatTable {
|
||||
fluid: "R134a".to_string(),
|
||||
points: vec![
|
||||
SatPoint { t_celsius: -10.0, p_bar: 2.013, hf_kjkg: 186.7, hg_kjkg: 392.7, rho_f: 1295.0, rho_g: 10.2 },
|
||||
SatPoint { t_celsius: 0.0, p_bar: 2.928, hf_kjkg: 200.0, hg_kjkg: 398.6, rho_f: 1295.0, rho_g: 14.4 },
|
||||
SatPoint { t_celsius: 7.0, p_bar: 3.748, hf_kjkg: 209.1, hg_kjkg: 402.4, rho_f: 1262.0, rho_g: 18.2 },
|
||||
SatPoint { t_celsius: 10.0, p_bar: 4.150, hf_kjkg: 213.0, hg_kjkg: 404.0, rho_f: 1251.0, rho_g: 20.2 },
|
||||
SatPoint { t_celsius: 20.0, p_bar: 5.719, hf_kjkg: 227.5, hg_kjkg: 409.4, rho_f: 1226.0, rho_g: 27.8 },
|
||||
SatPoint { t_celsius: 25.0, p_bar: 6.658, hf_kjkg: 234.6, hg_kjkg: 412.0, rho_f: 1207.0, rho_g: 32.3 },
|
||||
SatPoint { t_celsius: 35.0, p_bar: 8.875, hf_kjkg: 249.0, hg_kjkg: 414.4, rho_f: 1168.0, rho_g: 43.1 },
|
||||
SatPoint { t_celsius: 40.0, p_bar: 10.170, hf_kjkg: 256.4, hg_kjkg: 419.4, rho_f: 1148.0, rho_g: 50.8 },
|
||||
SatPoint { t_celsius: 45.0, p_bar: 11.597, hf_kjkg: 263.7, hg_kjkg: 420.6, rho_f: 1129.0, rho_g: 58.9 },
|
||||
SatPoint { t_celsius: 50.0, p_bar: 13.180, hf_kjkg: 271.4, hg_kjkg: 421.2, rho_f: 1102.0, rho_g: 68.2 },
|
||||
SatPoint {
|
||||
t_celsius: -10.0,
|
||||
p_bar: 2.013,
|
||||
hf_kjkg: 186.7,
|
||||
hg_kjkg: 392.7,
|
||||
rho_f: 1295.0,
|
||||
rho_g: 10.2,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 0.0,
|
||||
p_bar: 2.928,
|
||||
hf_kjkg: 200.0,
|
||||
hg_kjkg: 398.6,
|
||||
rho_f: 1295.0,
|
||||
rho_g: 14.4,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 7.0,
|
||||
p_bar: 3.748,
|
||||
hf_kjkg: 209.1,
|
||||
hg_kjkg: 402.4,
|
||||
rho_f: 1262.0,
|
||||
rho_g: 18.2,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 10.0,
|
||||
p_bar: 4.150,
|
||||
hf_kjkg: 213.0,
|
||||
hg_kjkg: 404.0,
|
||||
rho_f: 1251.0,
|
||||
rho_g: 20.2,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 20.0,
|
||||
p_bar: 5.719,
|
||||
hf_kjkg: 227.5,
|
||||
hg_kjkg: 409.4,
|
||||
rho_f: 1226.0,
|
||||
rho_g: 27.8,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 25.0,
|
||||
p_bar: 6.658,
|
||||
hf_kjkg: 234.6,
|
||||
hg_kjkg: 412.0,
|
||||
rho_f: 1207.0,
|
||||
rho_g: 32.3,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 35.0,
|
||||
p_bar: 8.875,
|
||||
hf_kjkg: 249.0,
|
||||
hg_kjkg: 414.4,
|
||||
rho_f: 1168.0,
|
||||
rho_g: 43.1,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 40.0,
|
||||
p_bar: 10.170,
|
||||
hf_kjkg: 256.4,
|
||||
hg_kjkg: 419.4,
|
||||
rho_f: 1148.0,
|
||||
rho_g: 50.8,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 45.0,
|
||||
p_bar: 11.597,
|
||||
hf_kjkg: 263.7,
|
||||
hg_kjkg: 420.6,
|
||||
rho_f: 1129.0,
|
||||
rho_g: 58.9,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 50.0,
|
||||
p_bar: 13.180,
|
||||
hf_kjkg: 271.4,
|
||||
hg_kjkg: 421.2,
|
||||
rho_f: 1102.0,
|
||||
rho_g: 68.2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -138,15 +208,78 @@ impl TestBackend {
|
||||
let r410a_sat = SatTable {
|
||||
fluid: "R410A".to_string(),
|
||||
points: vec![
|
||||
SatPoint { t_celsius: -30.0, p_bar: 2.34, hf_kjkg: 156.0, hg_kjkg: 422.0, rho_f: 1140.0, rho_g: 14.0 },
|
||||
SatPoint { t_celsius: -20.0, p_bar: 4.01, hf_kjkg: 175.0, hg_kjkg: 427.0, rho_f: 1113.0, rho_g: 23.0 },
|
||||
SatPoint { t_celsius: -10.0, p_bar: 5.85, hf_kjkg: 178.0, hg_kjkg: 428.0, rho_f: 1100.0, rho_g: 30.0 },
|
||||
SatPoint { t_celsius: 0.0, p_bar: 7.97, hf_kjkg: 192.0, hg_kjkg: 432.0, rho_f: 1080.0, rho_g: 40.0 },
|
||||
SatPoint { t_celsius: 10.0, p_bar: 10.82, hf_kjkg: 207.0, hg_kjkg: 436.0, rho_f: 1050.0, rho_g: 50.0 },
|
||||
SatPoint { t_celsius: 20.0, p_bar: 14.48, hf_kjkg: 225.0, hg_kjkg: 436.0, rho_f: 1020.0, rho_g: 65.0 },
|
||||
SatPoint { t_celsius: 30.0, p_bar: 18.95, hf_kjkg: 245.0, hg_kjkg: 434.0, rho_f: 985.0, rho_g: 82.0 },
|
||||
SatPoint { t_celsius: 40.0, p_bar: 24.27, hf_kjkg: 268.0, hg_kjkg: 432.0, rho_f: 950.0, rho_g: 100.0 },
|
||||
SatPoint { t_celsius: 50.0, p_bar: 30.47, hf_kjkg: 290.0, hg_kjkg: 427.0, rho_f: 900.0, rho_g: 130.0 },
|
||||
SatPoint {
|
||||
t_celsius: -30.0,
|
||||
p_bar: 2.34,
|
||||
hf_kjkg: 156.0,
|
||||
hg_kjkg: 422.0,
|
||||
rho_f: 1140.0,
|
||||
rho_g: 14.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: -20.0,
|
||||
p_bar: 4.01,
|
||||
hf_kjkg: 175.0,
|
||||
hg_kjkg: 427.0,
|
||||
rho_f: 1113.0,
|
||||
rho_g: 23.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: -10.0,
|
||||
p_bar: 5.85,
|
||||
hf_kjkg: 178.0,
|
||||
hg_kjkg: 428.0,
|
||||
rho_f: 1100.0,
|
||||
rho_g: 30.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 0.0,
|
||||
p_bar: 7.97,
|
||||
hf_kjkg: 192.0,
|
||||
hg_kjkg: 432.0,
|
||||
rho_f: 1080.0,
|
||||
rho_g: 40.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 10.0,
|
||||
p_bar: 10.82,
|
||||
hf_kjkg: 207.0,
|
||||
hg_kjkg: 436.0,
|
||||
rho_f: 1050.0,
|
||||
rho_g: 50.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 20.0,
|
||||
p_bar: 14.48,
|
||||
hf_kjkg: 225.0,
|
||||
hg_kjkg: 436.0,
|
||||
rho_f: 1020.0,
|
||||
rho_g: 65.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 30.0,
|
||||
p_bar: 18.95,
|
||||
hf_kjkg: 245.0,
|
||||
hg_kjkg: 434.0,
|
||||
rho_f: 985.0,
|
||||
rho_g: 82.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 40.0,
|
||||
p_bar: 24.27,
|
||||
hf_kjkg: 268.0,
|
||||
hg_kjkg: 432.0,
|
||||
rho_f: 950.0,
|
||||
rho_g: 100.0,
|
||||
},
|
||||
SatPoint {
|
||||
t_celsius: 50.0,
|
||||
p_bar: 30.47,
|
||||
hf_kjkg: 290.0,
|
||||
hg_kjkg: 427.0,
|
||||
rho_f: 900.0,
|
||||
rho_g: 130.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -189,16 +322,19 @@ impl TestBackend {
|
||||
|
||||
/// Property from (P, quality) for any fluid with a saturation table.
|
||||
fn property_px(&self, fluid: &str, property: Property, p_pa: f64, x: f64) -> FluidResult<f64> {
|
||||
let (t_sat, hf, hg, rho_f, rho_g) = self
|
||||
.sat_at_p(fluid, p_pa)
|
||||
.ok_or(FluidError::InvalidState {
|
||||
reason: format!("{} pressure {:.2} bar outside TestBackend table range", fluid, p_pa / 1e5),
|
||||
let (t_sat, hf, hg, rho_f, rho_g) =
|
||||
self.sat_at_p(fluid, p_pa).ok_or(FluidError::InvalidState {
|
||||
reason: format!(
|
||||
"{} pressure {:.2} bar outside TestBackend table range",
|
||||
fluid,
|
||||
p_pa / 1e5
|
||||
),
|
||||
})?;
|
||||
|
||||
let h = hf + x * (hg - hf); // kJ/kg
|
||||
match property {
|
||||
Property::Enthalpy => Ok(h * 1000.0), // J/kg
|
||||
Property::Temperature => Ok(t_sat + 273.15), // K
|
||||
Property::Enthalpy => Ok(h * 1000.0), // J/kg
|
||||
Property::Temperature => Ok(t_sat + 273.15), // K
|
||||
Property::Density => {
|
||||
let vf = 1.0 / rho_f;
|
||||
let vg = 1.0 / rho_g;
|
||||
@@ -207,6 +343,9 @@ impl TestBackend {
|
||||
}
|
||||
Property::Pressure => Ok(p_pa),
|
||||
Property::Cp => Ok(1500.0),
|
||||
// Order-of-magnitude R134a-like transport for two-phase ΔP unit tests.
|
||||
Property::Viscosity => Ok(if x <= 0.5 { 2.0e-4 } else { 1.2e-5 }),
|
||||
Property::SurfaceTension => Ok(0.008),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: property.to_string(),
|
||||
}),
|
||||
@@ -214,11 +353,20 @@ impl TestBackend {
|
||||
}
|
||||
|
||||
/// Property from (P, h) for any fluid with a saturation table.
|
||||
fn property_ph(&self, fluid: &str, property: Property, p_pa: f64, h_jkg: f64) -> FluidResult<f64> {
|
||||
let (t_sat, hf, hg, rho_f, rho_g) = self
|
||||
.sat_at_p(fluid, p_pa)
|
||||
.ok_or(FluidError::InvalidState {
|
||||
reason: format!("{} pressure {:.2} bar outside TestBackend table range", fluid, p_pa / 1e5),
|
||||
fn property_ph(
|
||||
&self,
|
||||
fluid: &str,
|
||||
property: Property,
|
||||
p_pa: f64,
|
||||
h_jkg: f64,
|
||||
) -> FluidResult<f64> {
|
||||
let (t_sat, hf, hg, rho_f, rho_g) =
|
||||
self.sat_at_p(fluid, p_pa).ok_or(FluidError::InvalidState {
|
||||
reason: format!(
|
||||
"{} pressure {:.2} bar outside TestBackend table range",
|
||||
fluid,
|
||||
p_pa / 1e5
|
||||
),
|
||||
})?;
|
||||
|
||||
let h_kjkg = h_jkg / 1000.0;
|
||||
@@ -264,6 +412,23 @@ impl TestBackend {
|
||||
Property::Enthalpy => Ok(h_jkg),
|
||||
Property::Pressure => Ok(p_pa),
|
||||
Property::Cp => Ok(1500.0),
|
||||
// Smooth, invertible entropy surrogate for the (P,h)→S→(P,S) isentropic
|
||||
// path used by the compressor: s = h/300 − 50·ln(P/1e5).
|
||||
Property::Entropy => Ok(h_jkg / 300.0 - 50.0 * (p_pa / 1e5).ln()),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: property.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of the [`property_ph`] entropy surrogate: recovers enthalpy from
|
||||
/// (P, S) so the isentropic compression path is consistent and smooth.
|
||||
/// h = 300·(s + 50·ln(P/1e5)).
|
||||
fn property_ps(&self, _fluid: &str, property: Property, p_pa: f64, s: f64) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Enthalpy => Ok(300.0 * (s + 50.0 * (p_pa / 1e5).ln())),
|
||||
Property::Pressure => Ok(p_pa),
|
||||
Property::Entropy => Ok(s),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: property.to_string(),
|
||||
}),
|
||||
@@ -324,17 +489,25 @@ impl TestBackend {
|
||||
}
|
||||
|
||||
fn water_property(&self, property: Property, state: FluidState) -> FluidResult<f64> {
|
||||
// Liquid-water idealization (Cp ≈ const) for unit tests without CoolProp.
|
||||
// Supports P-T and P-h so four-port secondary edges can query T(P,h)/Cp.
|
||||
let (p, t) = match state {
|
||||
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
|
||||
FluidState::PressureEnthalpy(p, h) => {
|
||||
let p_pa = p.to_pascals();
|
||||
// h ≈ 4200·(T−273.15) ⇒ T ≈ 273.15 + h/4200
|
||||
let t_k = 273.15 + h.to_joules_per_kg() / 4200.0;
|
||||
(p_pa, t_k)
|
||||
}
|
||||
_ => {
|
||||
return Err(FluidError::InvalidState {
|
||||
reason: "TestBackend only supports P-T state for water".to_string(),
|
||||
reason: "TestBackend water supports P-T and P-h liquid states only".to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Simplified water properties at ~1 atm
|
||||
if p < 1.1e5 && t > 273.15 && t < 373.15 {
|
||||
// Simplified liquid water near ambient pressure
|
||||
if p > 0.0 && p < 5.0e5 && 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
|
||||
@@ -368,6 +541,9 @@ impl TestBackend {
|
||||
FluidState::PressureEnthalpy(p, h) => {
|
||||
return self.property_ph(fluid, property, p.to_pascals(), h.to_joules_per_kg());
|
||||
}
|
||||
FluidState::PressureEntropy(p, s) => {
|
||||
return self.property_ps(fluid, property, p.to_pascals(), s.0);
|
||||
}
|
||||
_ => {} // fall through to P-T handling below
|
||||
}
|
||||
|
||||
@@ -376,9 +552,9 @@ impl TestBackend {
|
||||
_ => {
|
||||
return Err(FluidError::InvalidState {
|
||||
reason: format!(
|
||||
"TestBackend only supports P-T state for {} (P-x and P-h available for R134a)",
|
||||
fluid
|
||||
),
|
||||
"TestBackend only supports P-T state for {} (P-x and P-h available for R134a)",
|
||||
fluid
|
||||
),
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -634,11 +810,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_r134a_sat_enthalpy_quality_0_at_0c() {
|
||||
let backend = TestBackend::new();
|
||||
let state = FluidState::from_px(
|
||||
Pressure::from_bar(2.928),
|
||||
Quality(0.0),
|
||||
);
|
||||
let h = backend.property(FluidId::new("R134a"), Property::Enthalpy, state).unwrap();
|
||||
let state = FluidState::from_px(Pressure::from_bar(2.928), Quality(0.0));
|
||||
let h = backend
|
||||
.property(FluidId::new("R134a"), Property::Enthalpy, state)
|
||||
.unwrap();
|
||||
// h_f at 0°C = 200 kJ/kg = 200000 J/kg
|
||||
assert!(
|
||||
(h - 200_000.0).abs() < 500.0,
|
||||
@@ -650,11 +825,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_r134a_sat_enthalpy_quality_1_at_0c() {
|
||||
let backend = TestBackend::new();
|
||||
let state = FluidState::from_px(
|
||||
Pressure::from_bar(2.928),
|
||||
Quality(1.0),
|
||||
);
|
||||
let h = backend.property(FluidId::new("R134a"), Property::Enthalpy, state).unwrap();
|
||||
let state = FluidState::from_px(Pressure::from_bar(2.928), Quality(1.0));
|
||||
let h = backend
|
||||
.property(FluidId::new("R134a"), Property::Enthalpy, state)
|
||||
.unwrap();
|
||||
// h_g at 0°C = 398.6 kJ/kg = 398600 J/kg
|
||||
assert!(
|
||||
(h - 398_600.0).abs() < 500.0,
|
||||
@@ -673,7 +847,9 @@ mod tests {
|
||||
Pressure::from_bar(2.928),
|
||||
Enthalpy::from_kilojoules_per_kg(256.4),
|
||||
);
|
||||
let x = backend.property(FluidId::new("R134a"), Property::Quality, state).unwrap();
|
||||
let x = backend
|
||||
.property(FluidId::new("R134a"), Property::Quality, state)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(x - 0.284).abs() < 0.01,
|
||||
"quality after isenthalpic expansion: expected ~0.284, got {:.4}",
|
||||
@@ -690,7 +866,9 @@ mod tests {
|
||||
Pressure::from_bar(10.17),
|
||||
Enthalpy::from_kilojoules_per_kg(350.0), // mid two-phase
|
||||
);
|
||||
let t = backend.property(FluidId::new("R134a"), Property::Temperature, state).unwrap();
|
||||
let t = backend
|
||||
.property(FluidId::new("R134a"), Property::Temperature, state)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(t - 313.15).abs() < 1.0,
|
||||
"T_sat at 10.17 bar: expected ~313.15 K, got {:.2} K",
|
||||
@@ -703,11 +881,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_r134a_density_twophase() {
|
||||
let backend = TestBackend::new();
|
||||
let state = FluidState::from_px(
|
||||
Pressure::from_bar(2.928),
|
||||
Quality(0.5),
|
||||
);
|
||||
let rho = backend.property(FluidId::new("R134a"), Property::Density, state).unwrap();
|
||||
let state = FluidState::from_px(Pressure::from_bar(2.928), Quality(0.5));
|
||||
let rho = backend
|
||||
.property(FluidId::new("R134a"), Property::Density, state)
|
||||
.unwrap();
|
||||
// rho_f=1295, rho_g=14.4 at 0°C. At x=0.5, should be much closer to rho_g
|
||||
assert!(
|
||||
rho > 14.4 && rho < 1295.0,
|
||||
@@ -721,11 +898,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_r134a_sat_20c_liquid() {
|
||||
let backend = TestBackend::new();
|
||||
let state = FluidState::from_px(
|
||||
Pressure::from_bar(5.719),
|
||||
Quality(0.0),
|
||||
);
|
||||
let h = backend.property(FluidId::new("R134a"), Property::Enthalpy, state).unwrap();
|
||||
let state = FluidState::from_px(Pressure::from_bar(5.719), Quality(0.0));
|
||||
let h = backend
|
||||
.property(FluidId::new("R134a"), Property::Enthalpy, state)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(h - 227_500.0).abs() < 500.0,
|
||||
"h_f at 20°C: expected ~227500 J/kg, got {:.0}",
|
||||
|
||||
@@ -31,7 +31,9 @@ impl From<f64> for TemperatureDelta {
|
||||
}
|
||||
|
||||
/// Unique identifier for a fluid (e.g., "R410A", "Water", "Air").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
pub struct FluidId(pub String);
|
||||
|
||||
impl FluidId {
|
||||
|
||||
Reference in New Issue
Block a user