feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

235
crates/fluids/src/cache.rs Normal file
View File

@@ -0,0 +1,235 @@
//! 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, Property, FluidState};
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;
/// Default capacity as NonZeroUsize for LruCache (avoids unwrap in production path).
const DEFAULT_CAP_NONZERO: NonZeroUsize = unsafe { NonZeroUsize::new_unchecked(DEFAULT_CACHE_CAPACITY) };
/// Quantization factor: values rounded to 1e-9 relative.
/// (v * 1e9).round() as i64 for Hash-compatible key.
#[inline]
fn quantize(v: f64) -> i64 {
if v.is_nan() || v.is_infinite() {
0
} 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| {
let mut cache = c.borrow_mut();
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| {
let mut cache = c.borrow_mut();
cache.put(key, value);
});
}
/// Clear the thread-local cache (e.g. at solver iteration boundaries).
pub fn cache_clear() {
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.clear();
});
}
/// Resize the thread-local cache capacity.
pub fn cache_resize(capacity: NonZeroUsize) {
CACHE.with(|c| {
let mut cache = c.borrow_mut();
cache.resize(capacity);
});
}
#[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_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);
}
}