Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,7 +16,9 @@ fn coolprop_vendor_root() -> Option<PathBuf> {
|
||||
PathBuf::from("vendor/coolprop"),
|
||||
];
|
||||
candidates.into_iter().find_map(|p| {
|
||||
p.canonicalize().ok().filter(|abs| abs.join("CMakeLists.txt").exists())
|
||||
p.canonicalize()
|
||||
.ok()
|
||||
.filter(|abs| abs.join("CMakeLists.txt").exists())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,10 +147,7 @@ fn main() {
|
||||
dst.display()
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={}/lib", dst.display());
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}/build",
|
||||
vendor.display()
|
||||
);
|
||||
println!("cargo:rustc-link-search=native={}/build", vendor.display());
|
||||
println!("cargo:rustc-link-lib=static=CoolProp");
|
||||
|
||||
if target_os == "macos" {
|
||||
|
||||
@@ -387,11 +387,7 @@ 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
|
||||
}
|
||||
res.is_finite() && res != 0.0
|
||||
}
|
||||
|
||||
/// Get CoolProp version string.
|
||||
|
||||
@@ -25,6 +25,12 @@ const DEFAULT_CAP_NONZERO: NonZeroUsize = NonZeroUsize::new(DEFAULT_CACHE_CAPACI
|
||||
|
||||
/// Quantization factor: values rounded to 1e-9 relative.
|
||||
/// (v * 1e9).round() as i64 for Hash-compatible key.
|
||||
///
|
||||
/// Overflow safety (Story 0.7, 2026-07-19 finite-poisoning incident): for
|
||||
/// |v| > 9e9 the product `v * 1e9` exceeds `i64::MAX` and wraps to garbage,
|
||||
/// making extreme solver trial states (blown iterates, h ~ 1e13) collide
|
||||
/// with legitimate keys. Those states fall back to exact bit hashing — they
|
||||
/// do not need near-state hits, they need to never collide.
|
||||
#[inline]
|
||||
fn quantize(v: f64) -> i64 {
|
||||
if v.is_nan() {
|
||||
@@ -35,6 +41,9 @@ fn quantize(v: f64) -> i64 {
|
||||
#[cfg(debug_assertions)]
|
||||
eprintln!("[WARN] quantize: Infinite value encountered, mapping to 0");
|
||||
0
|
||||
} else if v.abs() > 9.0e9 {
|
||||
// Exact, sign-masked bit pattern (no quantization) for huge values.
|
||||
(v.to_bits() & 0x7fff_ffff_ffff_ffff) as i64
|
||||
} else {
|
||||
(v * 1e9).round() as i64
|
||||
}
|
||||
@@ -240,6 +249,24 @@ mod tests {
|
||||
cache_resize(NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity is non-zero"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quantize_extreme_values_do_not_collide_with_normal_keys() {
|
||||
// 2026-07-19 incident: `v * 1e9` overflows i64 for |v| > ~9e9, wrapping
|
||||
// blown-up trial states onto legitimate keys (finite poisoning).
|
||||
let normal_a = quantize(100_000.0);
|
||||
let normal_b = quantize(420_000.0);
|
||||
for extreme in [1.0e13, -1.0e13, 3.7e15, f64::MAX / 2.0] {
|
||||
let q = quantize(extreme);
|
||||
assert_ne!(q, normal_a, "extreme {extreme} collides with 1e5");
|
||||
assert_ne!(q, normal_b, "extreme {extreme} collides with 4.2e5");
|
||||
}
|
||||
// Distinct extremes get distinct keys (exact bit hashing).
|
||||
assert_ne!(quantize(1.0e13), quantize(1.0e13 * 1.000001));
|
||||
// Normal quantization unchanged.
|
||||
assert_eq!(quantize(1.5), quantize(1.5));
|
||||
assert_ne!(quantize(1.5), quantize(1.5 + 1e-8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_key_different_backends() {
|
||||
let fluid = FluidId::new("R134a");
|
||||
|
||||
@@ -70,7 +70,13 @@ impl<B: FluidBackend> FluidBackend for CachedBackend<B> {
|
||||
let v = self
|
||||
.inner
|
||||
.property(fluid.clone(), property, state.clone())?;
|
||||
cache_insert(self.backend_id, &fluid, property, &state, v);
|
||||
// SAFETY (Story 0.7): never cache non-finite values. A transient
|
||||
// line-search probe can legitimately return inf (a domain edge); if
|
||||
// cached, it would later be re-served at a quantized-near state where
|
||||
// the true value is finite — poisoning the solve (2026-07-19 incident).
|
||||
if v.is_finite() {
|
||||
cache_insert(self.backend_id, &fluid, property, &state, v);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -98,6 +104,33 @@ impl<B: FluidBackend> FluidBackend for CachedBackend<B> {
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
self.inner.full_state(fluid, p, h)
|
||||
}
|
||||
|
||||
// Saturation/mixture helpers MUST delegate too: the trait's default impls
|
||||
// return `UnsupportedProperty`, which silently degrades seeds through the
|
||||
// wrapper (2026-07-19 seed-fallback incident).
|
||||
fn saturation_pressure_t(&self, fluid: FluidId, t_k: f64) -> FluidResult<f64> {
|
||||
self.inner.saturation_pressure_t(fluid, t_k)
|
||||
}
|
||||
|
||||
fn saturation_enthalpy_t(&self, fluid: FluidId, t_k: f64, quality: f64) -> FluidResult<f64> {
|
||||
self.inner.saturation_enthalpy_t(fluid, t_k, quality)
|
||||
}
|
||||
|
||||
fn bubble_point(
|
||||
&self,
|
||||
pressure: entropyk_core::Pressure,
|
||||
mixture: &crate::mixture::Mixture,
|
||||
) -> FluidResult<entropyk_core::Temperature> {
|
||||
self.inner.bubble_point(pressure, mixture)
|
||||
}
|
||||
|
||||
fn dew_point(
|
||||
&self,
|
||||
pressure: entropyk_core::Pressure,
|
||||
mixture: &crate::mixture::Mixture,
|
||||
) -> FluidResult<entropyk_core::Temperature> {
|
||||
self.inner.dew_point(pressure, mixture)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -178,4 +211,99 @@ mod tests {
|
||||
let fluids = cached.list_fluids();
|
||||
assert!(!fluids.is_empty());
|
||||
}
|
||||
|
||||
/// Backend returning `Ok(inf)` at one scripted state and a finite value
|
||||
/// elsewhere — reproduces the 2026-07-19 cache-poisoning incident.
|
||||
struct InfAtExtremeBackend {
|
||||
calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl FluidBackend for InfAtExtremeBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_property: Property,
|
||||
state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
self.calls
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if let FluidState::PressureEnthalpy(_, h) = state {
|
||||
if h.to_joules_per_kg() > 1.0e12 {
|
||||
// Transient probe at a blown-up iterate: inf, as CoolProp
|
||||
// would return at a domain edge.
|
||||
return Ok(f64::INFINITY);
|
||||
}
|
||||
}
|
||||
Ok(4186.0)
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(crate::errors::FluidError::NoCriticalPoint { fluid: fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<crate::types::Phase> {
|
||||
Ok(crate::types::Phase::Liquid)
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![FluidId::new("Water")]
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
_p: entropyk_core::Pressure,
|
||||
_h: entropyk_core::Enthalpy,
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
Err(crate::errors::FluidError::UnsupportedProperty {
|
||||
property: format!("full_state for {}", fluid.0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_finite_values_are_never_cached() {
|
||||
let inner = InfAtExtremeBackend {
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
};
|
||||
let cached = CachedBackend::new(inner);
|
||||
|
||||
let extreme = FluidState::from_ph(
|
||||
Pressure::from_pascals(2.0e5),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(1.0e13),
|
||||
);
|
||||
let inf = cached
|
||||
.property(FluidId::new("Water"), Property::Cp, extreme)
|
||||
.unwrap();
|
||||
assert!(inf.is_infinite(), "setup: backend must return inf");
|
||||
|
||||
// Legitimate query at a normal state afterwards: must NOT see the
|
||||
// poisoning inf (it must hit the real backend and get 4186.0).
|
||||
let normal = FluidState::from_ph(
|
||||
Pressure::from_pascals(2.0e5),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(100_000.0),
|
||||
);
|
||||
let v = cached
|
||||
.property(FluidId::new("Water"), Property::Cp, normal.clone())
|
||||
.unwrap();
|
||||
assert_eq!(v, 4186.0, "poisoning: cached inf was re-served");
|
||||
|
||||
// The finite value IS cached (second identical query → no new call).
|
||||
let v2 = cached
|
||||
.property(FluidId::new("Water"), Property::Cp, normal)
|
||||
.unwrap();
|
||||
assert_eq!(v2, 4186.0);
|
||||
assert_eq!(
|
||||
cached
|
||||
.inner
|
||||
.calls
|
||||
.load(std::sync::atomic::Ordering::Relaxed),
|
||||
2,
|
||||
"expected 2 backend calls (extreme + normal); the 2nd normal query must be a cache hit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct CoolPropBackend {
|
||||
impl CoolPropBackend {
|
||||
/// Creates a new CoolPropBackend.
|
||||
pub fn new() -> Self {
|
||||
let backend = CoolPropBackend {
|
||||
CoolPropBackend {
|
||||
critical_cache: RwLock::new(HashMap::new()),
|
||||
available_fluids: vec![
|
||||
// HFC Refrigerants
|
||||
@@ -113,9 +113,7 @@ impl CoolPropBackend {
|
||||
FluidId::new("Benzene"),
|
||||
FluidId::new("Toluene"),
|
||||
],
|
||||
};
|
||||
|
||||
backend
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new CoolPropBackend with critical point damping enabled.
|
||||
@@ -310,6 +308,11 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
}
|
||||
|
||||
// Query property based on state input type
|
||||
let prof = crate::profiling::enabled();
|
||||
if prof {
|
||||
crate::profiling::init_if_enabled();
|
||||
}
|
||||
let t0 = prof.then(std::time::Instant::now);
|
||||
let result = match state {
|
||||
FluidState::PressureTemperature(p, t) => unsafe {
|
||||
coolprop::props_si_pt(prop_code, p.to_pascals(), t.to_kelvin(), &coolprop_fluid)
|
||||
@@ -340,6 +343,15 @@ impl crate::backend::FluidBackend for CoolPropBackend {
|
||||
FluidState::PressureQualityMixture(_, _, _) => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(t0) = t0 {
|
||||
crate::profiling::record(
|
||||
&coolprop_fluid,
|
||||
prop_code,
|
||||
crate::profiling::variant_label(&state),
|
||||
t0.elapsed(),
|
||||
);
|
||||
}
|
||||
|
||||
// Check for NaN (indicates error in CoolProp)
|
||||
if result.is_nan() {
|
||||
return Err(FluidError::InvalidState {
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod dll_backend;
|
||||
pub mod errors;
|
||||
pub mod incompressible;
|
||||
pub mod mixture;
|
||||
pub mod profiling;
|
||||
pub mod tabular;
|
||||
pub mod tabular_backend;
|
||||
pub mod test_backend;
|
||||
|
||||
89
crates/fluids/src/profiling.rs
Normal file
89
crates/fluids/src/profiling.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Env-gated property-call profiler (`ENTROPYK_PROF=1`), Story 0.7.
|
||||
//!
|
||||
//! Counts CoolProp property calls and cumulative self-time per
|
||||
//! `(fluid, property, input-pair)` site so component query volume can be
|
||||
//! reduced from data, not guesses. Zero setup cost when disabled: one atomic
|
||||
//! `OnceLock` read per call, no allocation.
|
||||
|
||||
use crate::types::FluidState;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
static ENABLED: OnceLock<bool> = OnceLock::new();
|
||||
#[allow(clippy::type_complexity)]
|
||||
static SITES: OnceLock<Mutex<HashMap<(String, &'static str, &'static str), (u64, Duration)>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// True when `ENTROPYK_PROF=1` (resolved once per process).
|
||||
#[inline]
|
||||
pub fn enabled() -> bool {
|
||||
*ENABLED.get_or_init(|| {
|
||||
std::env::var("ENTROPYK_PROF")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Records one property call (no-op when profiling is disabled).
|
||||
#[inline]
|
||||
pub fn record(fluid: &str, property: &'static str, variant: &'static str, dt: Duration) {
|
||||
let Some(mutex) = SITES.get() else { return };
|
||||
if let Ok(mut map) = mutex.lock() {
|
||||
let entry = map
|
||||
.entry((fluid.to_string(), property, variant))
|
||||
.or_default();
|
||||
entry.0 += 1;
|
||||
entry.1 += dt;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily registers the map (called once from the instrumented site).
|
||||
#[inline]
|
||||
pub fn init_if_enabled() {
|
||||
if enabled() {
|
||||
SITES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Label for the input-pair variant of a state (profiling key).
|
||||
pub fn variant_label(state: &FluidState) -> &'static str {
|
||||
match state {
|
||||
FluidState::PressureTemperature(_, _) => "PT",
|
||||
FluidState::PressureEnthalpy(_, _) => "PH",
|
||||
FluidState::PressureEntropy(_, _) => "PS",
|
||||
FluidState::PressureQuality(_, _) => "PX",
|
||||
FluidState::PressureTemperatureMixture(_, _, _) => "PT-mix",
|
||||
FluidState::PressureEnthalpyMixture(_, _, _) => "PH-mix",
|
||||
FluidState::PressureQualityMixture(_, _, _) => "PX-mix",
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorted report (cumulative time desc), top 20 sites, with the grand total.
|
||||
pub fn report() -> String {
|
||||
let Some(mutex) = SITES.get() else {
|
||||
return "profiling: no data (ENTROPYK_PROF=1 not set or no calls)".to_string();
|
||||
};
|
||||
let Ok(map) = mutex.lock() else {
|
||||
return "profiling: lock poisoned".to_string();
|
||||
};
|
||||
let mut rows: Vec<_> = map.iter().collect();
|
||||
rows.sort_by_key(|(_, (_, t))| std::cmp::Reverse(*t));
|
||||
let total_calls: u64 = rows.iter().map(|(_, (c, _))| c).sum();
|
||||
let total_time: Duration = rows.iter().map(|(_, (_, t))| *t).sum();
|
||||
let mut out = format!(
|
||||
"property-call profile: {total_calls} calls, {:.1} ms total\n",
|
||||
total_time.as_secs_f64() * 1e3
|
||||
);
|
||||
for ((fluid, prop, variant), (count, time)) in rows.into_iter().take(20) {
|
||||
out.push_str(&format!(
|
||||
" {:>7} calls {:>9.2} ms {} {} [{}]\n",
|
||||
count,
|
||||
time.as_secs_f64() * 1e3,
|
||||
fluid,
|
||||
prop,
|
||||
variant
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -226,6 +226,111 @@ impl Default for TabularBackend {
|
||||
}
|
||||
}
|
||||
|
||||
impl FluidBackend for TabularBackend {
|
||||
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
// Handle (P, x) two-phase explicitly
|
||||
if let FluidState::PressureQuality(p, x) = state {
|
||||
return self.property_two_phase(table, p.to_pascals(), x.value(), property);
|
||||
}
|
||||
|
||||
let (p, t) = self.resolve_state(&fluid, state)?;
|
||||
|
||||
// Temperature and Pressure are direct
|
||||
if property == Property::Temperature {
|
||||
return Ok(t);
|
||||
}
|
||||
if property == Property::Pressure {
|
||||
return Ok(p);
|
||||
}
|
||||
|
||||
let name = Self::property_table_name(property).ok_or(FluidError::UnsupportedProperty {
|
||||
property: property.to_string(),
|
||||
})?;
|
||||
|
||||
table.single_phase.interpolate(name, p, t, &fluid.0)
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::NoCriticalPoint {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
let cp = &table.critical_point;
|
||||
Ok(CriticalPoint::new(cp.temperature, cp.pressure, cp.density))
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
|
||||
self.tables.contains_key(&fluid.0)
|
||||
}
|
||||
|
||||
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
let (p, t) = self.resolve_state(&fluid, state.clone())?;
|
||||
let pc = table.critical_point.pressure.to_pascals();
|
||||
let tc = table.critical_point.temperature.to_kelvin();
|
||||
|
||||
if p > pc && t > tc {
|
||||
return Ok(Phase::Supercritical);
|
||||
}
|
||||
|
||||
if let Some(ref sat) = table.saturation {
|
||||
if let Some((_, h_liq, h_vap, _, _, _, _)) = sat.at_pressure(p) {
|
||||
if let FluidState::PressureEnthalpy(_, h) = state {
|
||||
let hv = h.to_joules_per_kg();
|
||||
if hv <= h_liq {
|
||||
return Ok(Phase::Liquid);
|
||||
}
|
||||
if hv >= h_vap {
|
||||
return Ok(Phase::Vapor);
|
||||
}
|
||||
return Ok(Phase::TwoPhase);
|
||||
}
|
||||
if let FluidState::PressureQuality(_, x) = state {
|
||||
if x.value() <= 0.0 {
|
||||
return Ok(Phase::Liquid);
|
||||
}
|
||||
if x.value() >= 1.0 {
|
||||
return Ok(Phase::Vapor);
|
||||
}
|
||||
return Ok(Phase::TwoPhase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
self.fluid_ids
|
||||
.iter()
|
||||
.map(|s| FluidId::new(s.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
p: entropyk_core::Pressure,
|
||||
h: entropyk_core::Enthalpy,
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
let t_k = self.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::from_ph(p, h),
|
||||
)?;
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: format!("full_state for TabularBackend: Temperature is {:.2} K", t_k),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -446,108 +551,3 @@ mod tests {
|
||||
assert_relative_eq!(h_t_px, h_c_px, epsilon = 0.01 * h_c_px.max(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
impl FluidBackend for TabularBackend {
|
||||
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
// Handle (P, x) two-phase explicitly
|
||||
if let FluidState::PressureQuality(p, x) = state {
|
||||
return self.property_two_phase(table, p.to_pascals(), x.value(), property);
|
||||
}
|
||||
|
||||
let (p, t) = self.resolve_state(&fluid, state)?;
|
||||
|
||||
// Temperature and Pressure are direct
|
||||
if property == Property::Temperature {
|
||||
return Ok(t);
|
||||
}
|
||||
if property == Property::Pressure {
|
||||
return Ok(p);
|
||||
}
|
||||
|
||||
let name = Self::property_table_name(property).ok_or(FluidError::UnsupportedProperty {
|
||||
property: property.to_string(),
|
||||
})?;
|
||||
|
||||
table.single_phase.interpolate(name, p, t, &fluid.0)
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::NoCriticalPoint {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
let cp = &table.critical_point;
|
||||
Ok(CriticalPoint::new(cp.temperature, cp.pressure, cp.density))
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
|
||||
self.tables.contains_key(&fluid.0)
|
||||
}
|
||||
|
||||
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
|
||||
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
|
||||
fluid: fluid.0.clone(),
|
||||
})?;
|
||||
|
||||
let (p, t) = self.resolve_state(&fluid, state.clone())?;
|
||||
let pc = table.critical_point.pressure.to_pascals();
|
||||
let tc = table.critical_point.temperature.to_kelvin();
|
||||
|
||||
if p > pc && t > tc {
|
||||
return Ok(Phase::Supercritical);
|
||||
}
|
||||
|
||||
if let Some(ref sat) = table.saturation {
|
||||
if let Some((_, h_liq, h_vap, _, _, _, _)) = sat.at_pressure(p) {
|
||||
if let FluidState::PressureEnthalpy(_, h) = state {
|
||||
let hv = h.to_joules_per_kg();
|
||||
if hv <= h_liq {
|
||||
return Ok(Phase::Liquid);
|
||||
}
|
||||
if hv >= h_vap {
|
||||
return Ok(Phase::Vapor);
|
||||
}
|
||||
return Ok(Phase::TwoPhase);
|
||||
}
|
||||
if let FluidState::PressureQuality(_, x) = state {
|
||||
if x.value() <= 0.0 {
|
||||
return Ok(Phase::Liquid);
|
||||
}
|
||||
if x.value() >= 1.0 {
|
||||
return Ok(Phase::Vapor);
|
||||
}
|
||||
return Ok(Phase::TwoPhase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
self.fluid_ids
|
||||
.iter()
|
||||
.map(|s| FluidId::new(s.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
p: entropyk_core::Pressure,
|
||||
h: entropyk_core::Enthalpy,
|
||||
) -> FluidResult<crate::types::ThermoState> {
|
||||
let t_k = self.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::from_ph(p, h),
|
||||
)?;
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: format!("full_state for TabularBackend: Temperature is {:.2} K", t_k),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +695,7 @@ mod tests {
|
||||
let backend = TestBackend::new();
|
||||
let fluids = backend.list_fluids();
|
||||
|
||||
assert!(fluids.len() > 0);
|
||||
assert!(!fluids.is_empty());
|
||||
assert!(fluids.iter().any(|f| f.0 == "CO2"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user