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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -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");
}

View File

@@ -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.

View File

@@ -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
]
}
}
}

View File

@@ -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(),
})
}
}

View File

@@ -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);
}
}

View File

@@ -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))

View File

@@ -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};

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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]

View File

@@ -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·(T273.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}",

View File

@@ -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 {