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

View File

@@ -0,0 +1,273 @@
//! Table generation from CoolProp or reference data.
//!
//! When the `coolprop` feature is enabled, generates tables by querying CoolProp.
//! Otherwise, provides template/reference tables for testing.
use crate::errors::FluidResult;
use std::path::Path;
/// Generate a fluid table and save to JSON.
///
/// When `coolprop` feature is enabled, uses CoolProp to compute property values.
/// Otherwise, loads from embedded reference data (R134a only).
pub fn generate_table(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
#[cfg(feature = "coolprop")]
{
generate_from_coolprop(fluid_name, output_path)
}
#[cfg(not(feature = "coolprop"))]
{
generate_from_reference(fluid_name, output_path)
}
}
/// Map user-facing fluid name to CoolProp internal name.
#[cfg(feature = "coolprop")]
fn fluid_name_to_coolprop(name: &str) -> String {
match name.to_lowercase().as_str() {
"r134a" => "R134a".to_string(),
"r410a" => "R410A".to_string(),
"r404a" => "R404A".to_string(),
"r407c" => "R407C".to_string(),
"r32" => "R32".to_string(),
"r125" => "R125".to_string(),
"co2" | "r744" => "CO2".to_string(),
"r290" => "R290".to_string(),
"r600" => "R600".to_string(),
"r600a" => "R600A".to_string(),
"water" => "Water".to_string(),
"air" => "Air".to_string(),
n => n.to_string(),
}
}
#[cfg(feature = "coolprop")]
fn generate_from_coolprop(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
use entropyk_coolprop_sys as coolprop;
use serde::Serialize;
let cp_fluid = fluid_name_to_coolprop(fluid_name);
if !unsafe { coolprop::is_fluid_available(&cp_fluid) } {
return Err(crate::errors::FluidError::UnknownFluid {
fluid: fluid_name.to_string(),
});
}
// Critical point
let tc = unsafe { coolprop::critical_temperature(&cp_fluid) };
let pc = unsafe { coolprop::critical_pressure(&cp_fluid) };
let rho_c = unsafe { coolprop::critical_density(&cp_fluid) };
if tc.is_nan() || pc.is_nan() || rho_c.is_nan() {
return Err(crate::errors::FluidError::NoCriticalPoint {
fluid: fluid_name.to_string(),
});
}
// Single-phase grid: P (Pa), T (K) - similar to r134a.json
let pressures: Vec<f64> = vec![
100_000.0,
200_000.0,
500_000.0,
1_000_000.0,
2_000_000.0,
3_000_000.0,
];
let temperatures: Vec<f64> = vec![250.0, 270.0, 290.0, 298.15, 320.0, 350.0];
let mut density = Vec::with_capacity(pressures.len() * temperatures.len());
let mut enthalpy = Vec::with_capacity(pressures.len() * temperatures.len());
let mut entropy = Vec::with_capacity(pressures.len() * temperatures.len());
let mut cp = Vec::with_capacity(pressures.len() * temperatures.len());
let mut cv = Vec::with_capacity(pressures.len() * temperatures.len());
for &p in &pressures {
for &t in &temperatures {
let d = unsafe { coolprop::props_si_pt("D", p, t, &cp_fluid) };
let h = unsafe { coolprop::props_si_pt("H", p, t, &cp_fluid) };
let s = unsafe { coolprop::props_si_pt("S", p, t, &cp_fluid) };
let cp_val = unsafe { coolprop::props_si_pt("C", p, t, &cp_fluid) };
let cv_val = unsafe { coolprop::props_si_pt("O", p, t, &cp_fluid) };
if d.is_nan() || h.is_nan() {
return Err(crate::errors::FluidError::InvalidState {
reason: format!("CoolProp NaN at P={} Pa, T={} K", p, t),
});
}
density.push(d);
enthalpy.push(h);
entropy.push(s);
cp.push(cp_val);
cv.push(cv_val);
}
}
// Saturation table: T from triple to critical
let t_min = 250.0;
let t_max = (tc - 1.0).min(350.0);
let n_sat = 12;
let temp_points: Vec<f64> = (0..n_sat)
.map(|i| t_min + (t_max - t_min) * (i as f64) / ((n_sat - 1) as f64))
.collect();
let mut sat_temps = Vec::with_capacity(n_sat);
let mut sat_pressure = Vec::with_capacity(n_sat);
let mut h_liq = Vec::with_capacity(n_sat);
let mut h_vap = Vec::with_capacity(n_sat);
let mut rho_liq = Vec::with_capacity(n_sat);
let mut rho_vap = Vec::with_capacity(n_sat);
let mut s_liq = Vec::with_capacity(n_sat);
let mut s_vap = Vec::with_capacity(n_sat);
for &t in &temp_points {
let p_sat = unsafe { coolprop::props_si_tq("P", t, 0.0, &cp_fluid) };
if p_sat.is_nan() || p_sat <= 0.0 {
continue;
}
sat_temps.push(t);
sat_pressure.push(p_sat);
h_liq.push(unsafe { coolprop::props_si_tq("H", t, 0.0, &cp_fluid) });
h_vap.push(unsafe { coolprop::props_si_tq("H", t, 1.0, &cp_fluid) });
rho_liq.push(unsafe { coolprop::props_si_tq("D", t, 0.0, &cp_fluid) });
rho_vap.push(unsafe { coolprop::props_si_tq("D", t, 1.0, &cp_fluid) });
s_liq.push(unsafe { coolprop::props_si_tq("S", t, 0.0, &cp_fluid) });
s_vap.push(unsafe { coolprop::props_si_tq("S", t, 1.0, &cp_fluid) });
}
#[derive(Serialize)]
struct JsonTable {
fluid: String,
critical_point: JsonCriticalPoint,
single_phase: JsonSinglePhase,
saturation: JsonSaturation,
}
#[derive(Serialize)]
struct JsonCriticalPoint {
tc: f64,
pc: f64,
rho_c: f64,
}
#[derive(Serialize)]
struct JsonSinglePhase {
pressure: Vec<f64>,
temperature: Vec<f64>,
density: Vec<f64>,
enthalpy: Vec<f64>,
entropy: Vec<f64>,
cp: Vec<f64>,
cv: Vec<f64>,
}
#[derive(Serialize)]
struct JsonSaturation {
temperature: Vec<f64>,
pressure: Vec<f64>,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
rho_liq: Vec<f64>,
rho_vap: Vec<f64>,
s_liq: Vec<f64>,
s_vap: Vec<f64>,
}
let json = JsonTable {
fluid: fluid_name.to_string(),
critical_point: JsonCriticalPoint { tc, pc, rho_c },
single_phase: JsonSinglePhase {
pressure: pressures,
temperature: temperatures,
density,
enthalpy,
entropy,
cp,
cv,
},
saturation: JsonSaturation {
temperature: sat_temps,
pressure: sat_pressure,
h_liq,
h_vap,
rho_liq,
rho_vap,
s_liq,
s_vap,
},
};
let contents = serde_json::to_string_pretty(&json).map_err(|e| {
crate::errors::FluidError::InvalidState {
reason: format!("JSON serialization failed: {}", e),
}
})?;
std::fs::write(output_path, contents).map_err(|e| {
crate::errors::FluidError::TableNotFound {
path: format!("{}: {}", output_path.display(), e),
}
})?;
Ok(())
}
#[cfg(not(feature = "coolprop"))]
fn generate_from_reference(fluid_name: &str, output_path: &Path) -> FluidResult<()> {
if fluid_name == "R134a" {
let json = include_str!("../../data/r134a.json");
std::fs::write(output_path, json).map_err(|e| {
crate::errors::FluidError::TableNotFound {
path: format!("{}: {}", output_path.display(), e),
}
})?;
Ok(())
} else {
Err(crate::errors::FluidError::UnknownFluid {
fluid: fluid_name.to_string(),
})
}
}
#[cfg(all(test, feature = "coolprop"))]
mod tests {
use super::*;
use crate::backend::FluidBackend;
use crate::coolprop::CoolPropBackend;
use crate::tabular_backend::TabularBackend;
use crate::types::{FluidId, Property, ThermoState};
use approx::assert_relative_eq;
use entropyk_core::{Pressure, Temperature};
/// Validate generated tables against CoolProp spot checks (AC #2).
#[test]
fn test_generated_table_vs_coolprop_spot_checks() {
let temp = std::env::temp_dir().join("entropyk_r134a_test.json");
generate_table("R134a", &temp).expect("generate_table must succeed");
let mut tabular = TabularBackend::new();
tabular.load_table(&temp).unwrap();
let _ = std::fs::remove_file(&temp);
let coolprop = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
// Spot check: grid point (200 kPa, 290 K)
let state = ThermoState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(290.0),
);
let rho_t = tabular
.property(fluid.clone(), Property::Density, state)
.unwrap();
let rho_c = coolprop
.property(fluid.clone(), Property::Density, state)
.unwrap();
assert_relative_eq!(rho_t, rho_c, epsilon = 0.0001 * rho_c.max(1.0));
// Spot check: interpolated point (1 bar, 25°C)
let state2 = ThermoState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let h_t = tabular
.property(fluid.clone(), Property::Enthalpy, state2)
.unwrap();
let h_c = coolprop
.property(fluid.clone(), Property::Enthalpy, state2)
.unwrap();
assert_relative_eq!(h_t, h_c, epsilon = 0.0001 * h_c.max(1.0));
}
}

