feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
273
crates/fluids/src/tabular/generator.rs
Normal file
273
crates/fluids/src/tabular/generator.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
152
crates/fluids/src/tabular/interpolate.rs
Normal file
152
crates/fluids/src/tabular/interpolate.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
11
crates/fluids/src/tabular/mod.rs
Normal file
11
crates/fluids/src/tabular/mod.rs
Normal 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};
|
||||
286
crates/fluids/src/tabular/table.rs
Normal file
286
crates/fluids/src/tabular/table.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user