Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

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