View File

@@ -0,0 +1,152 @@
//! Bilinear interpolation for 2D property tables.
//!
//! Provides C1-continuous interpolation suitable for solver Jacobian assembly.
use std::cmp::Ordering;
/// Performs bilinear interpolation on a 2D grid.
///
/// 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.
///
/// # 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 bilinear_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;
}
// Reject NaN to avoid panic in binary_search_by (Zero-Panic Policy)
if !p.is_finite() || !t.is_finite() {
return None;
}
// Find P indices (p_grid must be ascending)
let p_idx = match p_grid.binary_search_by(|x| x.partial_cmp(&p).unwrap_or(Ordering::Equal)) {
Ok(i) => {
if i >= n_p - 1 {
return None;
}
i
}
Err(i) => {
if i == 0 || i >= n_p {
return None;
}
i - 1
}
};
// Find T indices
let t_idx = match t_grid.binary_search_by(|x| x.partial_cmp(&t).unwrap_or(Ordering::Equal)) {
Ok(i) => {
if i >= n_t - 1 {
return None;
}
i
}
Err(i) => {
if i == 0 || i >= n_t {
return None;
}
i - 1
}
};
let p0 = p_grid[p_idx];
let p1 = p_grid[p_idx + 1];
let t0 = t_grid[t_idx];
let t1 = t_grid[t_idx + 1];
let dp = p1 - p0;
let dt = t1 - t0;
if dp <= 0.0 || dt <= 0.0 {
return None;
}
let fp = (p - p0) / dp;
let ft = (t - t0) / dt;
// Clamp to [0,1] for edge cases
let fp = fp.clamp(0.0, 1.0);
let ft = ft.clamp(0.0, 1.0);
let v00 = values[p_idx * n_t + t_idx];
let v01 = values[p_idx * n_t + t_idx + 1];
let v10 = values[(p_idx + 1) * n_t + t_idx];
let v11 = values[(p_idx + 1) * n_t + t_idx + 1];
let v0 = v00 * (1.0 - ft) + v01 * ft;
let v1 = v10 * (1.0 - ft) + v11 * ft;
Some(v0 * (1.0 - fp) + v1 * fp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bilinear_inside() {
let p = [100000.0, 200000.0, 300000.0];
let t = [250.0, 300.0, 350.0];
let v = [
1.0, 2.0, 3.0, // p=100k
4.0, 5.0, 6.0, // p=200k
7.0, 8.0, 9.0, // p=300k
];
let result = bilinear_interpolate(&p, &t, &v, 200000.0, 300.0);
assert!(result.is_some());
assert!((result.unwrap() - 5.0).abs() < 1e-10);
}
#[test]
fn test_bilinear_interpolated() {
let p = [0.0, 1.0];
let t = [0.0, 1.0];
let v = [0.0, 1.0, 1.0, 2.0]; // v(0,0)=0, v(0,1)=1, v(1,0)=1, v(1,1)=2
let result = bilinear_interpolate(&p, &t, &v, 0.5, 0.5);
assert!(result.is_some());
// At center: (0+1+1+2)/4 = 1.0
assert!((result.unwrap() - 1.0).abs() < 1e-10);
}
#[test]
fn test_bilinear_out_of_bounds() {
let p = [100000.0, 200000.0];
let t = [250.0, 300.0];
let v = [1.0, 2.0, 3.0, 4.0];
assert!(bilinear_interpolate(&p, &t, &v, 50000.0, 300.0).is_none());
assert!(bilinear_interpolate(&p, &t, &v, 300000.0, 300.0).is_none());
}
#[test]
fn test_bilinear_nan_rejected() {
let p = [100000.0, 200000.0];
let t = [250.0, 300.0];
let v = [1.0, 2.0, 3.0, 4.0];
assert!(bilinear_interpolate(&p, &t, &v, f64::NAN, 300.0).is_none());
assert!(bilinear_interpolate(&p, &t, &v, 150000.0, f64::NAN).is_none());
assert!(bilinear_interpolate(&p, &t, &v, f64::INFINITY, 300.0).is_none());
}
}

