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,452 @@
//! Critical point damping for thermodynamic properties.
//!
//! This module provides functionality to detect near-critical regions and apply
//! C1-continuous damping to prevent NaN values in derivative properties (Cp, Cv, etc.)
//! that diverge near the critical point.
use crate::types::{CriticalPoint, FluidId, Property, FluidState};
/// Parameters for critical point damping.
#[derive(Debug, Clone)]
pub struct DampingParams {
/// Reduced temperature threshold (default: 0.05 = 5%)
pub reduced_temp_threshold: f64,
/// Reduced pressure threshold (default: 0.05 = 5%)
pub reduced_pressure_threshold: f64,
/// Smoothness parameter for sigmoid transition (default: 0.01)
pub smoothness: f64,
/// Maximum allowed Cp value in J/(kg·K) (default: 1e6)
pub cp_max: f64,
/// Maximum allowed Cv value in J/(kg·K) (default: 1e6)
pub cv_max: f64,
/// Maximum allowed derivative value (default: 1e10)
pub derivative_max: f64,
}
impl Default for DampingParams {
fn default() -> Self {
DampingParams {
reduced_temp_threshold: 0.05,
reduced_pressure_threshold: 0.05,
smoothness: 0.01,
cp_max: 1e6,
cv_max: 1e6,
derivative_max: 1e10,
}
}
}
/// Extracts pressure and temperature from a FluidState.
/// Returns None if state cannot be converted to (P, T).
pub fn state_to_pt(state: &FluidState) -> Option<(f64, f64)> {
match state {
FluidState::PressureTemperature(p, t) => Some((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpy(_, _) => None,
FluidState::PressureEntropy(_, _) => None,
FluidState::PressureQuality(_, _) => None,
FluidState::PressureTemperatureMixture(p, t, _) => Some((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpyMixture(_, _, _) => None,
FluidState::PressureQualityMixture(_, _, _) => None,
}
}
/// Calculate reduced coordinates (Tr, Pr) from absolute values and critical point.
///
/// - Tr = T / Tc
/// - Pr = P / Pc
pub fn reduced_coordinates(
temperature_kelvin: f64,
pressure_pascals: f64,
cp: &CriticalPoint,
) -> (f64, f64) {
let tr = temperature_kelvin / cp.temperature_kelvin();
let pr = pressure_pascals / cp.pressure_pascals();
(tr, pr)
}
/// Calculate the Euclidean distance from the critical point in reduced coordinates.
///
/// Distance = sqrt((Tr - 1)^2 + (Pr - 1)^2)
pub fn reduced_distance(temperature_kelvin: f64, pressure_pascals: f64, cp: &CriticalPoint) -> f64 {
let (tr, pr) = reduced_coordinates(temperature_kelvin, pressure_pascals, cp);
((tr - 1.0).powi(2) + (pr - 1.0).powi(2)).sqrt()
}
/// Check if a state is within the near-critical region.
///
/// A state is "near critical" if:
/// |Tr - 1| < threshold AND |Pr - 1| < threshold
pub fn near_critical_point(
temperature_kelvin: f64,
pressure_pascals: f64,
cp: &CriticalPoint,
threshold: f64,
) -> bool {
let (tr, pr) = reduced_coordinates(temperature_kelvin, pressure_pascals, cp);
(tr - 1.0).abs() < threshold && (pr - 1.0).abs() < threshold
}
/// C1-continuous sigmoid blend factor.
///
/// Blend factor α: 0 = far from critical (use raw), 1 = at critical (use damped).
/// C1-continuous: α and dα/d(distance) are continuous.
///
/// - distance < threshold => near critical => α → 1
/// - distance > threshold + width => far => α → 0
pub fn sigmoid_blend(distance: f64, threshold: f64, width: f64) -> f64 {
// α = 0.5 * (1 + tanh((threshold - distance) / width))
// At distance = 0 (critical): α ≈ 1
// At distance = threshold: α = 0.5
// At distance >> threshold: α → 0
let x = (threshold - distance) / width;
0.5 * (1.0 + x.tanh())
}
/// Derivative of sigmoid blend factor with respect to distance.
///
/// This is used to ensure C1 continuity when applying damping.
pub fn sigmoid_blend_derivative(distance: f64, threshold: f64, width: f64) -> f64 {
// derivative of 0.5 * (1 + tanh((threshold - distance) / width)) with respect to distance
// = 0.5 * sech^2((threshold - distance) / width) * (-1 / width)
// = -0.5 * sech^2(x) / width where x = (threshold - distance) / width
let x = (threshold - distance) / width;
let sech = 1.0 / x.cosh();
-0.5 * sech * sech / width
}
/// Apply damping to a property value.
///
/// Returns the damped value using sigmoid blending between raw and capped values.
pub fn damp_property(value: f64, max_value: f64, blend_factor: f64) -> f64 {
let capped = value.abs().min(max_value) * value.signum();
blend_factor * capped + (1.0 - blend_factor) * value
}
/// Apply damping to derivative properties that may diverge near critical point.
///
/// Properties like Cp, Cv, and (∂ρ/∂P)_T can diverge near the critical point.
/// This function applies a smooth cap to prevent NaN values.
pub fn damp_derivative(value: f64, params: &DampingParams) -> f64 {
let blend = sigmoid_blend(0.0, params.reduced_temp_threshold, params.smoothness);
damp_property(value, params.derivative_max, blend)
}
/// Check if a property should be damped.
///
/// Derivative properties (Cp, Cv, etc.) may diverge near critical point.
pub fn should_damp_property(property: Property) -> bool {
matches!(
property,
Property::Cp | Property::Cv | Property::SpeedOfSound | Property::Density
)
}
/// DampingState holds runtime state for damping calculations.
#[derive(Debug, Clone)]
pub struct DampingState {
/// Whether damping is active for the current query
pub is_damping: bool,
/// The blend factor (0 = no damping, 1 = full damping)
pub blend_factor: f64,
/// Distance from critical point
pub distance: f64,
}
impl DampingState {
/// Create a new DampingState with no damping
pub fn none() -> Self {
DampingState {
is_damping: false,
blend_factor: 0.0,
distance: f64::MAX,
}
}
}
/// Calculate damping state for a given fluid and state.
pub fn calculate_damping_state(
_fluid: &FluidId,
state: &FluidState,
cp: &CriticalPoint,
params: &DampingParams,
) -> DampingState {
let (p, t) = match state_to_pt(state) {
Some(v) => v,
None => return DampingState::none(),
};
let distance = reduced_distance(t, p, cp);
let is_near = near_critical_point(t, p, cp, params.reduced_temp_threshold);
if !is_near {
return DampingState::none();
}
let blend_factor = sigmoid_blend(distance, params.reduced_temp_threshold, params.smoothness);
DampingState {
is_damping: true,
blend_factor,
distance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::FluidState;
use entropyk_core::{Pressure, Temperature};
fn make_co2_critical_point() -> CriticalPoint {
CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.6,
)
}
#[test]
fn test_reduced_coordinates() {
let cp = make_co2_critical_point();
// At critical point: Tr = 1, Pr = 1
let (tr, pr) = reduced_coordinates(304.13, 7.3773e6, &cp);
assert!((tr - 1.0).abs() < 1e-10);
assert!((pr - 1.0).abs() < 1e-10);
// At 5% above critical
let (tr, pr) = reduced_coordinates(319.3365, 7.746165e6, &cp);
assert!((tr - 1.05).abs() < 1e-6);
assert!((pr - 1.05).abs() < 1e-6);
}
#[test]
fn test_reduced_distance_at_critical() {
let cp = make_co2_critical_point();
// At critical point, distance should be 0
let dist = reduced_distance(304.13, 7.3773e6, &cp);
assert!(dist.abs() < 1e-10);
}
#[test]
fn test_near_critical_point_true() {
let cp = make_co2_critical_point();
// At critical point
assert!(near_critical_point(304.13, 7.3773e6, &cp, 0.05));
// 5% from critical
let t = 304.13 * 1.03;
let p = 7.3773e6 * 1.03;
assert!(near_critical_point(t, p, &cp, 0.05));
}
#[test]
fn test_near_critical_point_false() {
let cp = make_co2_critical_point();
// Far from critical (room temperature, 1 bar)
assert!(!near_critical_point(298.15, 1e5, &cp, 0.05));
// Outside 5% threshold
let t = 304.13 * 1.10;
let p = 7.3773e6 * 1.10;
assert!(!near_critical_point(t, p, &cp, 0.05));
}
#[test]
fn test_sigmoid_blend_at_critical() {
let threshold = 0.05;
let width = 0.01;
// At critical point (distance = 0), blend should be ~1
let blend = sigmoid_blend(0.0, threshold, width);
assert!(
blend > 0.99,
"Expected blend > 0.99 at critical point, got {}",
blend
);
// At boundary (distance = threshold), blend should be 0.5
let blend = sigmoid_blend(threshold, threshold, width);
assert!(
(blend - 0.5).abs() < 0.001,
"Expected blend ~0.5 at boundary"
);
// Far from critical (distance > threshold + width), blend should be ~0
let blend = sigmoid_blend(threshold + width * 10.0, threshold, width);
assert!(blend < 0.001);
}
#[test]
fn test_sigmoid_blend_derivative() {
let threshold = 0.05;
let width = 0.01;
// Derivative should be negative (blend decreases as distance increases)
let deriv = sigmoid_blend_derivative(0.0, threshold, width);
assert!(deriv < 0.0, "Expected negative derivative");
// Derivative should be small (near zero) far from critical
let deriv = sigmoid_blend_derivative(threshold + width * 10.0, threshold, width);
assert!(deriv.abs() < 1e-6);
}
#[test]
fn test_sigmoid_c1_continuous() {
let threshold = 0.05;
let width = 0.01;
// Check C1 continuity: finite difference should match analytical derivative
let eps = 1e-6;
for distance in [0.0, 0.02, 0.04, 0.06, 0.08] {
let deriv_analytical = sigmoid_blend_derivative(distance, threshold, width);
let deriv_numerical = (sigmoid_blend(distance + eps, threshold, width)
- sigmoid_blend(distance - eps, threshold, width))
/ (2.0 * eps);
assert!(
(deriv_analytical - deriv_numerical).abs() < 1e-4,
"C1 continuity failed at distance {}: analytical={}, numerical={}",
distance,
deriv_analytical,
deriv_numerical
);
}
}
#[test]
fn test_damp_property() {
// Large value should be capped
let damped = damp_property(1e8, 1e6, 1.0);
assert!(damped.abs() < 1e6 + 1.0);
// Small value should remain unchanged
let damped = damp_property(1000.0, 1e6, 1.0);
assert!((damped - 1000.0).abs() < 1.0);
// Partial blend
let damped = damp_property(1e8, 1e6, 0.5);
assert!(damped > 1e6 && damped < 1e8);
}
#[test]
fn test_state_to_pt() {
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let (p, t) = state_to_pt(&state).unwrap();
assert!((p - 1e5).abs() < 1.0);
assert!((t - 298.15).abs() < 1.0);
// Enthalpy state should return None
let state = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(400.0),
);
assert!(state_to_pt(&state).is_none());
}
#[test]
fn test_should_damp_property() {
assert!(should_damp_property(Property::Cp));
assert!(should_damp_property(Property::Cv));
assert!(should_damp_property(Property::Density));
assert!(should_damp_property(Property::SpeedOfSound));
assert!(!should_damp_property(Property::Enthalpy));
assert!(!should_damp_property(Property::Entropy));
assert!(!should_damp_property(Property::Pressure));
assert!(!should_damp_property(Property::Temperature));
}
#[test]
fn test_calculate_damping_state_near_critical() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// At critical point
let state = FluidState::from_pt(
Pressure::from_pascals(7.3773e6),
Temperature::from_kelvin(304.13),
);
let fluid = FluidId::new("CO2");
let damping = calculate_damping_state(&fluid, &state, &cp, &params);
assert!(damping.is_damping);
assert!(damping.blend_factor > 0.9);
}
#[test]
fn test_calculate_damping_state_far_from_critical() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// Room temperature, 1 bar - far from critical
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let fluid = FluidId::new("CO2");
let damping = calculate_damping_state(&fluid, &state, &cp, &params);
assert!(!damping.is_damping);
}
#[test]
fn test_damping_region_boundary_smooth_transition() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// 4.9% from critical - inside region
let t_near = 304.13 * (1.0 + 0.049);
let p_near = 7.3773e6 * (1.0 + 0.049);
let state_near = FluidState::from_pt(
Pressure::from_pascals(p_near),
Temperature::from_kelvin(t_near),
);
let damping_near = calculate_damping_state(&FluidId::new("CO2"), &state_near, &cp, &params);
// 5.1% from critical - outside region
let t_far = 304.13 * (1.0 + 0.051);
let p_far = 7.3773e6 * (1.0 + 0.051);
let state_far = FluidState::from_pt(
Pressure::from_pascals(p_far),
Temperature::from_kelvin(t_far),
);
let damping_far = calculate_damping_state(&FluidId::new("CO2"), &state_far, &cp, &params);
// Should transition smoothly
assert!(damping_near.is_damping, "4.9% should be in damping region");
assert!(
!damping_far.is_damping,
"5.1% should be outside damping region"
);
}
#[test]
fn test_damping_transition_is_smooth() {
let cp = make_co2_critical_point();
let params = DampingParams::default();
// Test at various distances around the boundary
let distances = [0.03, 0.04, 0.045, 0.05, 0.055, 0.06];
let mut previous_blend = 1.0;
for d in distances {
let t = 304.13 * (1.0 + d);
let p = 7.3773e6 * (1.0 + d);
let state =
FluidState::from_pt(Pressure::from_pascals(p), Temperature::from_kelvin(t));
let damping = calculate_damping_state(&FluidId::new("CO2"), &state, &cp, &params);
let blend = damping.blend_factor;
// Blend should decrease smoothly (no sudden jumps)
assert!(
blend <= previous_blend + 0.1,
"Blend should decrease smoothly: prev={}, curr={}",
previous_blend,
blend
);
previous_blend = blend;
}
}
}