Files
Entropyk/crates/fluids/src/cache.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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>
2026-07-19 16:35:31 +02:00

279 lines
9.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Thread-local LRU cache for fluid property queries.
//!
//! Avoids redundant backend calls without mutex contention by using
//! per-thread storage. Cache keys use quantized state values since f64
//! does not implement Hash.
//!
//! # Quantization Strategy
//!
//! State values (P, T, h, s, x) are quantized to 1e-9 relative precision
//! for cache key derivation. Solver iterations often repeat the same
//! (P,T) or (P,h) states; quantization should not lose cache hits for
//! typical thermodynamic ranges (P: 1e31e7 Pa, T: 200600 K).
use crate::mixture::Mixture;
use crate::types::{FluidId, FluidState, Property};
use lru::LruCache;
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::num::NonZeroUsize;
/// Default cache capacity (entries). LRU eviction when exceeded.
pub const DEFAULT_CACHE_CAPACITY: usize = 10_000;
const DEFAULT_CAP_NONZERO: NonZeroUsize = NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).unwrap();
/// 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() {
#[cfg(debug_assertions)]
eprintln!("[WARN] quantize: NaN value encountered, mapping to 0");
0
} else if v.is_infinite() {
#[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
}
}
/// Cache key for fluid property lookups.
///
/// Uses quantized state values since f64 does not implement Hash.
/// Includes backend_id so multiple CachedBackend instances don't mix results.
/// For mixtures, includes a hash of the mixture composition.
#[derive(Clone, Debug)]
pub struct CacheKey {
backend_id: usize,
fluid: String,
property: Property,
variant: u8,
p_quantized: i64,
second_quantized: i64,
mixture_hash: Option<u64>,
}
impl PartialEq for CacheKey {
fn eq(&self, other: &Self) -> bool {
self.backend_id == other.backend_id
&& self.fluid == other.fluid
&& self.property == other.property
&& self.variant == other.variant
&& self.p_quantized == other.p_quantized
&& self.second_quantized == other.second_quantized
&& self.mixture_hash == other.mixture_hash
}
}
impl Eq for CacheKey {}
impl Hash for CacheKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.backend_id.hash(state);
self.fluid.hash(state);
self.property.hash(state);
self.variant.hash(state);
self.p_quantized.hash(state);
self.second_quantized.hash(state);
self.mixture_hash.hash(state);
}
}
impl CacheKey {
/// Build a cache key from fluid, property, state, and backend id.
pub fn new(backend_id: usize, fluid: &FluidId, property: Property, state: &FluidState) -> Self {
let (p, second, variant, mixture_hash) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin(), 0u8, None),
FluidState::PressureEnthalpy(p, h) => (p.to_pascals(), h.to_joules_per_kg(), 1u8, None),
FluidState::PressureEntropy(p, s) => {
(p.to_pascals(), s.to_joules_per_kg_kelvin(), 2u8, None)
}
FluidState::PressureQuality(p, x) => (p.to_pascals(), x.value(), 3u8, None),
FluidState::PressureTemperatureMixture(p, t, ref m) => {
(p.to_pascals(), t.to_kelvin(), 4u8, Some(mix_hash(m)))
}
FluidState::PressureEnthalpyMixture(p, h, ref m) => {
(p.to_pascals(), h.to_joules_per_kg(), 5u8, Some(mix_hash(m)))
}
FluidState::PressureQualityMixture(p, x, ref m) => {
(p.to_pascals(), x.value(), 6u8, Some(mix_hash(m)))
}
};
CacheKey {
backend_id,
fluid: fluid.0.clone(),
property,
variant,
p_quantized: quantize(p),
second_quantized: quantize(second),
mixture_hash,
}
}
}
/// Compute a simple hash for a mixture for cache key purposes.
fn mix_hash(mixture: &Mixture) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut hasher = DefaultHasher::new();
mixture.hash(&mut hasher);
hasher.finish()
}
thread_local! {
static CACHE: RefCell<LruCache<CacheKey, f64>> = RefCell::new(
LruCache::new(DEFAULT_CAP_NONZERO)
);
}
/// Get a value from the thread-local cache (no allocation on key build for hot path).
pub fn cache_get(
backend_id: usize,
fluid: &FluidId,
property: Property,
state: &FluidState,
) -> Option<f64> {
let key = CacheKey::new(backend_id, fluid, property, state);
CACHE.with(|c| {
c.try_borrow_mut()
.ok()
.and_then(|mut cache| cache.get(&key).copied())
})
}
/// Insert a value into the thread-local cache.
pub fn cache_insert(
backend_id: usize,
fluid: &FluidId,
property: Property,
state: &FluidState,
value: f64,
) {
let key = CacheKey::new(backend_id, fluid, property, state);
CACHE.with(|c| {
if let Ok(mut cache) = c.try_borrow_mut() {
cache.put(key, value);
}
// Silently ignore if borrow fails (cache miss is acceptable)
});
}
/// Clear the thread-local cache (e.g. at solver iteration boundaries).
pub fn cache_clear() {
CACHE.with(|c| {
if let Ok(mut cache) = c.try_borrow_mut() {
cache.clear();
}
// Silently ignore if borrow fails
});
}
/// Resize the thread-local cache capacity.
pub fn cache_resize(capacity: NonZeroUsize) {
CACHE.with(|c| {
if let Ok(mut cache) = c.try_borrow_mut() {
cache.resize(capacity);
}
// Silently ignore if borrow fails
});
}
#[cfg(test)]
mod tests {
use super::*;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_cache_key_quantization() {
let fluid = FluidId::new("R134a");
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state);
let key2 = CacheKey::new(0, &fluid, Property::Density, &state);
assert_eq!(key1, key2);
// Equal keys must have same hash (for HashMap use)
use std::collections::hash_map::DefaultHasher;
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
key1.hash(&mut h1);
key2.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_cache_key_different_states() {
let fluid = FluidId::new("R134a");
let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let state2 = FluidState::from_pt(Pressure::from_bar(2.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state1);
let key2 = CacheKey::new(0, &fluid, Property::Density, &state2);
assert_ne!(key1, key2);
}
#[test]
fn test_lru_eviction() {
use std::num::NonZeroUsize;
cache_clear();
cache_resize(NonZeroUsize::new(2).expect("2 is non-zero"));
let fluid = FluidId::new("R134a");
let state1 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(20.0));
let state2 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let state3 = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(30.0));
cache_insert(0, &fluid, Property::Density, &state1, 1000.0);
cache_insert(0, &fluid, Property::Density, &state2, 1100.0);
cache_insert(0, &fluid, Property::Density, &state3, 1200.0);
assert!(cache_get(0, &fluid, Property::Density, &state1).is_none());
assert_eq!(
cache_get(0, &fluid, Property::Density, &state2),
Some(1100.0)
);
assert_eq!(
cache_get(0, &fluid, Property::Density, &state3),
Some(1200.0)
);
cache_resize(NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("capacity is non-zero"));
}
#[test]
fn test_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");
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let key1 = CacheKey::new(0, &fluid, Property::Density, &state);
let key2 = CacheKey::new(1, &fluid, Property::Density, &state);
assert_ne!(key1, key2);
}
}