View File

@@ -0,0 +1,11 @@
//! Tabular fluid property backend.
//!
//! Pre-computed NIST-style tables with fast bilinear interpolation
//! for 100x performance vs direct EOS calls.
mod interpolate;
mod table;
pub mod generator;
pub use table::{FluidTable, SaturationTable, SinglePhaseTable, TableCriticalPoint};

View File

@@ -0,0 +1,286 @@
//! Fluid property table structure and loading.
//!
//! Defines the JSON format for tabular fluid data and provides loading logic.
use crate::errors::{FluidError, FluidResult};
use entropyk_core::{Pressure, Temperature};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use super::interpolate::bilinear_interpolate;
/// Critical point data stored in table metadata.
#[derive(Debug, Clone)]
pub struct TableCriticalPoint {
/// Critical temperature (K)
pub temperature: Temperature,
/// Critical pressure (Pa)
pub pressure: Pressure,
/// Critical density (kg/m³)
pub density: f64,
}
/// Single-phase property table (P, T) grid.
#[derive(Debug, Clone)]
pub struct SinglePhaseTable {
/// Pressure grid (Pa), ascending
pub pressure: Vec<f64>,
/// Temperature grid (K), ascending
pub temperature: Vec<f64>,
/// Property grids: density, enthalpy, entropy, cp, cv, etc.
/// Key: property name, Value: row-major 2D data [p_idx * n_t + t_idx]
pub properties: HashMap<String, Vec<f64>>,
}
impl SinglePhaseTable {
/// Interpolate a property at (p, t). Returns error if out of bounds.
#[inline]
pub fn interpolate(
&self,
property_name: &str,
p: f64,
t: f64,
fluid_name: &str,
) -> FluidResult<f64> {
let values = self
.properties
.get(property_name)
.ok_or(FluidError::UnsupportedProperty {
property: property_name.to_string(),
})?;
bilinear_interpolate(&self.pressure, &self.temperature, values, p, t).ok_or(
FluidError::OutOfBounds {
fluid: fluid_name.to_string(),
p,
t,
},
)
}
/// Check if (p, t) is within table bounds.
#[inline]
pub fn in_bounds(&self, p: f64, t: f64) -> bool {
if self.pressure.is_empty() || self.temperature.is_empty() {
return false;
}
let p_min = self.pressure[0];
let p_max = self.pressure[self.pressure.len() - 1];
let t_min = self.temperature[0];
let t_max = self.temperature[self.temperature.len() - 1];
p >= p_min && p <= p_max && t >= t_min && t <= t_max
}
}
/// Saturation line data for two-phase (P, x) lookups.
#[derive(Debug, Clone)]
pub struct SaturationTable {
/// Temperature (K) - independent variable
pub temperature: Vec<f64>,
/// Saturation pressure (Pa)
pub pressure: Vec<f64>,
/// Saturated liquid enthalpy (J/kg)
pub h_liq: Vec<f64>,
/// Saturated vapor enthalpy (J/kg)
pub h_vap: Vec<f64>,
/// Saturated liquid density (kg/m³)
pub rho_liq: Vec<f64>,
/// Saturated vapor density (kg/m³)
pub rho_vap: Vec<f64>,
/// Saturated liquid entropy (J/(kg·K))
pub s_liq: Vec<f64>,
/// Saturated vapor entropy (J/(kg·K))
pub s_vap: Vec<f64>,
}
impl SaturationTable {
/// Find saturation properties at pressure P via 1D interpolation on P_sat(T).
/// Returns (T_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap).
pub fn at_pressure(&self, p: f64) -> Option<(f64, f64, f64, f64, f64, f64, f64)> {
if self.pressure.is_empty() || self.pressure.len() != self.temperature.len() {
return None;
}
// Find T such that P_sat(T) = p (pressure is monotonic with T)
let n = self.pressure.len();
if p < self.pressure[0] || p > self.pressure[n - 1] {
return None;
}
let idx = self.pressure.iter().position(|&x| x >= p).unwrap_or(n - 1);
let i = if idx == 0 { 0 } else { idx - 1 };
let j = (i + 1).min(n - 1);
let p0 = self.pressure[i];
let p1 = self.pressure[j];
let frac = if (p1 - p0).abs() < 1e-15 {
0.0
} else {
((p - p0) / (p1 - p0)).clamp(0.0, 1.0)
};
let t_sat = self.temperature[i] * (1.0 - frac) + self.temperature[j] * frac;
let h_liq = self.h_liq[i] * (1.0 - frac) + self.h_liq[j] * frac;
let h_vap = self.h_vap[i] * (1.0 - frac) + self.h_vap[j] * frac;
let rho_liq = self.rho_liq[i] * (1.0 - frac) + self.rho_liq[j] * frac;
let rho_vap = self.rho_vap[i] * (1.0 - frac) + self.rho_vap[j] * frac;
let s_liq = self.s_liq[i] * (1.0 - frac) + self.s_liq[j] * frac;
let s_vap = self.s_vap[i] * (1.0 - frac) + self.s_vap[j] * frac;
Some((t_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap))
}
}
/// Complete fluid table with single-phase and saturation data.
#[derive(Debug, Clone)]
pub struct FluidTable {
/// Fluid identifier
pub fluid_id: String,
/// Critical point
pub critical_point: TableCriticalPoint,
/// Single-phase (P, T) table
pub single_phase: SinglePhaseTable,
/// Saturation table (optional - for two-phase support)
pub saturation: Option<SaturationTable>,
}
/// JSON deserialization structures (internal format).
#[derive(Debug, Deserialize)]
struct JsonCriticalPoint {
tc: f64,
pc: f64,
rho_c: f64,
}
#[derive(Debug, Deserialize)]
struct JsonSinglePhase {
pressure: Vec<f64>,
temperature: Vec<f64>,
density: Vec<f64>,
enthalpy: Vec<f64>,
entropy: Vec<f64>,
cp: Vec<f64>,
cv: Vec<f64>,
}
#[derive(Debug, Deserialize)]
struct JsonSaturation {
temperature: Vec<f64>,
pressure: Vec<f64>,
h_liq: Vec<f64>,
h_vap: Vec<f64>,
rho_liq: Vec<f64>,
rho_vap: Vec<f64>,
s_liq: Vec<f64>,
s_vap: Vec<f64>,
}
#[derive(Debug, Deserialize)]
struct JsonFluidTable {
fluid: String,
critical_point: JsonCriticalPoint,
single_phase: JsonSinglePhase,
saturation: Option<JsonSaturation>,
}
impl FluidTable {
/// Load a fluid table from a JSON file.
pub fn load_from_path(path: &Path) -> FluidResult<Self> {
let contents = std::fs::read_to_string(path).map_err(|e| FluidError::TableNotFound {
path: format!("{}: {}", path.display(), e),
})?;
let json: JsonFluidTable =
serde_json::from_str(&contents).map_err(|e| FluidError::InvalidState {
reason: format!("Invalid table JSON: {}", e),
})?;
Self::from_json(json)
}
/// Load from JSON string (for embedded tables in tests).
pub fn load_from_str(s: &str) -> FluidResult<Self> {
let json: JsonFluidTable =
serde_json::from_str(s).map_err(|e| FluidError::InvalidState {
reason: format!("Invalid table JSON: {}", e),
})?;
Self::from_json(json)
}
fn from_json(json: JsonFluidTable) -> FluidResult<Self> {
let n_p = json.single_phase.pressure.len();
let n_t = json.single_phase.temperature.len();
let expected = n_p * n_t;
if json.single_phase.density.len() != expected
|| json.single_phase.enthalpy.len() != expected
|| json.single_phase.entropy.len() != expected
|| json.single_phase.cp.len() != expected
|| json.single_phase.cv.len() != expected
{
return Err(FluidError::InvalidState {
reason: "Table grid dimensions do not match property arrays".to_string(),
});
}
let mut properties = HashMap::new();
properties.insert("density".to_string(), json.single_phase.density);
properties.insert("enthalpy".to_string(), json.single_phase.enthalpy);
properties.insert("entropy".to_string(), json.single_phase.entropy);
properties.insert("cp".to_string(), json.single_phase.cp);
properties.insert("cv".to_string(), json.single_phase.cv);
let single_phase = SinglePhaseTable {
pressure: json.single_phase.pressure,
temperature: json.single_phase.temperature,
properties,
};
let saturation = json
.saturation
.map(|s| {
let n = s.temperature.len();
if s.pressure.len() != n
|| s.h_liq.len() != n
|| s.h_vap.len() != n
|| s.rho_liq.len() != n
|| s.rho_vap.len() != n
|| s.s_liq.len() != n
|| s.s_vap.len() != n
{
return Err(FluidError::InvalidState {
reason: format!(
"Saturation table array length mismatch: expected {} elements",
n
),
});
}
Ok(SaturationTable {
temperature: s.temperature,
pressure: s.pressure,
h_liq: s.h_liq,
h_vap: s.h_vap,
rho_liq: s.rho_liq,
rho_vap: s.rho_vap,
s_liq: s.s_liq,
s_vap: s.s_vap,
})
})
.transpose()?;
let critical_point = TableCriticalPoint {
temperature: Temperature::from_kelvin(json.critical_point.tc),
pressure: Pressure::from_pascals(json.critical_point.pc),
density: json.critical_point.rho_c,
};
Ok(FluidTable {
fluid_id: json.fluid,
critical_point,
single_phase,
saturation,
})
}
}