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

@@ -7,9 +7,17 @@ description = "Core component trait definitions for Entropyk thermodynamic simul
license = "MIT OR Apache-2.0"
repository = "https://github.com/entropyk/entropyk"
[features]
default = []
ffi = []
http = []
[dependencies]
# Core types will be added when core crate is created
# entropyk-core = { path = "../core" }
# Core types from Story 1.2
entropyk-core = { path = "../core" }
# Fluid properties backend (Story 5.1 - FluidBackend integration)
entropyk-fluids = { path = "../fluids" }
# Error handling
thiserror = "1.0"
@@ -18,8 +26,8 @@ thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
# Testing utilities
# tokio-test = "0.4"
# Floating-point assertions
approx = "0.5"
[lib]
name = "entropyk_components"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,628 @@
//! External Component Model Interface
//!
//! This module provides support for external component models via:
//! - Dynamic library loading (.dll/.so) via FFI
//! - HTTP API calls to external services
//!
//! ## Architecture
//!
//! The external model interface allows integration of proprietary or vendor-supplied
//! component models that cannot be implemented natively in Rust.
//!
//! ## FFI Interface (DLL/SO)
//!
//! External libraries must implement the `entropyk_model` C ABI:
//!
//! ```c
//! // Required exported functions:
//! int entropyk_model_compute(double* inputs, double* outputs, int n_in, int n_out);
//! int entropyk_model_jacobian(double* inputs, double* jacobian, int n_in, int n_out);
//! const char* entropyk_model_name(void);
//! const char* entropyk_model_version(void);
//! ```
//!
//! ## HTTP API Interface
//!
//! External services must provide REST endpoints:
//!
//! - `POST /compute`: Accepts JSON with inputs, returns JSON with outputs
//! - `POST /jacobian`: Accepts JSON with inputs, returns JSON with Jacobian matrix
use crate::ComponentError;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
/// Configuration for an external model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalModelConfig {
/// Unique identifier for this model
pub id: String,
/// Model type (ffi or http)
pub model_type: ExternalModelType,
/// Number of inputs expected
pub n_inputs: usize,
/// Number of outputs produced
pub n_outputs: usize,
/// Optional timeout in milliseconds
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
}
fn default_timeout() -> u64 {
5000
}
/// Type of external model interface.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ExternalModelType {
/// Dynamic library (.dll on Windows, .so on Linux, .dylib on macOS)
Ffi {
/// Path to the library file
library_path: PathBuf,
/// Optional function name prefix
function_prefix: Option<String>,
},
/// HTTP REST API
Http {
/// Base URL for the API
base_url: String,
/// Optional API key for authentication
api_key: Option<String>,
},
}
/// Trait for external model implementations.
///
/// This trait abstracts over FFI and HTTP interfaces, providing
/// a unified interface for the solver.
pub trait ExternalModel: Send + Sync {
/// Returns the model identifier.
fn id(&self) -> &str;
/// Returns the number of inputs.
fn n_inputs(&self) -> usize;
/// Returns the number of outputs.
fn n_outputs(&self) -> usize;
/// Computes outputs from inputs.
///
/// # Arguments
///
/// * `inputs` - Input values (length = n_inputs)
///
/// # Returns
///
/// Output values (length = n_outputs)
fn compute(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError>;
/// Computes the Jacobian matrix.
///
/// # Arguments
///
/// * `inputs` - Input values
///
/// # Returns
///
/// Jacobian matrix as a flat array (row-major, n_outputs × n_inputs)
fn jacobian(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError>;
/// Returns model metadata.
fn metadata(&self) -> ExternalModelMetadata;
}
/// Metadata about an external model.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalModelMetadata {
/// Model name
pub name: String,
/// Model version
pub version: String,
/// Model description
pub description: Option<String>,
/// Input names/units
pub input_names: Vec<String>,
/// Output names/units
pub output_names: Vec<String>,
}
/// Errors from external model operations.
#[derive(Debug, Clone, thiserror::Error)]
pub enum ExternalModelError {
/// Library loading failed
#[error("Failed to load library: {0}")]
LibraryLoad(String),
/// Function not found in library
#[error("Function not found: {0}")]
FunctionNotFound(String),
/// Computation failed
#[error("Computation failed: {0}")]
ComputationFailed(String),
/// Invalid input dimensions
#[error("Invalid input dimensions: expected {expected}, got {actual}")]
InvalidInputDimensions {
/// Expected number of inputs
expected: usize,
/// Actual number received
actual: usize,
},
/// HTTP request failed
#[error("HTTP request failed: {0}")]
HttpError(String),
/// Timeout exceeded
#[error("Operation timed out after {0}ms")]
Timeout(u64),
/// JSON parsing error
#[error("JSON error: {0}")]
JsonError(String),
/// Model not initialized
#[error("Model not initialized")]
NotInitialized,
}
impl From<ExternalModelError> for ComponentError {
fn from(err: ExternalModelError) -> Self {
ComponentError::InvalidState(format!("External model error: {}", err))
}
}
/// Request body for HTTP compute endpoint.
#[derive(Debug, Serialize)]
#[allow(dead_code)]
struct ComputeRequest {
inputs: Vec<f64>,
}
/// Response from HTTP compute endpoint.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ComputeResponse {
outputs: Vec<f64>,
}
/// Request body for HTTP Jacobian endpoint.
#[derive(Debug, Serialize)]
#[allow(dead_code)]
struct JacobianRequest {
inputs: Vec<f64>,
}
/// Response from HTTP Jacobian endpoint.
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct JacobianResponse {
jacobian: Vec<f64>,
}
/// FFI-based external model (mock implementation for non-ffi builds).
///
/// When the `ffi` feature is not enabled, this provides a mock implementation
/// that can be used for testing and development. The mock passes inputs through
/// unchanged (identity function).
#[cfg(not(feature = "ffi"))]
pub struct FfiModel {
config: ExternalModelConfig,
metadata: ExternalModelMetadata,
}
#[cfg(not(feature = "ffi"))]
impl FfiModel {
/// Creates a new FFI model (mock implementation without ffi feature).
///
/// This creates a mock model that can be used for testing. The mock
/// implements an identity function (output = input for first n_outputs).
pub fn new(config: ExternalModelConfig) -> Result<Self, ExternalModelError> {
let metadata = ExternalModelMetadata {
name: format!("Mock FFI Model: {}", config.id),
version: "0.1.0-mock".to_string(),
description: Some("Mock FFI model for testing (ffi feature not enabled)".to_string()),
input_names: (0..config.n_inputs)
.map(|i| format!("input_{}", i))
.collect(),
output_names: (0..config.n_outputs)
.map(|i| format!("output_{}", i))
.collect(),
};
Ok(Self { config, metadata })
}
/// Creates with custom mock metadata for testing.
pub fn new_mock(
config: ExternalModelConfig,
metadata: ExternalModelMetadata,
) -> Result<Self, ExternalModelError> {
Ok(Self { config, metadata })
}
}
#[cfg(not(feature = "ffi"))]
impl ExternalModel for FfiModel {
fn id(&self) -> &str {
&self.config.id
}
fn n_inputs(&self) -> usize {
self.config.n_inputs
}
fn n_outputs(&self) -> usize {
self.config.n_outputs
}
fn compute(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
if inputs.len() != self.config.n_inputs {
return Err(ExternalModelError::InvalidInputDimensions {
expected: self.config.n_inputs,
actual: inputs.len(),
});
}
// Mock: pass through inputs (identity for first n_outputs, zero padding)
let mut outputs = vec![0.0; self.config.n_outputs];
for (i, &input) in inputs.iter().take(self.config.n_outputs).enumerate() {
outputs[i] = input;
}
Ok(outputs)
}
fn jacobian(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
if inputs.len() != self.config.n_inputs {
return Err(ExternalModelError::InvalidInputDimensions {
expected: self.config.n_inputs,
actual: inputs.len(),
});
}
// Mock: returns identity-like Jacobian
let mut jacobian = vec![0.0; self.config.n_inputs * self.config.n_outputs];
let min_dim = self.config.n_inputs.min(self.config.n_outputs);
for i in 0..min_dim {
jacobian[i * self.config.n_inputs + i] = 1.0;
}
Ok(jacobian)
}
fn metadata(&self) -> ExternalModelMetadata {
self.metadata.clone()
}
}
/// HTTP-based external model (mock implementation for non-http builds).
///
/// When the `http` feature is not enabled, this provides a mock implementation
/// that can be used for testing and development. The mock passes inputs through
/// unchanged (identity function).
#[cfg(not(feature = "http"))]
pub struct HttpModel {
config: ExternalModelConfig,
metadata: ExternalModelMetadata,
}
#[cfg(not(feature = "http"))]
impl HttpModel {
/// Creates a new HTTP model (mock implementation without http feature).
///
/// This creates a mock model that can be used for testing. The mock
/// implements an identity function (output = input for first n_outputs).
pub fn new(config: ExternalModelConfig) -> Result<Self, ExternalModelError> {
let metadata = ExternalModelMetadata {
name: format!("Mock HTTP Model: {}", config.id),
version: "0.1.0-mock".to_string(),
description: Some("Mock HTTP model for testing (http feature not enabled)".to_string()),
input_names: (0..config.n_inputs)
.map(|i| format!("input_{}", i))
.collect(),
output_names: (0..config.n_outputs)
.map(|i| format!("output_{}", i))
.collect(),
};
Ok(Self { config, metadata })
}
/// Creates with custom mock metadata for testing.
pub fn new_mock(
config: ExternalModelConfig,
metadata: ExternalModelMetadata,
) -> Result<Self, ExternalModelError> {
Ok(Self { config, metadata })
}
}
#[cfg(not(feature = "http"))]
impl ExternalModel for HttpModel {
fn id(&self) -> &str {
&self.config.id
}
fn n_inputs(&self) -> usize {
self.config.n_inputs
}
fn n_outputs(&self) -> usize {
self.config.n_outputs
}
fn compute(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
if inputs.len() != self.config.n_inputs {
return Err(ExternalModelError::InvalidInputDimensions {
expected: self.config.n_inputs,
actual: inputs.len(),
});
}
// Mock: pass through inputs (identity for first n_outputs, zero padding)
let mut outputs = vec![0.0; self.config.n_outputs];
for (i, &input) in inputs.iter().take(self.config.n_outputs).enumerate() {
outputs[i] = input;
}
Ok(outputs)
}
fn jacobian(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
if inputs.len() != self.config.n_inputs {
return Err(ExternalModelError::InvalidInputDimensions {
expected: self.config.n_inputs,
actual: inputs.len(),
});
}
// Mock: returns identity-like Jacobian
let mut jacobian = vec![0.0; self.config.n_inputs * self.config.n_outputs];
let min_dim = self.config.n_inputs.min(self.config.n_outputs);
for i in 0..min_dim {
jacobian[i * self.config.n_inputs + i] = 1.0;
}
Ok(jacobian)
}
fn metadata(&self) -> ExternalModelMetadata {
self.metadata.clone()
}
}
/// Thread-safe wrapper for external models.
///
/// This wrapper ensures safe concurrent access to external models,
/// which may not be thread-safe themselves.
pub struct ThreadSafeExternalModel {
inner: Arc<dyn ExternalModel>,
}
impl ThreadSafeExternalModel {
/// Creates a new thread-safe wrapper.
pub fn new(model: impl ExternalModel + 'static) -> Self {
Self {
inner: Arc::new(model),
}
}
/// Creates from an existing Arc.
pub fn from_arc(model: Arc<dyn ExternalModel>) -> Self {
Self { inner: model }
}
/// Returns a reference to the inner model.
pub fn inner(&self) -> &dyn ExternalModel {
self.inner.as_ref()
}
}
impl Clone for ThreadSafeExternalModel {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl std::fmt::Debug for ThreadSafeExternalModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ThreadSafeExternalModel")
.field("id", &self.inner.id())
.finish()
}
}
/// Mock external model for testing.
#[derive(Debug, Clone)]
pub struct MockExternalModel {
id: String,
n_inputs: usize,
n_outputs: usize,
compute_fn: fn(&[f64]) -> Vec<f64>,
}
impl MockExternalModel {
/// Creates a new mock model.
pub fn new(
id: impl Into<String>,
n_inputs: usize,
n_outputs: usize,
compute_fn: fn(&[f64]) -> Vec<f64>,
) -> Self {
Self {
id: id.into(),
n_inputs,
n_outputs,
compute_fn,
}
}
/// Creates a simple linear model: y = x
pub fn linear_passthrough(n: usize) -> Self {
Self::new("linear_passthrough", n, n, |x| x.to_vec())
}
/// Creates a model that doubles inputs.
pub fn doubler(n: usize) -> Self {
Self::new("doubler", n, n, |x| x.iter().map(|v| v * 2.0).collect())
}
}
impl ExternalModel for MockExternalModel {
fn id(&self) -> &str {
&self.id
}
fn n_inputs(&self) -> usize {
self.n_inputs
}
fn n_outputs(&self) -> usize {
self.n_outputs
}
fn compute(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
if inputs.len() != self.n_inputs {
return Err(ExternalModelError::InvalidInputDimensions {
expected: self.n_inputs,
actual: inputs.len(),
});
}
Ok((self.compute_fn)(inputs))
}
fn jacobian(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
// Default: finite difference approximation
let h = 1e-6;
let mut jacobian = vec![0.0; self.n_outputs * self.n_inputs];
for j in 0..self.n_inputs {
let mut inputs_plus = inputs.to_vec();
let mut inputs_minus = inputs.to_vec();
inputs_plus[j] += h;
inputs_minus[j] -= h;
let y_plus = self.compute(&inputs_plus)?;
let y_minus = self.compute(&inputs_minus)?;
for i in 0..self.n_outputs {
jacobian[i * self.n_inputs + j] = (y_plus[i] - y_minus[i]) / (2.0 * h);
}
}
Ok(jacobian)
}
fn metadata(&self) -> ExternalModelMetadata {
ExternalModelMetadata {
name: self.id.clone(),
version: "1.0.0".to_string(),
description: Some("Mock external model for testing".to_string()),
input_names: (0..self.n_inputs).map(|i| format!("input_{}", i)).collect(),
output_names: (0..self.n_outputs)
.map(|i| format!("output_{}", i))
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mock_external_model_compute() {
let model = MockExternalModel::doubler(3);
let result = model.compute(&[1.0, 2.0, 3.0]).unwrap();
assert_eq!(result, vec![2.0, 4.0, 6.0]);
}
#[test]
fn test_mock_external_model_dimensions() {
let model = MockExternalModel::doubler(3);
assert_eq!(model.n_inputs(), 3);
assert_eq!(model.n_outputs(), 3);
}
#[test]
fn test_mock_external_model_invalid_input() {
let model = MockExternalModel::doubler(3);
let result = model.compute(&[1.0, 2.0]);
assert!(result.is_err());
}
#[test]
fn test_mock_external_model_jacobian() {
let model = MockExternalModel::doubler(2);
let jac = model.jacobian(&[1.0, 2.0]).unwrap();
// Jacobian of y = 2x should be [[2, 0], [0, 2]]
assert!((jac[0] - 2.0).abs() < 0.01);
assert!((jac[1] - 0.0).abs() < 0.01);
assert!((jac[2] - 0.0).abs() < 0.01);
assert!((jac[3] - 2.0).abs() < 0.01);
}
#[test]
fn test_thread_safe_wrapper() {
let model = MockExternalModel::doubler(2);
let wrapped = ThreadSafeExternalModel::new(model);
let result = wrapped.inner().compute(&[1.0, 2.0]).unwrap();
assert_eq!(result, vec![2.0, 4.0]);
}
#[test]
fn test_thread_safe_clone() {
let model = MockExternalModel::doubler(2);
let wrapped = ThreadSafeExternalModel::new(model);
let cloned = wrapped.clone();
assert_eq!(wrapped.inner().id(), cloned.inner().id());
}
#[test]
fn test_external_model_metadata() {
let model = MockExternalModel::doubler(2);
let meta = model.metadata();
assert_eq!(meta.name, "doubler");
assert_eq!(meta.version, "1.0.0");
assert_eq!(meta.input_names, vec!["input_0", "input_1"]);
assert_eq!(meta.output_names, vec!["output_0", "output_1"]);
}
#[test]
fn test_linear_passthrough_model() {
let model = MockExternalModel::linear_passthrough(3);
let result = model.compute(&[1.0, 2.0, 3.0]).unwrap();
assert_eq!(result, vec![1.0, 2.0, 3.0]);
}
#[test]
fn test_external_model_config() {
let config = ExternalModelConfig {
id: "test_model".to_string(),
model_type: ExternalModelType::Http {
base_url: "http://localhost:8080".to_string(),
api_key: Some("secret".to_string()),
},
n_inputs: 4,
n_outputs: 2,
timeout_ms: 3000,
};
assert_eq!(config.id, "test_model");
assert_eq!(config.n_inputs, 4);
assert_eq!(config.n_outputs, 2);
assert_eq!(config.timeout_ms, 3000);
}
#[test]
fn test_error_conversion() {
let err = ExternalModelError::ComputationFailed("test error".to_string());
let component_err: ComponentError = err.into();
match component_err {
ComponentError::InvalidState(msg) => {
assert!(msg.contains("External model error"));
}
_ => panic!("Expected InvalidState error"),
}
}
}

View File

@@ -0,0 +1,636 @@
//! Fan Component Implementation
//!
//! This module provides a fan component for air handling systems using
//! polynomial performance curves and affinity laws for variable speed operation.
//!
//! ## Performance Curves
//!
//! **Static Pressure Curve:** P_s = a₀ + a₁Q + a₂Q² + a₃Q³
//!
//! **Efficiency Curve:** η = b₀ + b₁Q + b₂Q²
//!
//! **Fan Power:** P_fan = Q × P_s / η
//!
//! ## Affinity Laws (Variable Speed)
//!
//! When operating at reduced speed (VFD):
//! - Q₂/Q₁ = N₂/N₁
//! - P₂/P₁ = (N₂/N₁)²
//! - Pwr₂/Pwr₁ = (N₂/N₁)³
use crate::polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D};
use crate::port::{Connected, Disconnected, FluidId, Port};
use crate::state_machine::StateManageable;
use crate::{
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
ResidualVector, SystemState,
};
use entropyk_core::{MassFlow, Power};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
/// Fan performance curve coefficients.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FanCurves {
/// Performance curves (static pressure, efficiency)
curves: PerformanceCurves,
}
impl FanCurves {
/// Creates fan curves from performance curves.
pub fn new(curves: PerformanceCurves) -> Result<Self, ComponentError> {
curves.validate()?;
Ok(Self { curves })
}
/// Creates fan curves from polynomial coefficients.
///
/// # Arguments
///
/// * `pressure_coeffs` - Static pressure curve [a0, a1, a2, ...] in Pa
/// * `eff_coeffs` - Efficiency coefficients [b0, b1, b2, ...] as decimal
///
/// # Units
///
/// * Q (flow) in m³/s
/// * P_s (static pressure) in Pascals
/// * η (efficiency) as decimal (0.0 to 1.0)
pub fn from_coefficients(
pressure_coeffs: Vec<f64>,
eff_coeffs: Vec<f64>,
) -> Result<Self, ComponentError> {
let pressure_curve = Polynomial1D::new(pressure_coeffs);
let eff_curve = Polynomial1D::new(eff_coeffs);
let curves = PerformanceCurves::simple(pressure_curve, eff_curve);
Self::new(curves)
}
/// Creates a quadratic fan curve.
pub fn quadratic(
p0: f64,
p1: f64,
p2: f64,
e0: f64,
e1: f64,
e2: f64,
) -> Result<Self, ComponentError> {
Self::from_coefficients(vec![p0, p1, p2], vec![e0, e1, e2])
}
/// Creates a cubic fan curve (common for fans).
pub fn cubic(
p0: f64,
p1: f64,
p2: f64,
p3: f64,
e0: f64,
e1: f64,
e2: f64,
) -> Result<Self, ComponentError> {
Self::from_coefficients(vec![p0, p1, p2, p3], vec![e0, e1, e2])
}
/// Returns static pressure at given flow rate (full speed).
pub fn static_pressure_at_flow(&self, flow_m3_per_s: f64) -> f64 {
self.curves.head_curve.evaluate(flow_m3_per_s)
}
/// Returns efficiency at given flow rate (full speed).
pub fn efficiency_at_flow(&self, flow_m3_per_s: f64) -> f64 {
let eta = self.curves.efficiency_curve.evaluate(flow_m3_per_s);
eta.clamp(0.0, 1.0)
}
/// Returns reference to performance curves.
pub fn curves(&self) -> &PerformanceCurves {
&self.curves
}
}
impl Default for FanCurves {
fn default() -> Self {
Self::quadratic(500.0, 0.0, 0.0, 0.7, 0.0, 0.0).unwrap()
}
}
/// Standard air properties at sea level (for reference).
pub mod standard_air {
/// Standard air density at 20°C, 101325 Pa (kg/m³)
pub const DENSITY: f64 = 1.204;
/// Standard air specific heat at constant pressure (J/(kg·K))
pub const CP: f64 = 1005.0;
}
/// A fan component with polynomial performance curves.
///
/// Fans differ from pumps in that:
/// - They work with compressible fluids (air)
/// - Static pressure is typically much lower
/// - Common to use cubic curves for pressure
///
/// # Example
///
/// ```ignore
/// use entropyk_components::fan::{Fan, FanCurves};
/// use entropyk_components::port::{FluidId, Port};
/// use entropyk_core::{Pressure, Enthalpy};
///
/// // Create fan curves: P_s = 500 - 50*Q - 10*Q² (Pa, m³/s)
/// let curves = FanCurves::quadratic(500.0, -50.0, -10.0, 0.5, 0.2, -0.1).unwrap();
///
/// let inlet = Port::new(
/// FluidId::new("Air"),
/// Pressure::from_bar(1.01325),
/// Enthalpy::from_joules_per_kg(300000.0),
/// );
/// let outlet = Port::new(
/// FluidId::new("Air"),
/// Pressure::from_bar(1.01325),
/// Enthalpy::from_joules_per_kg(300000.0),
/// );
///
/// let fan = Fan::new(curves, inlet, outlet, 1.2).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct Fan<State> {
/// Performance curves
curves: FanCurves,
/// Inlet port
port_inlet: Port<State>,
/// Outlet port
port_outlet: Port<State>,
/// Air density in kg/m³
air_density_kg_per_m3: f64,
/// Speed ratio (0.0 to 1.0)
speed_ratio: f64,
/// Circuit identifier
circuit_id: CircuitId,
/// Operational state
operational_state: OperationalState,
/// Phantom data for type state
_state: PhantomData<State>,
}
impl Fan<Disconnected> {
/// Creates a new disconnected fan.
///
/// # Arguments
///
/// * `curves` - Fan performance curves
/// * `port_inlet` - Inlet port (disconnected)
/// * `port_outlet` - Outlet port (disconnected)
/// * `air_density` - Air density in kg/m³ (use 1.2 for standard conditions)
pub fn new(
curves: FanCurves,
port_inlet: Port<Disconnected>,
port_outlet: Port<Disconnected>,
air_density: f64,
) -> Result<Self, ComponentError> {
if port_inlet.fluid_id() != port_outlet.fluid_id() {
return Err(ComponentError::InvalidState(
"Inlet and outlet ports must have the same fluid type".to_string(),
));
}
if air_density <= 0.0 {
return Err(ComponentError::InvalidState(
"Air density must be positive".to_string(),
));
}
Ok(Self {
curves,
port_inlet,
port_outlet,
air_density_kg_per_m3: air_density,
speed_ratio: 1.0,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
_state: PhantomData,
})
}
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &FluidId {
self.port_inlet.fluid_id()
}
/// Returns the air density.
pub fn air_density(&self) -> f64 {
self.air_density_kg_per_m3
}
/// Returns the speed ratio.
pub fn speed_ratio(&self) -> f64 {
self.speed_ratio
}
/// Sets the speed ratio (0.0 to 1.0).
pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> {
if !(0.0..=1.0).contains(&ratio) {
return Err(ComponentError::InvalidState(
"Speed ratio must be between 0.0 and 1.0".to_string(),
));
}
self.speed_ratio = ratio;
Ok(())
}
}
impl Fan<Connected> {
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&self.port_outlet
}
/// Calculates the static pressure rise across the fan.
///
/// Applies affinity laws for variable speed operation.
pub fn static_pressure_rise(&self, flow_m3_per_s: f64) -> f64 {
// Handle zero speed - fan produces no pressure
if self.speed_ratio <= 0.0 {
return 0.0;
}
// Handle zero flow
if flow_m3_per_s <= 0.0 {
let pressure = self.curves.static_pressure_at_flow(0.0);
return AffinityLaws::scale_head(pressure, self.speed_ratio);
}
let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio);
let pressure = self.curves.static_pressure_at_flow(equivalent_flow);
AffinityLaws::scale_head(pressure, self.speed_ratio)
}
/// Calculates total pressure (static + velocity pressure).
///
/// Total pressure = Static pressure + ½ρ
///
/// # Arguments
///
/// * `flow_m3_per_s` - Volumetric flow rate
/// * `duct_area_m2` - Duct cross-sectional area
pub fn total_pressure_rise(&self, flow_m3_per_s: f64, duct_area_m2: f64) -> f64 {
let static_p = self.static_pressure_rise(flow_m3_per_s);
if duct_area_m2 <= 0.0 {
return static_p;
}
// Velocity pressure: P_v = ½ρ
let velocity = flow_m3_per_s / duct_area_m2;
let velocity_pressure = 0.5 * self.air_density_kg_per_m3 * velocity * velocity;
static_p + velocity_pressure
}
/// Calculates efficiency at the given flow rate.
pub fn efficiency(&self, flow_m3_per_s: f64) -> f64 {
// Handle zero speed - fan is not running
if self.speed_ratio <= 0.0 {
return 0.0;
}
// Handle zero flow
if flow_m3_per_s <= 0.0 {
return self.curves.efficiency_at_flow(0.0);
}
let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio);
self.curves.efficiency_at_flow(equivalent_flow)
}
/// Calculates the fan power consumption.
///
/// P_fan = Q × P_s / η
pub fn fan_power(&self, flow_m3_per_s: f64) -> Power {
if flow_m3_per_s <= 0.0 || self.speed_ratio <= 0.0 {
return Power::from_watts(0.0);
}
let pressure = self.static_pressure_rise(flow_m3_per_s);
let eta = self.efficiency(flow_m3_per_s);
if eta <= 0.0 {
return Power::from_watts(0.0);
}
let power_w = flow_m3_per_s * pressure / eta;
Power::from_watts(power_w)
}
/// Calculates mass flow from volumetric flow.
pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow {
MassFlow::from_kg_per_s(flow_m3_per_s * self.air_density_kg_per_m3)
}
/// Calculates volumetric flow from mass flow.
pub fn volumetric_from_mass_flow(&self, mass_flow: MassFlow) -> f64 {
mass_flow.to_kg_per_s() / self.air_density_kg_per_m3
}
/// Returns the air density.
pub fn air_density(&self) -> f64 {
self.air_density_kg_per_m3
}
/// Returns the speed ratio.
pub fn speed_ratio(&self) -> f64 {
self.speed_ratio
}
/// Sets the speed ratio (0.0 to 1.0).
pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> {
if !(0.0..=1.0).contains(&ratio) {
return Err(ComponentError::InvalidState(
"Speed ratio must be between 0.0 and 1.0".to_string(),
));
}
self.speed_ratio = ratio;
Ok(())
}
/// Returns both ports as a slice for solver topology.
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
}
impl Component for Fan<Connected> {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() != self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.operational_state {
OperationalState::Off => {
residuals[0] = state[0];
residuals[1] = 0.0;
return Ok(());
}
OperationalState::Bypass => {
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
residuals[0] = p_in - p_out;
residuals[1] = h_in - h_out;
return Ok(());
}
OperationalState::On => {}
}
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
}
let mass_flow_kg_s = state[0];
let _power_w = state[1];
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let delta_p_calc = self.static_pressure_rise(flow_m3_s);
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let delta_p_actual = p_out - p_in;
residuals[0] = delta_p_calc - delta_p_actual;
let power_calc = self.fan_power(flow_m3_s).to_watts();
residuals[1] = power_calc - _power_w;
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
}
let mass_flow_kg_s = state[0];
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
let h = 0.001;
let p_plus = self.static_pressure_rise(flow_m3_s + h / self.air_density_kg_per_m3);
let p_minus = self.static_pressure_rise(flow_m3_s - h / self.air_density_kg_per_m3);
let dp_dm = (p_plus - p_minus) / (2.0 * h);
jacobian.add_entry(0, 0, dp_dm);
jacobian.add_entry(0, 1, 0.0);
let pow_plus = self
.fan_power(flow_m3_s + h / self.air_density_kg_per_m3)
.to_watts();
let pow_minus = self
.fan_power(flow_m3_s - h / self.air_density_kg_per_m3)
.to_watts();
let dpow_dm = (pow_plus - pow_minus) / (2.0 * h);
jacobian.add_entry(1, 0, dpow_dm);
jacobian.add_entry(1, 1, -1.0);
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
impl StateManageable for Fan<Connected> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
let from = self.operational_state;
self.operational_state = state;
self.on_state_change(from, state);
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::port::FluidId;
use approx::assert_relative_eq;
use entropyk_core::{Enthalpy, Pressure};
fn create_test_curves() -> FanCurves {
// Typical centrifugal fan:
// P_s = 500 - 100*Q - 200*Q² (Pa, Q in m³/s)
// η = 0.5 + 0.3*Q - 0.5*Q²
FanCurves::quadratic(500.0, -100.0, -200.0, 0.5, 0.3, -0.5).unwrap()
}
fn create_test_fan_connected() -> Fan<Connected> {
let curves = create_test_curves();
let inlet = Port::new(
FluidId::new("Air"),
Pressure::from_bar(1.01325),
Enthalpy::from_joules_per_kg(300000.0),
);
let outlet = Port::new(
FluidId::new("Air"),
Pressure::from_bar(1.01325),
Enthalpy::from_joules_per_kg(300000.0),
);
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
Fan {
curves,
port_inlet: inlet_conn,
port_outlet: outlet_conn,
air_density_kg_per_m3: 1.2,
speed_ratio: 1.0,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
_state: PhantomData,
}
}
#[test]
fn test_fan_curves_creation() {
let curves = create_test_curves();
assert_eq!(curves.static_pressure_at_flow(0.0), 500.0);
assert_relative_eq!(curves.efficiency_at_flow(0.0), 0.5);
}
#[test]
fn test_fan_static_pressure() {
let curves = create_test_curves();
// P_s = 500 - 100*1 - 200*1 = 200 Pa
let pressure = curves.static_pressure_at_flow(1.0);
assert_relative_eq!(pressure, 200.0, epsilon = 1e-10);
}
#[test]
fn test_fan_creation() {
let fan = create_test_fan_connected();
assert_relative_eq!(fan.air_density(), 1.2, epsilon = 1e-10);
assert_eq!(fan.speed_ratio(), 1.0);
}
#[test]
fn test_fan_pressure_rise_full_speed() {
let fan = create_test_fan_connected();
let pressure = fan.static_pressure_rise(0.0);
assert_relative_eq!(pressure, 500.0, epsilon = 1e-10);
}
#[test]
fn test_fan_pressure_rise_half_speed() {
let mut fan = create_test_fan_connected();
fan.set_speed_ratio(0.5).unwrap();
// At 50% speed, shut-off pressure is 25% of full speed
let pressure = fan.static_pressure_rise(0.0);
assert_relative_eq!(pressure, 125.0, epsilon = 1e-10);
}
#[test]
fn test_fan_fan_power() {
let fan = create_test_fan_connected();
// At Q=1 m³/s: P_s ≈ 200 Pa, η ≈ 0.3
// P = 1 * 200 / 0.3 ≈ 667 W
let power = fan.fan_power(1.0);
assert!(power.to_watts() > 0.0);
assert!(power.to_watts() < 2000.0);
}
#[test]
fn test_fan_affinity_laws_power() {
let fan_full = create_test_fan_connected();
let mut fan_half = create_test_fan_connected();
fan_half.set_speed_ratio(0.5).unwrap();
let power_full = fan_full.fan_power(1.0);
let power_half = fan_half.fan_power(0.5);
// Ratio should be approximately 0.125 (cube law)
let ratio = power_half.to_watts() / power_full.to_watts();
assert_relative_eq!(ratio, 0.125, epsilon = 0.1);
}
#[test]
fn test_fan_total_pressure() {
let fan = create_test_fan_connected();
// With a duct area of 0.5 m²
let total_p = fan.total_pressure_rise(1.0, 0.5);
let static_p = fan.static_pressure_rise(1.0);
// Total > Static due to velocity pressure
assert!(total_p > static_p);
}
#[test]
fn test_fan_component_n_equations() {
let fan = create_test_fan_connected();
assert_eq!(fan.n_equations(), 2);
}
#[test]
fn test_fan_state_manageable() {
let fan = create_test_fan_connected();
assert_eq!(fan.state(), OperationalState::On);
assert!(fan.can_transition_to(OperationalState::Off));
}
#[test]
fn test_standard_air_constants() {
assert_relative_eq!(standard_air::DENSITY, 1.204, epsilon = 0.01);
assert_relative_eq!(standard_air::CP, 1005.0);
}
}

View File

@@ -0,0 +1,249 @@
//! Condenser Component
//!
//! A heat exchanger configured for refrigerant condensation.
//! The refrigerant (hot side) condenses from superheated vapor to
//! subcooled liquid, releasing heat to the cold side.
use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Condenser heat exchanger.
///
/// Uses the LMTD method for heat transfer calculation.
/// The refrigerant condenses on the hot side, releasing heat
/// to the cold side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Refrigerant condensing (phase change)
/// - Cold side: Heat sink (water, air, etc.)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Condenser;
/// use entropyk_components::Component;
///
/// let condenser = Condenser::new(10_000.0); // UA = 10 kW/K
/// assert_eq!(condenser.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct Condenser {
/// Inner heat exchanger with LMTD model
inner: HeatExchanger<LmtdModel>,
/// Saturation temperature for condensation (K)
saturation_temp: f64,
}
impl Condenser {
/// Creates a new condenser with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Condenser;
///
/// let condenser = Condenser::new(15_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Condenser"),
saturation_temp: 323.15,
}
}
/// Creates a condenser with a specific saturation temperature.
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Condenser"),
saturation_temp,
}
}
/// Returns the name of this condenser.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value (effective: f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns calibration factors (f_ua for condenser).
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.inner.set_calib(calib);
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.saturation_temp
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.saturation_temp = temp;
}
/// Validates that the outlet quality is <= 1 (fully condensed or subcooled).
///
/// # Arguments
///
/// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
/// * `h_liquid` - Saturated liquid enthalpy at condensing pressure
/// * `h_vapor` - Saturated vapor enthalpy at condensing pressure
///
/// # Returns
///
/// Returns Ok(true) if fully condensed, Err otherwise
pub fn validate_outlet_quality(
&self,
outlet_enthalpy: f64,
h_liquid: f64,
h_vapor: f64,
) -> Result<bool, ComponentError> {
if h_vapor <= h_liquid {
return Err(ComponentError::NumericalError(
"Invalid saturation enthalpies".to_string(),
));
}
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality <= 1.0 + 1e-6 {
Ok(true)
} else {
Err(ComponentError::InvalidState(format!(
"Condenser outlet quality {} > 1 (superheated)",
quality
)))
}
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.hot_inlet_state()
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
}
impl Component for Condenser {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for Condenser {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_condenser_creation() {
let condenser = Condenser::new(10_000.0);
assert_eq!(condenser.ua(), 10_000.0);
assert_eq!(condenser.n_equations(), 3);
}
#[test]
fn test_condenser_with_saturation_temp() {
let condenser = Condenser::with_saturation_temp(10_000.0, 323.15);
assert_eq!(condenser.saturation_temp(), 323.15);
}
#[test]
fn test_validate_outlet_quality_fully_condensed() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 180_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_ok());
}
#[test]
fn test_validate_outlet_quality_superheated() {
let condenser = Condenser::new(10_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 450_000.0;
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
assert!(result.is_err());
}
#[test]
fn test_compute_residuals() {
let condenser = Condenser::new(10_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = condenser.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,195 @@
//! Condenser Coil Component
//!
//! An air-side (finned) heat exchanger for refrigerant condensation.
//! The refrigerant (hot side) condenses, releasing heat to air (cold side).
//! Used in split systems and air-source heat pumps.
//!
//! ## Port Convention
//!
//! - **Hot side (refrigerant)**: Condensing
//! - **Cold side (air)**: Heat sink — connect to Fan outlet/inlet
//!
//! ## Integration with Fan
//!
//! Connect Fan outlet → CondenserCoil air inlet, CondenserCoil air outlet → Fan inlet.
//! Use `FluidId::new("Air")` for air ports.
use super::condenser::Condenser;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Condenser coil (air-side finned heat exchanger).
///
/// Explicit component for air-source condensers. Uses LMTD method.
/// Refrigerant condenses on hot side, air on cold side.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::CondenserCoil;
/// use entropyk_components::Component;
///
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
/// assert_eq!(coil.ua(), 10_000.0);
/// assert_eq!(coil.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct CondenserCoil {
inner: Condenser,
}
impl CondenserCoil {
/// Creates a new condenser coil with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
pub fn new(ua: f64) -> Self {
Self {
inner: Condenser::new(ua),
}
}
/// Creates a condenser coil with a specific saturation temperature.
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
Self {
inner: Condenser::with_saturation_temp(ua, saturation_temp),
}
}
/// Returns the name of this component.
pub fn name(&self) -> &str {
"CondenserCoil"
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.inner.saturation_temp()
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.inner.set_saturation_temp(temp);
}
}
impl Component for CondenserCoil {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for CondenserCoil {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_condenser_coil_creation() {
let coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.ua(), 10_000.0);
assert_eq!(coil.name(), "CondenserCoil");
}
#[test]
fn test_condenser_coil_n_equations() {
let coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.n_equations(), 3);
}
#[test]
fn test_condenser_coil_with_saturation_temp() {
let coil = CondenserCoil::with_saturation_temp(10_000.0, 323.15);
assert_eq!(coil.saturation_temp(), 323.15);
}
#[test]
fn test_condenser_coil_compute_residuals() {
let coil = CondenserCoil::new(10_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
}
#[test]
fn test_condenser_coil_jacobian_entries() {
let coil = CondenserCoil::new(10_000.0);
let state = vec![0.0; 10];
let mut jacobian = crate::JacobianBuilder::new();
let result = coil.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
// HeatExchanger base returns empty jacobian until framework implements it
assert!(
jacobian.is_empty(),
"delegation works; empty jacobian expected until HeatExchanger implements entries"
);
}
#[test]
fn test_condenser_coil_set_saturation_temp() {
let mut coil = CondenserCoil::new(10_000.0);
coil.set_saturation_temp(320.0);
assert!((coil.saturation_temp() - 320.0).abs() < 1e-10);
}
#[test]
fn test_condenser_coil_state_manageable() {
use crate::state_machine::{OperationalState, StateManageable};
let mut coil = CondenserCoil::new(10_000.0);
assert_eq!(coil.state(), OperationalState::On);
assert!(coil.can_transition_to(OperationalState::Off));
assert!(coil.set_state(OperationalState::Off).is_ok());
assert_eq!(coil.state(), OperationalState::Off);
}
}

View File

@@ -0,0 +1,251 @@
//! Economizer Component
//!
//! An internal heat exchanger with bypass support for refrigeration systems.
/// Can be switched between ON (active heat exchange), OFF (no flow), and
/// BYPASS (adiabatic pipe) modes.
use super::exchanger::HeatExchanger;
use super::lmtd::{FlowConfiguration, LmtdModel};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector,
SystemState,
};
/// Economizer (internal heat exchanger) with state machine support.
///
/// The economizer can operate in three modes:
/// - **ON**: Normal heat exchange between suction and liquid lines
/// - **OFF**: No mass flow contribution (component disabled)
/// - **BYPASS**: Adiabatic pipe (P_in = P_out, h_in = h_out)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Economizer;
/// use entropyk_components::OperationalState;
///
/// let mut economizer = Economizer::new(2_000.0);
/// assert_eq!(economizer.state(), OperationalState::On);
///
/// economizer.set_state(OperationalState::Bypass);
/// assert_eq!(economizer.state(), OperationalState::Bypass);
/// ```
#[derive(Debug)]
pub struct Economizer {
/// Inner heat exchanger with LMTD model
inner: HeatExchanger<LmtdModel>,
/// Operational state
state: OperationalState,
}
impl Economizer {
/// Creates a new economizer with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Economizer;
///
/// let economizer = Economizer::new(2_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Economizer"),
state: OperationalState::On,
}
}
/// Creates an economizer in a specific state.
pub fn with_state(ua: f64, state: OperationalState) -> Self {
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Economizer"),
state,
}
}
/// Returns the name of this economizer.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the current operational state.
pub fn state(&self) -> OperationalState {
self.state
}
/// Sets the operational state.
pub fn set_state(&mut self, state: OperationalState) {
self.state = state;
}
/// Returns true if the economizer is active (ON or BYPASS).
pub fn is_active(&self) -> bool {
self.state.is_active()
}
/// Returns true if in bypass mode (adiabatic pipe behavior).
pub fn is_bypass(&self) -> bool {
self.state.is_bypass()
}
/// Returns the mass flow multiplier based on state.
pub fn mass_flow_multiplier(&self) -> f64 {
self.state.mass_flow_multiplier()
}
/// Computes bypass residuals (P_in = P_out, h_in = h_out).
fn compute_bypass_residuals(&self, residuals: &mut ResidualVector) {
residuals[0] = 0.0;
residuals[1] = 0.0;
residuals[2] = 0.0;
}
/// Computes off residuals (zero flow).
fn compute_off_residuals(&self, residuals: &mut ResidualVector) {
residuals[0] = 0.0;
residuals[1] = 0.0;
residuals[2] = 0.0;
}
}
impl Component for Economizer {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.state {
OperationalState::On => self.inner.compute_residuals(state, residuals),
OperationalState::Off => {
self.compute_off_residuals(residuals);
Ok(())
}
OperationalState::Bypass => {
self.compute_bypass_residuals(residuals);
Ok(())
}
}
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
match self.state {
OperationalState::On => self.inner.jacobian_entries(state, jacobian),
OperationalState::Off | OperationalState::Bypass => Ok(()),
}
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_economizer_creation() {
let economizer = Economizer::new(2_000.0);
assert_eq!(economizer.ua(), 2_000.0);
assert_eq!(economizer.state(), OperationalState::On);
}
#[test]
fn test_economizer_with_state() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Bypass);
assert_eq!(economizer.state(), OperationalState::Bypass);
}
#[test]
fn test_state_transitions() {
let mut economizer = Economizer::new(2_000.0);
assert!(economizer.is_active());
assert!(!economizer.is_bypass());
economizer.set_state(OperationalState::Bypass);
assert!(economizer.is_active());
assert!(economizer.is_bypass());
economizer.set_state(OperationalState::Off);
assert!(!economizer.is_active());
assert!(!economizer.is_bypass());
}
#[test]
fn test_mass_flow_multiplier() {
let mut economizer = Economizer::new(2_000.0);
assert_eq!(economizer.mass_flow_multiplier(), 1.0);
economizer.set_state(OperationalState::Bypass);
assert_eq!(economizer.mass_flow_multiplier(), 1.0);
economizer.set_state(OperationalState::Off);
assert_eq!(economizer.mass_flow_multiplier(), 0.0);
}
#[test]
fn test_compute_residuals_on() {
let economizer = Economizer::new(2_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_compute_residuals_bypass() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Bypass);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_compute_residuals_off() {
let economizer = Economizer::with_state(2_000.0, OperationalState::Off);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = economizer.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_n_equations() {
let economizer = Economizer::new(2_000.0);
assert_eq!(economizer.n_equations(), 3);
}
}

View File

@@ -0,0 +1,344 @@
//! Effectiveness-NTU (ε-NTU) Model
//!
//! Implements the ε-NTU method for heat exchanger calculations.
//!
//! ## Theory
//!
//! The heat transfer rate is calculated as:
//!
//! $$\dot{Q} = \varepsilon \cdot \dot{Q}_{max} = \varepsilon \cdot C_{min} \cdot (T_{hot,in} - T_{cold,in})$$
//!
//! Where:
//! - $\varepsilon$: Effectiveness (0 to 1)
//! - $C_{min} = \min(\dot{m}_{hot} \cdot c_{p,hot}, \dot{m}_{cold} \cdot c_{p,cold})$: Minimum heat capacity rate
//! - $NTU = UA / C_{min}$: Number of Transfer Units
//! - $C_r = C_{min} / C_{max}$: Heat capacity ratio
//!
//! ## Zero-flow regularization (Story 3.5)
//!
//! When $C_{min} < 10^{-10}$ (e.g. zero mass flow on one side), heat transfer is set to zero
//! and divisions by $C_{min}$ or $C_r$ are avoided to prevent NaN/Inf.
//!
//! Note: This module uses `1e-10` kW/K for capacity rate regularization, which is appropriate
//! for the kW/K scale. For mass flow regularization at the kg/s scale, see
//! [`MIN_MASS_FLOW_REGULARIZATION_KG_S`](entropyk_core::MIN_MASS_FLOW_REGULARIZATION_KG_S).
//!
//! For counter-flow:
//! $$\varepsilon = \frac{1 - \exp(-NTU \cdot (1 - C_r))}{1 - C_r \cdot \exp(-NTU \cdot (1 - C_r))}$$
use super::model::{FluidState, HeatTransferModel};
use crate::ResidualVector;
use entropyk_core::Power;
/// Heat exchanger type for ε-NTU calculations.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum ExchangerType {
/// Counter-flow (most efficient)
#[default]
CounterFlow,
/// Parallel-flow (co-current)
ParallelFlow,
/// Cross-flow, both fluids unmixed
CrossFlowUnmixed,
/// Cross-flow, one fluid mixed (C_max mixed)
CrossFlowMixedMax,
/// Cross-flow, one fluid mixed (C_min mixed)
CrossFlowMixedMin,
/// Shell-and-tube with specified number of shell passes
ShellAndTube {
/// Number of shell passes
passes: usize,
},
}
/// ε-NTU (Effectiveness-NTU) heat transfer model.
///
/// Uses the effectiveness-NTU method for heat exchanger rating.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{EpsNtuModel, ExchangerType, HeatTransferModel};
///
/// let model = EpsNtuModel::new(5000.0, ExchangerType::CounterFlow);
/// assert_eq!(model.ua(), 5000.0);
/// ```
#[derive(Debug, Clone)]
pub struct EpsNtuModel {
/// Overall heat transfer coefficient × Area (W/K), nominal
ua: f64,
/// UA calibration scale: UA_eff = ua_scale × ua (default 1.0)
ua_scale: f64,
/// Heat exchanger type
exchanger_type: ExchangerType,
}
impl EpsNtuModel {
/// Creates a new ε-NTU model.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K). Must be non-negative.
/// * `exchanger_type` - Type of heat exchanger
///
/// # Panics
///
/// Panics if `ua` is negative or NaN.
pub fn new(ua: f64, exchanger_type: ExchangerType) -> Self {
assert!(
ua.is_finite() && ua >= 0.0,
"UA must be non-negative and finite, got {}",
ua
);
Self {
ua,
ua_scale: 1.0,
exchanger_type,
}
}
/// Creates a counter-flow ε-NTU model.
pub fn counter_flow(ua: f64) -> Self {
Self::new(ua, ExchangerType::CounterFlow)
}
/// Creates a parallel-flow ε-NTU model.
pub fn parallel_flow(ua: f64) -> Self {
Self::new(ua, ExchangerType::ParallelFlow)
}
/// Creates a cross-flow (unmixed) ε-NTU model.
pub fn cross_flow_unmixed(ua: f64) -> Self {
Self::new(ua, ExchangerType::CrossFlowUnmixed)
}
/// Calculates the effectiveness ε.
///
/// # Arguments
///
/// * `ntu` - Number of Transfer Units (UA / C_min)
/// * `c_r` - Heat capacity ratio (C_min / C_max)
///
/// # Returns
///
/// The effectiveness ε (0 to 1)
pub fn effectiveness(&self, ntu: f64, c_r: f64) -> f64 {
if ntu <= 0.0 {
return 0.0;
}
match self.exchanger_type {
ExchangerType::CounterFlow => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
let exp_term = (-ntu * (1.0 - c_r)).exp();
(1.0 - exp_term) / (1.0 - c_r * exp_term)
}
}
ExchangerType::ParallelFlow => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 - (-ntu * (1.0 + c_r)).exp()) / (1.0 + c_r)
}
}
ExchangerType::CrossFlowUnmixed => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
1.0 - (-c_r * (1.0 - (-ntu / c_r).exp())).exp()
}
}
ExchangerType::CrossFlowMixedMax => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
let ntu_c_r = ntu / c_r;
(1.0 - (-ntu_c_r).exp()) / c_r * (1.0 - (-c_r * ntu).exp())
}
}
ExchangerType::CrossFlowMixedMin => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 / c_r) * (1.0 - (-c_r * (1.0 - (-ntu).exp())).exp())
}
}
ExchangerType::ShellAndTube { passes: _ } => {
if c_r < 1e-10 {
1.0 - (-ntu).exp()
} else {
(1.0 - (-ntu * (1.0 + c_r * c_r).sqrt()).exp()) / (1.0 + c_r)
}
}
}
}
/// Calculates the maximum possible heat transfer rate.
///
/// Q̇_max = C_min × (T_hot,in - T_cold,in)
pub fn q_max(&self, c_min: f64, t_hot_in: f64, t_cold_in: f64) -> f64 {
c_min * (t_hot_in - t_cold_in).max(0.0)
}
}
impl HeatTransferModel for EpsNtuModel {
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
_hot_outlet: &FluidState,
cold_inlet: &FluidState,
_cold_outlet: &FluidState,
) -> Power {
let c_hot = hot_inlet.heat_capacity_rate();
let c_cold = cold_inlet.heat_capacity_rate();
let (c_min, c_max) = if c_hot < c_cold {
(c_hot, c_cold)
} else {
(c_cold, c_hot)
};
if c_min < 1e-10 {
return Power::from_watts(0.0);
}
let c_r = c_min / c_max;
let ntu = self.effective_ua() / c_min;
let effectiveness = self.effectiveness(ntu, c_r);
let q_max = self.q_max(c_min, hot_inlet.temperature, cold_inlet.temperature);
Power::from_watts(effectiveness * q_max)
}
fn compute_residuals(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
.to_watts();
let q_hot =
hot_inlet.mass_flow * hot_inlet.cp * (hot_inlet.temperature - hot_outlet.temperature);
let q_cold = cold_inlet.mass_flow
* cold_inlet.cp
* (cold_outlet.temperature - cold_inlet.temperature);
residuals[0] = q_hot - q;
residuals[1] = q_cold - q;
residuals[2] = q_hot - q_cold;
}
fn n_equations(&self) -> usize {
3
}
fn ua(&self) -> f64 {
self.ua
}
fn ua_scale(&self) -> f64 {
self.ua_scale
}
fn set_ua_scale(&mut self, s: f64) {
self.ua_scale = s;
}
fn effective_ua(&self) -> f64 {
self.ua * self.ua_scale
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_eps_ntu_model_creation() {
let model = EpsNtuModel::new(5000.0, ExchangerType::CounterFlow);
assert_eq!(model.ua(), 5000.0);
}
#[test]
fn test_effectiveness_counter_flow() {
let model = EpsNtuModel::counter_flow(5000.0);
let eps = model.effectiveness(5.0, 0.5);
assert!(eps > 0.0 && eps < 1.0);
let eps_cr_zero = model.effectiveness(5.0, 0.0);
assert!((eps_cr_zero - (1.0 - (-5.0_f64).exp())).abs() < 1e-10);
}
#[test]
fn test_effectiveness_parallel_flow() {
let model = EpsNtuModel::parallel_flow(5000.0);
let eps = model.effectiveness(5.0, 0.5);
assert!(eps > 0.0 && eps < 1.0);
assert!(eps < model.effectiveness(5.0, 0.5) + 0.1);
}
#[test]
fn test_effectiveness_zero_ntu() {
let model = EpsNtuModel::counter_flow(5000.0);
let eps = model.effectiveness(0.0, 0.5);
assert_eq!(eps, 0.0);
}
#[test]
fn test_compute_heat_transfer() {
let model = EpsNtuModel::counter_flow(5000.0);
let hot_inlet = FluidState::new(80.0 + 273.15, 101_325.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = FluidState::new(60.0 + 273.15, 101_325.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = FluidState::new(20.0 + 273.15, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet = FluidState::new(30.0 + 273.15, 101_325.0, 120_000.0, 0.2, 4180.0);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
assert!(q.to_watts() > 0.0);
}
#[test]
fn test_n_equations() {
let model = EpsNtuModel::counter_flow(1000.0);
assert_eq!(model.n_equations(), 3);
}
#[test]
fn test_q_max() {
let model = EpsNtuModel::counter_flow(5000.0);
let c_min = 1000.0;
let t_hot_in = 350.0;
let t_cold_in = 300.0;
let q_max = model.q_max(c_min, t_hot_in, t_cold_in);
assert_eq!(q_max, 50_000.0);
}
#[test]
#[should_panic(expected = "UA must be non-negative")]
fn test_negative_ua_panics() {
let _model = EpsNtuModel::new(-1000.0, ExchangerType::CounterFlow);
}
#[test]
fn test_effectiveness_cross_flow_unmixed_cr_zero() {
let model = EpsNtuModel::cross_flow_unmixed(5000.0);
let eps = model.effectiveness(5.0, 0.0);
let expected = 1.0 - (-5.0_f64).exp();
assert!((eps - expected).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,292 @@
//! Evaporator Component
//!
//! A heat exchanger configured for refrigerant evaporation.
//! The refrigerant (cold side) evaporates from two-phase mixture to
/// superheated vapor, absorbing heat from the hot side.
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::HeatExchanger;
use entropyk_core::Calib;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Evaporator heat exchanger.
///
/// Uses the ε-NTU method for heat transfer calculation.
/// The refrigerant evaporates on the cold side, absorbing heat
/// from the hot side (typically water or air).
///
/// # Configuration
///
/// - Hot side: Heat source (water, air, etc.)
/// - Cold side: Refrigerant evaporating (phase change)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Evaporator;
/// use entropyk_components::Component;
///
/// let evaporator = Evaporator::new(8_000.0); // UA = 8 kW/K
/// assert_eq!(evaporator.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct Evaporator {
/// Inner heat exchanger with ε-NTU model
inner: HeatExchanger<EpsNtuModel>,
/// Saturation temperature for evaporation (K)
saturation_temp: f64,
/// Target superheat (K)
superheat_target: f64,
}
impl Evaporator {
/// Creates a new evaporator with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::Evaporator;
///
/// let evaporator = Evaporator::new(8_000.0);
/// ```
pub fn new(ua: f64) -> Self {
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Evaporator"),
saturation_temp: 278.15,
superheat_target: 5.0,
}
}
/// Creates an evaporator with specific saturation and superheat.
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
Self {
inner: HeatExchanger::new(model, "Evaporator"),
saturation_temp,
superheat_target,
}
}
/// Returns the name of this evaporator.
pub fn name(&self) -> &str {
self.inner.name()
}
/// Returns the UA value (effective: f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns calibration factors (f_ua for evaporator).
pub fn calib(&self) -> &Calib {
self.inner.calib()
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.inner.set_calib(calib);
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.saturation_temp
}
/// Returns the superheat target.
pub fn superheat_target(&self) -> f64 {
self.superheat_target
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.saturation_temp = temp;
}
/// Sets the superheat target.
pub fn set_superheat_target(&mut self, superheat: f64) {
self.superheat_target = superheat;
}
/// Validates that the outlet quality is >= 0 (fully evaporated or superheated).
///
/// # Arguments
///
/// * `outlet_enthalpy` - Outlet specific enthalpy (J/kg)
/// * `h_liquid` - Saturated liquid enthalpy at evaporating pressure
/// * `h_vapor` - Saturated vapor enthalpy at evaporating pressure
///
/// # Returns
///
/// Returns Ok(superheat) if valid, Err otherwise
pub fn validate_outlet_quality(
&self,
outlet_enthalpy: f64,
h_liquid: f64,
h_vapor: f64,
cp_vapor: f64,
) -> Result<f64, ComponentError> {
if h_vapor <= h_liquid {
return Err(ComponentError::NumericalError(
"Invalid saturation enthalpies".to_string(),
));
}
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
if quality >= 0.0 - 1e-6 {
if outlet_enthalpy >= h_vapor {
let superheat = (outlet_enthalpy - h_vapor) / cp_vapor;
Ok(superheat)
} else {
Ok(0.0)
}
} else {
Err(ComponentError::InvalidState(format!(
"Evaporator outlet quality {} < 0 (subcooled)",
quality
)))
}
}
/// Calculates the superheat residual for inverse control.
///
/// Returns (actual_superheat - target_superheat)
pub fn superheat_residual(&self, actual_superheat: f64) -> f64 {
actual_superheat - self.superheat_target
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.hot_inlet_state()
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
self.inner.cold_inlet_state()
}
}
impl Component for Evaporator {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for Evaporator {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaporator_creation() {
let evaporator = Evaporator::new(8_000.0);
assert_eq!(evaporator.ua(), 8_000.0);
assert_eq!(evaporator.n_equations(), 3);
}
#[test]
fn test_evaporator_with_superheat() {
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 10.0);
assert_eq!(evaporator.saturation_temp(), 278.15);
assert_eq!(evaporator.superheat_target(), 10.0);
}
#[test]
fn test_validate_outlet_quality_superheated() {
let evaporator = Evaporator::new(8_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 420_000.0;
let cp_vapor = 1000.0;
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
assert!(result.is_ok());
let superheat = result.unwrap();
assert!((superheat - 20.0).abs() < 1e-10);
}
#[test]
fn test_validate_outlet_quality_subcooled() {
let evaporator = Evaporator::new(8_000.0);
let h_liquid = 200_000.0;
let h_vapor = 400_000.0;
let outlet_h = 150_000.0;
let cp_vapor = 1000.0;
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
assert!(result.is_err());
}
#[test]
fn test_superheat_residual() {
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 5.0);
let residual = evaporator.superheat_residual(7.0);
assert!((residual - 2.0).abs() < 1e-10);
let residual = evaporator.superheat_residual(3.0);
assert!((residual - (-2.0)).abs() < 1e-10);
}
#[test]
fn test_compute_residuals() {
let evaporator = Evaporator::new(8_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = evaporator.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
}

View File

@@ -0,0 +1,208 @@
//! Evaporator Coil Component
//!
//! An air-side (finned) heat exchanger for refrigerant evaporation.
//! The refrigerant (cold side) evaporates, absorbing heat from air (hot side).
//! Used in split systems and air-source heat pumps.
//!
//! ## Port Convention
//!
//! - **Hot side (air)**: Heat source — connect to Fan outlet/inlet
//! - **Cold side (refrigerant)**: Evaporating
//!
//! ## Integration with Fan
//!
//! Connect Fan outlet → EvaporatorCoil air inlet, EvaporatorCoil air outlet → Fan inlet.
//! Use `FluidId::new("Air")` for air ports.
use super::evaporator::Evaporator;
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
/// Evaporator coil (air-side finned heat exchanger).
///
/// Explicit component for air-source evaporators. Uses ε-NTU method.
/// Refrigerant evaporates on cold side, air on hot side.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::EvaporatorCoil;
/// use entropyk_components::Component;
///
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
/// assert_eq!(coil.ua(), 8_000.0);
/// assert_eq!(coil.n_equations(), 3);
/// ```
#[derive(Debug)]
pub struct EvaporatorCoil {
inner: Evaporator,
}
impl EvaporatorCoil {
/// Creates a new evaporator coil with the given UA value.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
pub fn new(ua: f64) -> Self {
Self {
inner: Evaporator::new(ua),
}
}
/// Creates an evaporator coil with specific saturation and superheat.
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
Self {
inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target),
}
}
/// Returns the name of this component.
pub fn name(&self) -> &str {
"EvaporatorCoil"
}
/// Returns the UA value.
pub fn ua(&self) -> f64 {
self.inner.ua()
}
/// Returns the saturation temperature.
pub fn saturation_temp(&self) -> f64 {
self.inner.saturation_temp()
}
/// Returns the superheat target.
pub fn superheat_target(&self) -> f64 {
self.inner.superheat_target()
}
/// Sets the saturation temperature.
pub fn set_saturation_temp(&mut self, temp: f64) {
self.inner.set_saturation_temp(temp);
}
/// Sets the superheat target.
pub fn set_superheat_target(&mut self, superheat: f64) {
self.inner.set_superheat_target(superheat);
}
}
impl Component for EvaporatorCoil {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
self.inner.compute_residuals(state, residuals)
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
}
fn n_equations(&self) -> usize {
self.inner.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
self.inner.get_ports()
}
}
impl StateManageable for EvaporatorCoil {
fn state(&self) -> OperationalState {
self.inner.state()
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
self.inner.set_state(state)
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.inner.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
self.inner.circuit_id()
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.inner.set_circuit_id(circuit_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evaporator_coil_creation() {
let coil = EvaporatorCoil::new(8_000.0);
assert_eq!(coil.ua(), 8_000.0);
assert_eq!(coil.name(), "EvaporatorCoil");
}
#[test]
fn test_evaporator_coil_n_equations() {
let coil = EvaporatorCoil::new(5_000.0);
assert_eq!(coil.n_equations(), 3);
}
#[test]
fn test_evaporator_coil_with_superheat() {
let coil = EvaporatorCoil::with_superheat(8_000.0, 278.15, 5.0);
assert_eq!(coil.saturation_temp(), 278.15);
assert_eq!(coil.superheat_target(), 5.0);
}
#[test]
fn test_evaporator_coil_compute_residuals() {
let coil = EvaporatorCoil::new(8_000.0);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = coil.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
}
#[test]
fn test_evaporator_coil_jacobian_entries() {
let coil = EvaporatorCoil::new(8_000.0);
let state = vec![0.0; 10];
let mut jacobian = crate::JacobianBuilder::new();
let result = coil.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
// HeatExchanger base returns empty jacobian until framework implements it
assert!(
jacobian.is_empty(),
"delegation works; empty jacobian expected until HeatExchanger implements entries"
);
}
#[test]
fn test_evaporator_coil_setters() {
let mut coil = EvaporatorCoil::new(8_000.0);
coil.set_saturation_temp(275.0);
coil.set_superheat_target(7.0);
assert!((coil.saturation_temp() - 275.0).abs() < 1e-10);
assert!((coil.superheat_target() - 7.0).abs() < 1e-10);
}
#[test]
fn test_evaporator_coil_state_manageable() {
use crate::state_machine::{OperationalState, StateManageable};
let mut coil = EvaporatorCoil::new(8_000.0);
assert_eq!(coil.state(), OperationalState::On);
assert!(coil.can_transition_to(OperationalState::Off));
assert!(coil.set_state(OperationalState::Off).is_ok());
assert_eq!(coil.state(), OperationalState::Off);
}
}

View File

@@ -262,10 +262,34 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
self.fluid_backend.is_some()
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Hot conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute hot inlet state: {}", e)))
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Cold conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute cold inlet state: {}", e)))
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = ThermoState::from_pt(
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
@@ -279,7 +303,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Queries specific enthalpy (J/kg) from the backend for a given side at (P, T).
fn query_enthalpy(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = ThermoState::from_pt(
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);

View File

@@ -0,0 +1,398 @@
//! Log Mean Temperature Difference (LMTD) Model
//!
//! Implements the LMTD method for heat exchanger calculations.
//!
//! ## Theory
//!
//! The heat transfer rate is calculated as:
//!
//! $$\dot{Q} = U \cdot A \cdot \Delta T_{lm} \cdot F$$
//!
//! Where:
//! - $\dot{Q}$: Heat transfer rate (W)
//! - $U$: Overall heat transfer coefficient (W/m²·K)
//! - $A$: Heat transfer area (m²)
//! - $\Delta T_{lm}$: Log mean temperature difference (K)
//! - $F$: Correction factor for flow configuration
//!
//! For counter-flow:
//! $$\Delta T_{lm} = \frac{\Delta T_1 - \Delta T_2}{\ln(\Delta T_1 / \Delta T_2)}$$
//!
//! Where:
//! - $\Delta T_1 = T_{hot,in} - T_{cold,out}$
//! - $\Delta T_2 = T_{hot,out} - T_{cold,in}$
use super::model::{FluidState, HeatTransferModel};
use crate::ResidualVector;
use entropyk_core::Power;
/// Flow configuration for the heat exchanger.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum FlowConfiguration {
/// Counter-flow (most efficient)
#[default]
CounterFlow,
/// Parallel-flow (co-current)
ParallelFlow,
/// Cross-flow with correction factor
CrossFlow {
/// Correction factor F (typically 0.8-1.0)
correction_factor: f64,
},
/// Shell-and-tube with 1 shell pass, 2 tube passes
ShellAndTube1_2,
}
impl FlowConfiguration {
/// Returns the correction factor F for this configuration.
pub fn correction_factor(&self) -> f64 {
match self {
FlowConfiguration::CounterFlow => 1.0,
FlowConfiguration::ParallelFlow => 1.0,
FlowConfiguration::CrossFlow { correction_factor } => *correction_factor,
FlowConfiguration::ShellAndTube1_2 => 0.9,
}
}
}
/// LMTD (Log Mean Temperature Difference) heat transfer model.
///
/// Uses the classical LMTD method for heat exchanger sizing and rating.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{LmtdModel, FlowConfiguration, HeatTransferModel};
/// use entropyk_components::heat_exchanger::model::FluidState;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// assert_eq!(model.ua(), 5000.0);
/// ```
#[derive(Debug, Clone)]
pub struct LmtdModel {
/// Overall heat transfer coefficient × Area (W/K), nominal
ua: f64,
/// UA calibration scale: UA_eff = ua_scale × ua (default 1.0)
ua_scale: f64,
/// Flow configuration
flow_config: FlowConfiguration,
}
impl LmtdModel {
/// Creates a new LMTD model.
///
/// # Arguments
///
/// * `ua` - Overall heat transfer coefficient × Area (W/K). Must be positive.
/// * `flow_config` - Flow configuration (counter-flow, parallel-flow, etc.)
///
/// # Panics
///
/// Panics if `ua` is negative or NaN.
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{LmtdModel, FlowConfiguration};
///
/// let model = LmtdModel::new(10000.0, FlowConfiguration::CounterFlow);
/// ```
pub fn new(ua: f64, flow_config: FlowConfiguration) -> Self {
assert!(
ua.is_finite() && ua >= 0.0,
"UA must be non-negative and finite, got {}",
ua
);
Self {
ua,
ua_scale: 1.0,
flow_config,
}
}
/// Creates a counter-flow LMTD model.
pub fn counter_flow(ua: f64) -> Self {
Self::new(ua, FlowConfiguration::CounterFlow)
}
/// Creates a parallel-flow LMTD model.
pub fn parallel_flow(ua: f64) -> Self {
Self::new(ua, FlowConfiguration::ParallelFlow)
}
/// Creates a cross-flow LMTD model with correction factor.
pub fn cross_flow(ua: f64, correction_factor: f64) -> Self {
Self::new(ua, FlowConfiguration::CrossFlow { correction_factor })
}
/// Calculates the Log Mean Temperature Difference.
///
/// For counter-flow:
/// - ΔT₁ = T_hot,in - T_cold,out
/// - ΔT₂ = T_hot,out - T_cold,in
///
/// For parallel-flow:
/// - ΔT₁ = T_hot,in - T_cold,in
/// - ΔT₂ = T_hot,out - T_cold,out
///
/// Special handling when ΔT₁ ≈ ΔT₂: uses arithmetic mean.
pub fn lmtd(&self, t_hot_in: f64, t_hot_out: f64, t_cold_in: f64, t_cold_out: f64) -> f64 {
let (dt1, dt2) = match self.flow_config {
FlowConfiguration::CounterFlow
| FlowConfiguration::CrossFlow { .. }
| FlowConfiguration::ShellAndTube1_2 => (t_hot_in - t_cold_out, t_hot_out - t_cold_in),
FlowConfiguration::ParallelFlow => (t_hot_in - t_cold_in, t_hot_out - t_cold_out),
};
// Zero-flow / zero LMTD regularization: avoid division by zero (Story 3.5)
if dt1.abs() < 1e-10 && dt2.abs() < 1e-10 {
return 0.0;
}
if (dt1 - dt2).abs() / dt1.max(dt2).max(1e-10) < 1e-6 {
return (dt1 + dt2) / 2.0;
}
if dt1 <= 0.0 || dt2 <= 0.0 {
return (dt1 + dt2) / 2.0;
}
(dt1 - dt2) / (dt1 / dt2).ln()
}
}
impl HeatTransferModel for LmtdModel {
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
) -> Power {
let lmtd = self.lmtd(
hot_inlet.temperature,
hot_outlet.temperature,
cold_inlet.temperature,
cold_outlet.temperature,
);
let f = self.flow_config.correction_factor();
let ua_eff = self.effective_ua();
let q = ua_eff * lmtd * f;
Power::from_watts(q)
}
fn compute_residuals(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
) {
let q = self
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
.to_watts();
let q_hot =
hot_inlet.mass_flow * hot_inlet.cp * (hot_inlet.temperature - hot_outlet.temperature);
let q_cold = cold_inlet.mass_flow
* cold_inlet.cp
* (cold_outlet.temperature - cold_inlet.temperature);
residuals[0] = q_hot - q;
residuals[1] = q_cold - q;
residuals[2] = q_hot - q_cold;
}
fn n_equations(&self) -> usize {
3
}
fn ua(&self) -> f64 {
self.ua
}
fn ua_scale(&self) -> f64 {
self.ua_scale
}
fn set_ua_scale(&mut self, s: f64) {
self.ua_scale = s;
}
fn effective_ua(&self) -> f64 {
self.ua * self.ua_scale
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heat_exchanger::{EpsNtuModel, HeatTransferModel};
use approx::assert_relative_eq;
#[test]
fn test_lmtd_model_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
assert_eq!(model.ua(), 5000.0);
}
#[test]
fn test_f_ua_scales_heat_transfer() {
let mut model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
assert_relative_eq!(model.effective_ua(), 5000.0, epsilon = 1e-10);
model.set_ua_scale(1.1);
assert_relative_eq!(model.effective_ua(), 5500.0, epsilon = 1e-10);
}
#[test]
fn test_lmtd_counter_flow() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 20.0;
let t_cold_out = 50.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd > 0.0);
assert!(lmtd < (t_hot_in - t_cold_in));
}
#[test]
fn test_lmtd_equal_deltas() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 40.0;
let t_cold_out = 60.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!((lmtd - 20.0).abs() < 0.1);
}
#[test]
fn test_lmtd_parallel_flow() {
let model = LmtdModel::parallel_flow(5000.0);
let t_hot_in = 80.0;
let t_hot_out = 60.0;
let t_cold_in = 20.0;
let t_cold_out = 40.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd > 0.0);
}
#[test]
fn test_compute_heat_transfer() {
let model = LmtdModel::counter_flow(5000.0);
let hot_inlet = FluidState::from_temperature(80.0 + 273.15);
let hot_outlet = FluidState::from_temperature(60.0 + 273.15);
let cold_inlet = FluidState::from_temperature(20.0 + 273.15);
let cold_outlet = FluidState::from_temperature(50.0 + 273.15);
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
assert!(q.to_watts() > 0.0);
}
#[test]
fn test_flow_configuration_correction_factor() {
assert_eq!(FlowConfiguration::CounterFlow.correction_factor(), 1.0);
assert_eq!(FlowConfiguration::ParallelFlow.correction_factor(), 1.0);
assert_eq!(FlowConfiguration::ShellAndTube1_2.correction_factor(), 0.9);
let cross = FlowConfiguration::CrossFlow {
correction_factor: 0.85,
};
assert_eq!(cross.correction_factor(), 0.85);
}
#[test]
fn test_n_equations() {
let model = LmtdModel::counter_flow(1000.0);
assert_eq!(model.n_equations(), 3);
}
#[test]
fn test_lmtd_negative_deltas() {
let model = LmtdModel::counter_flow(5000.0);
let t_hot_in = 40.0;
let t_hot_out = 50.0;
let t_cold_in = 60.0;
let t_cold_out = 70.0;
let lmtd = model.lmtd(t_hot_in, t_hot_out, t_cold_in, t_cold_out);
assert!(lmtd < 0.0);
}
#[test]
#[should_panic(expected = "UA must be non-negative")]
fn test_negative_ua_panics() {
let _model = LmtdModel::new(-1000.0, FlowConfiguration::CounterFlow);
}
#[test]
fn test_zero_ua_allowed() {
let model = LmtdModel::new(0.0, FlowConfiguration::CounterFlow);
assert_eq!(model.ua(), 0.0);
}
#[test]
fn test_lmtd_vs_eps_ntu_comparison() {
// AC #8: Compare LMTD vs ε-NTU results for same conditions
// Verify both methods produce reasonable heat transfer values
let ua = 5_000.0;
let lmtd_model = LmtdModel::counter_flow(ua);
let eps_ntu_model = EpsNtuModel::counter_flow(ua);
// Typical HVAC operating conditions
// Hot water cooling from 80°C to 60°C (353K to 333K)
// Cold water heating from 20°C to 40°C (293K to 313K)
let hot_inlet = FluidState::new(353.0, 200_000.0, 335_000.0, 0.5, 4180.0);
let hot_outlet = FluidState::new(333.0, 195_000.0, 250_000.0, 0.5, 4180.0);
let cold_inlet = FluidState::new(293.0, 101_325.0, 85_000.0, 0.3, 4180.0);
let cold_outlet = FluidState::new(313.0, 101_325.0, 170_000.0, 0.3, 4180.0);
let q_lmtd = lmtd_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.to_watts();
let q_eps_ntu = eps_ntu_model
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
.to_watts();
// Both methods should give positive heat transfer
assert!(q_lmtd > 0.0, "LMTD should give positive Q, got {}", q_lmtd);
assert!(
q_eps_ntu > 0.0,
"ε-NTU should give positive Q, got {}",
q_eps_ntu
);
// Verify reasonable magnitude for a 5 kW/K heat exchanger
// LMTD should be around 30-40K, so Q should be 150-200 kW range for these temps
assert!(
q_lmtd > 100_000.0 && q_lmtd < 300_000.0,
"LMTD Q unexpected: {}",
q_lmtd
);
// ε-NTU uses inlet temps only, so result differs from LMTD
assert!(
q_eps_ntu < 300_000.0,
"ε-NTU Q should be reasonable, got {}",
q_eps_ntu
);
}
}

View File

@@ -0,0 +1,51 @@
//! Heat Exchanger Framework
//!
//! This module provides a pluggable heat exchanger framework supporting multiple
//! calculation models (LMTD, ε-NTU) for thermodynamic simulations.
//!
//! ## Architecture
//!
//! The framework uses the Strategy Pattern for heat transfer calculations:
//!
//! - [`HeatTransferModel`]: Trait for pluggable calculation strategies
//! - [`LmtdModel`]: Log Mean Temperature Difference method
//! - [`EpsNtuModel`]: Effectiveness-NTU method
//! - [`HeatExchanger`]: Generic heat exchanger component
//!
//! ## Components
//!
//! - [`Condenser`]: Refrigerant condensing (phase change) on hot side
//! - [`Evaporator`]: Refrigerant evaporating (phase change) on cold side
//! - [`EvaporatorCoil`]: Air-side evaporator (finned coil)
//! - [`CondenserCoil`]: Air-side condenser (finned coil)
//! - [`Economizer`]: Internal heat exchanger with bypass support
//!
//! ## Example
//!
//! ```rust
//! use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration};
//!
//! // Create a heat exchanger with LMTD model
//! let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
//! // Heat exchanger would be created with connected ports
//! ```
pub mod condenser;
pub mod condenser_coil;
pub mod economizer;
pub mod evaporator_coil;
pub mod eps_ntu;
pub mod evaporator;
pub mod exchanger;
pub mod lmtd;
pub mod model;
pub use condenser::Condenser;
pub use condenser_coil::CondenserCoil;
pub use economizer::Economizer;
pub use evaporator_coil::EvaporatorCoil;
pub use eps_ntu::{EpsNtuModel, ExchangerType};
pub use evaporator::Evaporator;
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use model::HeatTransferModel;

View File

@@ -0,0 +1,204 @@
//! Heat Transfer Model Trait
//!
//! Defines the Strategy Pattern interface for heat transfer calculations.
//! This trait is object-safe for dynamic dispatch.
use crate::ResidualVector;
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure, Temperature};
/// Fluid state for heat transfer calculations.
///
/// Represents the thermodynamic state at a port (inlet or outlet).
#[derive(Debug, Clone, Copy)]
pub struct FluidState {
/// Temperature in Kelvin
pub temperature: f64,
/// Pressure in Pascals
pub pressure: f64,
/// Specific enthalpy in J/kg
pub enthalpy: f64,
/// Mass flow rate in kg/s
pub mass_flow: f64,
/// Specific heat capacity at constant pressure in J/(kg·K)
pub cp: f64,
}
impl Default for FluidState {
fn default() -> Self {
Self {
temperature: 300.0,
pressure: 101_325.0,
enthalpy: 0.0,
mass_flow: 0.1,
cp: 1000.0,
}
}
}
impl FluidState {
/// Creates a new fluid state.
pub fn new(temperature: f64, pressure: f64, enthalpy: f64, mass_flow: f64, cp: f64) -> Self {
Self {
temperature,
pressure,
enthalpy,
mass_flow,
cp,
}
}
/// Creates a fluid state with default properties for a given temperature.
pub fn from_temperature(temperature: f64) -> Self {
Self {
temperature,
..Default::default()
}
}
/// Returns the heat capacity rate C = ṁ × Cp in W/K.
pub fn heat_capacity_rate(&self) -> f64 {
self.mass_flow * self.cp
}
/// Creates a FluidState from strongly-typed physical quantities.
pub fn from_types(
temperature: Temperature,
pressure: Pressure,
enthalpy: Enthalpy,
mass_flow: MassFlow,
cp: f64,
) -> Self {
Self {
temperature: temperature.to_kelvin(),
pressure: pressure.to_pascals(),
enthalpy: enthalpy.to_joules_per_kg(),
mass_flow: mass_flow.to_kg_per_s(),
cp,
}
}
/// Returns temperature as a strongly-typed Temperature.
pub fn temperature(&self) -> Temperature {
Temperature::from_kelvin(self.temperature)
}
/// Returns pressure as a strongly-typed Pressure.
pub fn pressure(&self) -> Pressure {
Pressure::from_pascals(self.pressure)
}
/// Returns enthalpy as a strongly-typed Enthalpy.
pub fn enthalpy(&self) -> Enthalpy {
Enthalpy::from_joules_per_kg(self.enthalpy)
}
/// Returns mass flow as a strongly-typed MassFlow.
pub fn mass_flow(&self) -> MassFlow {
MassFlow::from_kg_per_s(self.mass_flow)
}
}
/// Trait for heat transfer calculation models.
///
/// This trait uses the Strategy Pattern to allow different heat transfer
/// calculation methods (LMTD, ε-NTU, etc.) to be used interchangeably.
///
/// # Object Safety
///
/// This trait is object-safe and can be used with dynamic dispatch:
///
/// ```
/// # use entropyk_components::heat_exchanger::model::{HeatTransferModel, FluidState};
/// # use entropyk_components::ResidualVector;
/// # use entropyk_core::Power;
/// struct SimpleModel { ua: f64 }
/// impl HeatTransferModel for SimpleModel {
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState) -> Power {
/// Power::from_watts(0.0)
/// }
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector) {}
/// fn n_equations(&self) -> usize { 3 }
/// fn ua(&self) -> f64 { self.ua }
/// }
/// let model: Box<dyn HeatTransferModel> = Box::new(SimpleModel { ua: 1000.0 });
/// ```
pub trait HeatTransferModel: Send + Sync {
/// Computes the heat transfer rate Q̇ (Watts).
///
/// # Arguments
///
/// * `hot_inlet` - Hot side inlet state
/// * `hot_outlet` - Hot side outlet state
/// * `cold_inlet` - Cold side inlet state
/// * `cold_outlet` - Cold side outlet state
///
/// # Returns
///
/// The heat transfer rate in Watts (positive = heat flows from hot to cold)
fn compute_heat_transfer(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
) -> Power;
/// Computes residuals for the solver.
///
/// The residuals represent the error in the heat transfer equations
/// that the solver will attempt to drive to zero.
fn compute_residuals(
&self,
hot_inlet: &FluidState,
hot_outlet: &FluidState,
cold_inlet: &FluidState,
cold_outlet: &FluidState,
residuals: &mut ResidualVector,
);
/// Returns the number of equations this model contributes.
fn n_equations(&self) -> usize;
/// Returns the nominal UA value (overall heat transfer coefficient × area) in W/K.
fn ua(&self) -> f64;
/// Returns the UA calibration scale (default 1.0). UA_eff = ua_scale × ua_nominal.
fn ua_scale(&self) -> f64 {
1.0
}
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
fn set_ua_scale(&mut self, _s: f64) {}
/// Returns the effective UA used in heat transfer: ua_scale × ua_nominal.
fn effective_ua(&self) -> f64 {
self.ua() * self.ua_scale()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fluid_state_default() {
let state = FluidState::default();
assert_eq!(state.temperature, 300.0);
assert_eq!(state.pressure, 101_325.0);
assert_eq!(state.cp, 1000.0);
}
#[test]
fn test_fluid_state_heat_capacity_rate() {
let state = FluidState::new(300.0, 101_325.0, 0.0, 0.5, 2000.0);
let c = state.heat_capacity_rate();
assert!((c - 1000.0).abs() < 1e-10);
}
#[test]
fn test_fluid_state_from_temperature() {
let state = FluidState::from_temperature(350.0);
assert_eq!(state.temperature, 350.0);
assert_eq!(state.pressure, 101_325.0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,702 @@
//! Polynomial curve models for component performance characterization.
//!
//! This module provides polynomial curve implementations for:
//! - 1D polynomials: Pump curves (Q-H, efficiency), Fan curves
//! - 2D polynomials: Compressor maps based on SST/SDT
//!
//! ## 1D Polynomial (Pump/Fan Curves)
//!
//! ```text
//! y = c0 + c1*x + c2*x² + c3*x³ + ...
//! ```
//!
//! ## 2D Polynomial (Compressor Maps)
//!
//! ```text
//! z = Σ a_ij * x^i * y^j
//! ```
//!
//! Where x = SST (Saturated Suction Temperature)
//! and y = SDT (Saturated Discharge Temperature)
use crate::ComponentError;
use serde::{Deserialize, Serialize};
/// 1D Polynomial curve for component performance modeling.
///
/// Used for pump head curves, fan static pressure curves, and efficiency curves.
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial1D;
///
/// // Pump curve: H = 50 - 0.1*Q - 0.001*Q²
/// let curve = Polynomial1D::new(vec![50.0, -0.1, -0.001]);
///
/// // Evaluate at Q = 100 m³/h
/// let head = curve.evaluate(100.0);
/// assert!(head > 0.0);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Polynomial1D {
/// Polynomial coefficients [c0, c1, c2, ...] for y = c0 + c1*x + c2*x² + ...
coefficients: Vec<f64>,
}
impl Polynomial1D {
/// Creates a new 1D polynomial from coefficients.
///
/// # Arguments
///
/// * `coefficients` - Coefficients [c0, c1, c2, ...] where y = c0 + c1*x + c2*x² + ...
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial1D;
///
/// // Linear: y = 2x + 3
/// let linear = Polynomial1D::new(vec![3.0, 2.0]);
///
/// // Quadratic: y = 1 + 2x + 3x²
/// let quadratic = Polynomial1D::new(vec![1.0, 2.0, 3.0]);
/// ```
pub fn new(coefficients: Vec<f64>) -> Self {
Self { coefficients }
}
/// Creates a constant polynomial (degree 0).
pub fn constant(value: f64) -> Self {
Self::new(vec![value])
}
/// Creates a linear polynomial: y = a + b*x.
pub fn linear(a: f64, b: f64) -> Self {
Self::new(vec![a, b])
}
/// Creates a quadratic polynomial: y = a + b*x + c*x².
pub fn quadratic(a: f64, b: f64, c: f64) -> Self {
Self::new(vec![a, b, c])
}
/// Creates a cubic polynomial: y = a + b*x + c*x² + d*x³.
pub fn cubic(a: f64, b: f64, c: f64, d: f64) -> Self {
Self::new(vec![a, b, c, d])
}
/// Evaluates the polynomial at point x using Horner's method.
///
/// # Arguments
///
/// * `x` - The point at which to evaluate
///
/// # Returns
///
/// The polynomial value y = P(x)
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial1D;
///
/// let p = Polynomial1D::quadratic(1.0, 2.0, 3.0);
/// // y = 1 + 2*2 + 3*4 = 1 + 4 + 12 = 17
/// assert!((p.evaluate(2.0) - 17.0).abs() < 1e-10);
/// ```
pub fn evaluate(&self, x: f64) -> f64 {
if self.coefficients.is_empty() {
return 0.0;
}
// Horner's method: efficient polynomial evaluation
self.coefficients
.iter()
.rev()
.fold(0.0, |acc, &c| acc * x + c)
}
/// Computes the derivative of the polynomial at point x.
///
/// # Arguments
///
/// * `x` - The point at which to evaluate the derivative
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial1D;
///
/// let p = Polynomial1D::quadratic(1.0, 2.0, 3.0);
/// // dy/dx = 2 + 6x, at x=2: dy/dx = 2 + 12 = 14
/// assert!((p.derivative(2.0) - 14.0).abs() < 1e-10);
/// ```
pub fn derivative(&self, x: f64) -> f64 {
if self.coefficients.len() <= 1 {
return 0.0;
}
// Derivative coefficients: [c1, 2*c2, 3*c3, ...]
let deriv_coeffs: Vec<f64> = self.coefficients[1..]
.iter()
.enumerate()
.map(|(i, &c)| c * (i + 1) as f64)
.collect();
Polynomial1D::new(deriv_coeffs).evaluate(x)
}
/// Returns the degree of the polynomial.
pub fn degree(&self) -> usize {
if self.coefficients.is_empty() {
0
} else {
self.coefficients.len() - 1
}
}
/// Returns a reference to the coefficients.
pub fn coefficients(&self) -> &[f64] {
&self.coefficients
}
/// Validates that all coefficients are finite (not NaN or infinite).
pub fn validate(&self) -> Result<(), ComponentError> {
for (i, &c) in self.coefficients.iter().enumerate() {
if c.is_nan() {
return Err(ComponentError::InvalidState(format!(
"Coefficient {} is NaN",
i
)));
}
if c.is_infinite() {
return Err(ComponentError::InvalidState(format!(
"Coefficient {} is infinite",
i
)));
}
}
Ok(())
}
}
impl Default for Polynomial1D {
fn default() -> Self {
Self::constant(0.0)
}
}
/// 2D Polynomial for compressor maps based on SST/SDT.
///
/// Models performance as a function of two variables (e.g., SST and SDT):
///
/// ```text
/// z = Σ a_ij * x^i * y^j for i=0..nx, j=0..ny
/// ```
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial2D;
///
/// // Simple bilinear: z = a00 + a10*x + a01*y + a11*x*y
/// let coeffs = vec![
/// vec![1.0, 0.5], // a00, a01
/// vec![2.0, 0.1], // a10, a11
/// ];
/// let poly = Polynomial2D::new(coeffs);
///
/// let z = poly.evaluate(10.0, 20.0);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Polynomial2D {
/// Coefficient matrix where coeffs[i][j] is the coefficient for x^i * y^j
/// coeffs[i] contains coefficients for all j values at degree i in x
coefficients: Vec<Vec<f64>>,
}
impl Polynomial2D {
/// Creates a new 2D polynomial from coefficient matrix.
///
/// # Arguments
///
/// * `coefficients` - Matrix where coefficients[i][j] is coefficient for x^i * y^j
///
/// # Example
///
/// ```
/// use entropyk_components::polynomials::Polynomial2D;
///
/// // z = 5 + 2*x + 3*y + 0.5*x*y
/// let coeffs = vec![
/// vec![5.0, 3.0], // Constant and y term
/// vec![2.0, 0.5], // x term and x*y term
/// ];
/// let poly = Polynomial2D::new(coeffs);
/// ```
pub fn new(coefficients: Vec<Vec<f64>>) -> Self {
Self { coefficients }
}
/// Creates a constant 2D polynomial.
pub fn constant(value: f64) -> Self {
Self::new(vec![vec![value]])
}
/// Creates a bilinear polynomial: z = a00 + a10*x + a01*y + a11*x*y.
pub fn bilinear(a00: f64, a10: f64, a01: f64, a11: f64) -> Self {
Self::new(vec![vec![a00, a01], vec![a10, a11]])
}
/// Creates a biquadratic polynomial (degree 2 in both variables).
///
/// z = a00 + a10*x + a01*y + a20*x² + a11*x*y + a02*y²
pub fn biquadratic(a00: f64, a10: f64, a01: f64, a20: f64, a11: f64, a02: f64) -> Self {
Self::new(vec![vec![a00, a01, a02], vec![a10, a11], vec![a20]])
}
/// Evaluates the polynomial at (x, y).
///
/// # Arguments
///
/// * `x` - First variable (e.g., SST)
/// * `y` - Second variable (e.g., SDT)
///
/// # Returns
///
/// The polynomial value z = P(x, y)
pub fn evaluate(&self, x: f64, y: f64) -> f64 {
let mut result = 0.0;
for (i, row) in self.coefficients.iter().enumerate() {
let x_pow = x.powi(i as i32);
for (j, &coeff) in row.iter().enumerate() {
result += coeff * x_pow * y.powi(j as i32);
}
}
result
}
/// Computes partial derivative with respect to x.
pub fn partial_x(&self, x: f64, y: f64) -> f64 {
let mut result = 0.0;
for (i, row) in self.coefficients.iter().enumerate() {
if i == 0 {
continue; // ∂/∂x of constant is 0
}
let x_pow = (i as f64) * x.powi((i - 1) as i32);
for (j, &coeff) in row.iter().enumerate() {
result += coeff * x_pow * y.powi(j as i32);
}
}
result
}
/// Computes partial derivative with respect to y.
pub fn partial_y(&self, x: f64, y: f64) -> f64 {
let mut result = 0.0;
for (i, row) in self.coefficients.iter().enumerate() {
let x_pow = x.powi(i as i32);
for (j, &coeff) in row.iter().enumerate() {
if j == 0 {
continue; // ∂/∂y of constant is 0
}
result += coeff * x_pow * (j as f64) * y.powi((j - 1) as i32);
}
}
result
}
/// Returns the degree in x.
pub fn degree_x(&self) -> usize {
if self.coefficients.is_empty() {
0
} else {
self.coefficients.len() - 1
}
}
/// Returns the degree in y.
pub fn degree_y(&self) -> usize {
self.coefficients
.iter()
.map(|row| if row.is_empty() { 0 } else { row.len() - 1 })
.max()
.unwrap_or(0)
}
/// Returns a reference to the coefficient matrix.
pub fn coefficients(&self) -> &[Vec<f64>] {
&self.coefficients
}
/// Validates that all coefficients are finite.
pub fn validate(&self) -> Result<(), ComponentError> {
for (i, row) in self.coefficients.iter().enumerate() {
for (j, &c) in row.iter().enumerate() {
if c.is_nan() {
return Err(ComponentError::InvalidState(format!(
"Coefficient [{},{}] is NaN",
i, j
)));
}
if c.is_infinite() {
return Err(ComponentError::InvalidState(format!(
"Coefficient [{},{}] is infinite",
i, j
)));
}
}
}
Ok(())
}
}
impl Default for Polynomial2D {
fn default() -> Self {
Self::constant(0.0)
}
}
/// Pump/Fan curve set containing head, efficiency, and power polynomials.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PerformanceCurves {
/// Head/Pressure curve: H = f(Q) where Q is volumetric flow
pub head_curve: Polynomial1D,
/// Efficiency curve: η = f(Q)
pub efficiency_curve: Polynomial1D,
/// Optional power curve: P = f(Q) - if not provided, calculated from head and efficiency
pub power_curve: Option<Polynomial1D>,
}
impl PerformanceCurves {
/// Creates a new performance curve set.
pub fn new(
head_curve: Polynomial1D,
efficiency_curve: Polynomial1D,
power_curve: Option<Polynomial1D>,
) -> Self {
Self {
head_curve,
efficiency_curve,
power_curve,
}
}
/// Creates a simple pump curve with head and efficiency only.
pub fn simple(head_curve: Polynomial1D, efficiency_curve: Polynomial1D) -> Self {
Self::new(head_curve, efficiency_curve, None)
}
/// Validates all curves.
pub fn validate(&self) -> Result<(), ComponentError> {
self.head_curve.validate()?;
self.efficiency_curve.validate()?;
if let Some(ref pc) = self.power_curve {
pc.validate()?;
}
Ok(())
}
}
impl Default for PerformanceCurves {
fn default() -> Self {
Self::simple(Polynomial1D::default(), Polynomial1D::default())
}
}
/// Affinity laws for variable speed operation.
///
/// When speed changes from N1 to N2:
/// - Q2/Q1 = N2/N1 (flow proportional to speed)
/// - H2/H1 = (N2/N1)² (head proportional to speed squared)
/// - P2/P1 = (N2/N1)³ (power proportional to speed cubed)
pub struct AffinityLaws;
impl AffinityLaws {
/// Applies affinity laws to scale flow rate.
///
/// # Arguments
///
/// * `flow` - Original flow at speed_ratio = 1.0
/// * `speed_ratio` - New speed / rated speed (0.0 to 1.0)
///
/// # Returns
///
/// Scaled flow rate
pub fn scale_flow(flow: f64, speed_ratio: f64) -> f64 {
flow * speed_ratio
}
/// Applies affinity laws to scale head/pressure.
///
/// # Arguments
///
/// * `head` - Original head at speed_ratio = 1.0
/// * `speed_ratio` - New speed / rated speed (0.0 to 1.0)
///
/// # Returns
///
/// Scaled head
pub fn scale_head(head: f64, speed_ratio: f64) -> f64 {
head * speed_ratio * speed_ratio
}
/// Applies affinity laws to scale power.
///
/// # Arguments
///
/// * `power` - Original power at speed_ratio = 1.0
/// * `speed_ratio` - New speed / rated speed (0.0 to 1.0)
///
/// # Returns
///
/// Scaled power
pub fn scale_power(power: f64, speed_ratio: f64) -> f64 {
power * speed_ratio * speed_ratio * speed_ratio
}
/// Reverse affinity law: find original flow from scaled flow.
pub fn unscale_flow(scaled_flow: f64, speed_ratio: f64) -> f64 {
if speed_ratio <= 0.0 {
return f64::INFINITY;
}
scaled_flow / speed_ratio
}
/// Reverse affinity law: find original head from scaled head.
pub fn unscale_head(scaled_head: f64, speed_ratio: f64) -> f64 {
if speed_ratio <= 0.0 {
return f64::INFINITY;
}
scaled_head / (speed_ratio * speed_ratio)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_polynomial_1d_constant() {
let p = Polynomial1D::constant(5.0);
assert_relative_eq!(p.evaluate(0.0), 5.0);
assert_relative_eq!(p.evaluate(100.0), 5.0);
assert_relative_eq!(p.derivative(0.0), 0.0);
}
#[test]
fn test_polynomial_1d_linear() {
// y = 3 + 2x
let p = Polynomial1D::linear(3.0, 2.0);
assert_relative_eq!(p.evaluate(0.0), 3.0);
assert_relative_eq!(p.evaluate(1.0), 5.0);
assert_relative_eq!(p.evaluate(2.0), 7.0);
assert_relative_eq!(p.derivative(5.0), 2.0);
}
#[test]
fn test_polynomial_1d_quadratic() {
// y = 1 + 2x + 3x²
let p = Polynomial1D::quadratic(1.0, 2.0, 3.0);
assert_relative_eq!(p.evaluate(0.0), 1.0);
assert_relative_eq!(p.evaluate(1.0), 6.0); // 1 + 2 + 3
assert_relative_eq!(p.evaluate(2.0), 17.0); // 1 + 4 + 12
assert_relative_eq!(p.derivative(2.0), 14.0); // 2 + 6*2 = 14
}
#[test]
fn test_polynomial_1d_cubic() {
// y = 1 + x + x² + x³
let p = Polynomial1D::cubic(1.0, 1.0, 1.0, 1.0);
assert_relative_eq!(p.evaluate(0.0), 1.0);
assert_relative_eq!(p.evaluate(1.0), 4.0);
assert_relative_eq!(p.evaluate(2.0), 15.0); // 1 + 2 + 4 + 8
}
#[test]
fn test_polynomial_1d_empty() {
let p = Polynomial1D::new(vec![]);
assert_relative_eq!(p.evaluate(5.0), 0.0);
}
#[test]
fn test_polynomial_1d_degree() {
assert_eq!(Polynomial1D::constant(1.0).degree(), 0);
assert_eq!(Polynomial1D::linear(1.0, 2.0).degree(), 1);
assert_eq!(Polynomial1D::quadratic(1.0, 2.0, 3.0).degree(), 2);
assert_eq!(Polynomial1D::cubic(1.0, 2.0, 3.0, 4.0).degree(), 3);
}
#[test]
fn test_polynomial_1d_validate() {
let valid = Polynomial1D::quadratic(1.0, 2.0, 3.0);
assert!(valid.validate().is_ok());
let nan_coeff = Polynomial1D::new(vec![1.0, f64::NAN]);
assert!(nan_coeff.validate().is_err());
let inf_coeff = Polynomial1D::new(vec![f64::INFINITY]);
assert!(inf_coeff.validate().is_err());
}
#[test]
fn test_polynomial_2d_constant() {
let p = Polynomial2D::constant(5.0);
assert_relative_eq!(p.evaluate(0.0, 0.0), 5.0);
assert_relative_eq!(p.evaluate(10.0, 20.0), 5.0);
}
#[test]
fn test_polynomial_2d_bilinear() {
// z = 1 + 2*x + 3*y + 0.5*x*y
let p = Polynomial2D::bilinear(1.0, 2.0, 3.0, 0.5);
// At (0,0): z = 1
assert_relative_eq!(p.evaluate(0.0, 0.0), 1.0);
// At (1,0): z = 1 + 2 = 3
assert_relative_eq!(p.evaluate(1.0, 0.0), 3.0);
// At (0,1): z = 1 + 3 = 4
assert_relative_eq!(p.evaluate(0.0, 1.0), 4.0);
// At (2,3): z = 1 + 4 + 9 + 3 = 17
assert_relative_eq!(p.evaluate(2.0, 3.0), 17.0);
}
#[test]
fn test_polynomial_2d_biquadratic() {
let p = Polynomial2D::biquadratic(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
// At (0,0): z = 1
assert_relative_eq!(p.evaluate(0.0, 0.0), 1.0);
// At (1,1): z = 1 + 2 + 3 + 4 + 5 + 6 = 21
assert_relative_eq!(p.evaluate(1.0, 1.0), 21.0);
}
#[test]
fn test_polynomial_2d_partial_derivatives() {
// z = 1 + 2*x + 3*y + 4*x*y
let p = Polynomial2D::bilinear(1.0, 2.0, 3.0, 4.0);
// ∂z/∂x = 2 + 4*y
assert_relative_eq!(p.partial_x(0.0, 0.0), 2.0);
assert_relative_eq!(p.partial_x(0.0, 1.0), 6.0);
// ∂z/∂y = 3 + 4*x
assert_relative_eq!(p.partial_y(0.0, 0.0), 3.0);
assert_relative_eq!(p.partial_y(1.0, 0.0), 7.0);
}
#[test]
fn test_polynomial_2d_degrees() {
let p = Polynomial2D::bilinear(1.0, 2.0, 3.0, 4.0);
assert_eq!(p.degree_x(), 1);
assert_eq!(p.degree_y(), 1);
let biq = Polynomial2D::biquadratic(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
assert_eq!(biq.degree_x(), 2);
assert_eq!(biq.degree_y(), 2);
}
#[test]
fn test_polynomial_2d_validate() {
let valid = Polynomial2D::bilinear(1.0, 2.0, 3.0, 4.0);
assert!(valid.validate().is_ok());
let nan_coeff = Polynomial2D::new(vec![vec![1.0, f64::NAN]]);
assert!(nan_coeff.validate().is_err());
}
#[test]
fn test_affinity_laws_flow() {
let flow = 100.0;
// At full speed: no change
assert_relative_eq!(AffinityLaws::scale_flow(flow, 1.0), 100.0);
// At half speed: half flow
assert_relative_eq!(AffinityLaws::scale_flow(flow, 0.5), 50.0);
// At 80% speed: 80% flow
assert_relative_eq!(AffinityLaws::scale_flow(flow, 0.8), 80.0);
}
#[test]
fn test_affinity_laws_head() {
let head = 100.0;
// At full speed: no change
assert_relative_eq!(AffinityLaws::scale_head(head, 1.0), 100.0);
// At half speed: 25% head
assert_relative_eq!(AffinityLaws::scale_head(head, 0.5), 25.0);
// At 80% speed: 64% head
assert_relative_eq!(AffinityLaws::scale_head(head, 0.8), 64.0);
}
#[test]
fn test_affinity_laws_power() {
let power = 1000.0;
// At full speed: no change
assert_relative_eq!(AffinityLaws::scale_power(power, 1.0), 1000.0);
// At half speed: 12.5% power
assert_relative_eq!(AffinityLaws::scale_power(power, 0.5), 125.0);
// At 80% speed: 51.2% power
assert_relative_eq!(AffinityLaws::scale_power(power, 0.8), 512.0);
}
#[test]
fn test_affinity_laws_reverse() {
let flow = 50.0;
let head = 25.0;
let speed = 0.5;
// Reverse scaling
assert_relative_eq!(AffinityLaws::unscale_flow(flow, speed), 100.0);
assert_relative_eq!(AffinityLaws::unscale_head(head, speed), 100.0);
}
#[test]
fn test_performance_curves() {
// Typical pump curve: H = 50 - 0.1*Q - 0.001*Q²
let head = Polynomial1D::quadratic(50.0, -0.1, -0.001);
// Efficiency: η = 0.4 + 0.02*Q - 0.0001*Q²
let eff = Polynomial1D::quadratic(0.4, 0.02, -0.0001);
let curves = PerformanceCurves::simple(head.clone(), eff.clone());
assert!(curves.validate().is_ok());
assert_relative_eq!(curves.head_curve.evaluate(0.0), 50.0);
assert_relative_eq!(curves.efficiency_curve.evaluate(0.0), 0.4);
}
#[test]
fn test_pump_curve_realistic() {
// Realistic pump curve for a small centrifugal pump
// H (m) = 30 - 0.05*Q - 0.0005*Q², where Q is in m³/h
let head_curve = Polynomial1D::quadratic(30.0, -0.05, -0.0005);
// At Q=0 (shut-off): H = 30 m
assert_relative_eq!(head_curve.evaluate(0.0), 30.0);
// At Q=100 m³/h: H = 30 - 5 - 5 = 20 m
assert_relative_eq!(head_curve.evaluate(100.0), 20.0);
// At Q=200 m³/h: H = 30 - 10 - 20 = 0 m (run-out)
assert_relative_eq!(head_curve.evaluate(200.0), 0.0, epsilon = 1e-10);
}
}

View File

@@ -0,0 +1,753 @@
//! Port and Connection System
//!
//! This module provides the foundation for connecting thermodynamic components
//! using the Type-State pattern for compile-time connection safety.
//!
//! ## Type-State Pattern
//!
//! Ports have two states:
//! - `Disconnected`: Initial state, cannot be used in solver
//! - `Connected`: Linked to another port, ready for simulation
//!
//! State transitions are enforced at compile time:
//! ```text
//! Port<Disconnected> --connect()--> Port<Connected>
//! ↑ │
//! └───────── (no way back) ────────────┘
//! ```
//!
//! ## Connection Semantics
//!
//! Connected ports validate continuity (pressure/enthalpy match) at connection time,
//! but track values independently afterward. This allows the solver to update port
//! states during iteration without requiring synchronization.
//!
//! ## Example
//!
//! ```rust
//! use entropyk_components::port::{Port, Disconnected, Connected, FluidId, ConnectionError};
//! use entropyk_core::{Pressure, Enthalpy};
//!
//! // Create two disconnected ports
//! let port1 = Port::new(FluidId::new("R134a"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(400000.0));
//! let port2 = Port::new(FluidId::new("R134a"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(400000.0));
//!
//! // Connect them
//! let (connected1, connected2) = port1.connect(port2)?;
//!
//! // Ports track values independently for solver flexibility
//! assert_eq!(connected1.pressure().to_bar(), 1.0);
//! # Ok::<(), ConnectionError>(())
//! ```
use entropyk_core::{Enthalpy, Pressure};
use std::fmt;
use std::marker::PhantomData;
use thiserror::Error;
/// Default relative tolerance for pressure matching (0.01% = 100 ppm).
/// For 1 bar = 100,000 Pa, this allows 10 Pa difference.
const PRESSURE_TOLERANCE_FRACTION: f64 = 1e-4;
/// Default absolute tolerance for enthalpy matching (100 J/kg).
/// This is approximately 0.024 kJ/kg, reasonable for HVAC calculations.
const ENTHALPY_TOLERANCE_J_KG: f64 = 100.0;
/// Minimum absolute pressure tolerance (1 Pa) to avoid issues near zero.
const MIN_PRESSURE_TOLERANCE_PA: f64 = 1.0;
/// Errors that can occur during port operations.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ConnectionError {
/// Attempted to connect ports with incompatible fluids.
#[error("Incompatible fluids: cannot connect {from} to {to}")]
IncompatibleFluid {
/// Source fluid identifier
from: String,
/// Target fluid identifier
to: String,
},
/// Pressure mismatch at connection point.
#[error(
"Pressure mismatch: {from_pressure} Pa vs {to_pressure} Pa (tolerance: {tolerance} Pa)"
)]
PressureMismatch {
/// Pressure at source port (Pa)
from_pressure: f64,
/// Pressure at target port (Pa)
to_pressure: f64,
/// Tolerance used for comparison (Pa)
tolerance: f64,
},
/// Enthalpy mismatch at connection point.
#[error("Enthalpy mismatch: {from_enthalpy} J/kg vs {to_enthalpy} J/kg (tolerance: {tolerance} J/kg)")]
EnthalpyMismatch {
/// Enthalpy at source port (J/kg)
from_enthalpy: f64,
/// Enthalpy at target port (J/kg)
to_enthalpy: f64,
/// Tolerance used for comparison (J/kg)
tolerance: f64,
},
/// Attempted to connect a port that is already connected.
#[error("Port is already connected and cannot be reconnected")]
AlreadyConnected,
/// Detected a cycle in the connection graph.
#[error("Connection would create a cycle in the system topology")]
CycleDetected,
/// Invalid port index.
#[error(
"Invalid port index {index}: component has {port_count} ports (valid: 0..{max_index})"
)]
InvalidPortIndex {
/// The invalid port index that was requested
index: usize,
/// Number of ports on the component
port_count: usize,
/// Maximum valid index (port_count - 1, or 0 if no ports)
max_index: usize,
},
/// Invalid node index.
#[error("Invalid node index: {0}")]
InvalidNodeIndex(usize),
}
/// Type-state marker for disconnected ports.
///
/// Ports in this state cannot be used in the solver until connected.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Disconnected;
/// Type-state marker for connected ports.
///
/// Ports in this state are linked to another port and ready for simulation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Connected;
/// Identifier for thermodynamic fluids.
///
/// Used to ensure only compatible fluids are connected.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FluidId(String);
impl FluidId {
/// Creates a new fluid identifier.
///
/// # Arguments
///
/// * `id` - Unique identifier for the fluid (e.g., "R134a", "Water")
///
/// # Examples
///
/// ```
/// use entropyk_components::port::FluidId;
///
/// let fluid = FluidId::new("R134a");
/// ```
pub fn new(id: impl Into<String>) -> Self {
FluidId(id.into())
}
/// Returns the fluid identifier as a string slice.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for FluidId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// A thermodynamic port for connecting components.
///
/// Ports use the Type-State pattern to enforce connection safety at compile time.
/// A `Port<Disconnected>` must be connected before it can be used in simulations.
///
/// # Type Parameters
///
/// * `State` - Either `Disconnected` or `Connected`, tracking the port's state
///
/// # Examples
///
/// ```
/// use entropyk_components::port::{Port, Disconnected, FluidId};
/// use entropyk_core::{Pressure, Enthalpy};
///
/// // Create a disconnected port
/// let port: Port<Disconnected> = Port::new(
/// FluidId::new("R134a"),
/// Pressure::from_bar(1.0),
/// Enthalpy::from_joules_per_kg(400000.0)
/// );
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Port<State> {
fluid_id: FluidId,
pressure: Pressure,
enthalpy: Enthalpy,
_state: PhantomData<State>,
}
/// Helper to validate connection parameters.
fn validate_connection_params(
from_fluid: &FluidId,
from_p: Pressure,
from_h: Enthalpy,
to_fluid: &FluidId,
to_p: Pressure,
to_h: Enthalpy,
) -> Result<(), ConnectionError> {
if from_fluid != to_fluid {
return Err(ConnectionError::IncompatibleFluid {
from: from_fluid.to_string(),
to: to_fluid.to_string(),
});
}
let pressure_tol =
(from_p.to_pascals().abs() * PRESSURE_TOLERANCE_FRACTION).max(MIN_PRESSURE_TOLERANCE_PA);
let pressure_diff = (from_p.to_pascals() - to_p.to_pascals()).abs();
if pressure_diff > pressure_tol {
return Err(ConnectionError::PressureMismatch {
from_pressure: from_p.to_pascals(),
to_pressure: to_p.to_pascals(),
tolerance: pressure_tol,
});
}
let enthalpy_diff = (from_h.to_joules_per_kg() - to_h.to_joules_per_kg()).abs();
if enthalpy_diff > ENTHALPY_TOLERANCE_J_KG {
return Err(ConnectionError::EnthalpyMismatch {
from_enthalpy: from_h.to_joules_per_kg(),
to_enthalpy: to_h.to_joules_per_kg(),
tolerance: ENTHALPY_TOLERANCE_J_KG,
});
}
Ok(())
}
impl Port<Disconnected> {
/// Creates a new disconnected port.
///
/// # Arguments
///
/// * `fluid_id` - Identifier for the fluid flowing through this port
/// * `pressure` - Initial pressure at the port
/// * `enthalpy` - Initial specific enthalpy at the port
///
/// # Examples
///
/// ```
/// use entropyk_components::port::{Port, FluidId};
/// use entropyk_core::{Pressure, Enthalpy};
///
/// let port = Port::new(
/// FluidId::new("R134a"),
/// Pressure::from_bar(1.0),
/// Enthalpy::from_joules_per_kg(400000.0)
/// );
/// ```
pub fn new(fluid_id: FluidId, pressure: Pressure, enthalpy: Enthalpy) -> Self {
Self {
fluid_id,
pressure,
enthalpy,
_state: PhantomData,
}
}
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &FluidId {
&self.fluid_id
}
/// Returns the current pressure.
pub fn pressure(&self) -> Pressure {
self.pressure
}
/// Returns the current enthalpy.
pub fn enthalpy(&self) -> Enthalpy {
self.enthalpy
}
/// Connects two disconnected ports.
///
/// Validates that:
/// - Both ports have the same fluid type
/// - Pressures match within relative tolerance
/// - Enthalpies match within absolute tolerance
///
/// After connection, ports track values independently, allowing the solver
/// to update states during iteration.
///
/// # Arguments
///
/// * `other` - The port to connect to
///
/// # Returns
///
/// Returns a tuple of `(Port<Connected>, Port<Connected>)` on success,
/// or a `ConnectionError` if validation fails.
///
/// # Examples
///
/// ```
/// use entropyk_components::port::{Port, FluidId, ConnectionError};
/// use entropyk_core::{Pressure, Enthalpy};
///
/// let port1 = Port::new(
/// FluidId::new("R134a"),
/// Pressure::from_pascals(100000.0),
/// Enthalpy::from_joules_per_kg(400000.0)
/// );
/// let port2 = Port::new(
/// FluidId::new("R134a"),
/// Pressure::from_pascals(100000.0),
/// Enthalpy::from_joules_per_kg(400000.0)
/// );
///
/// let (connected1, connected2) = port1.connect(port2)?;
/// # Ok::<(), ConnectionError>(())
/// ```
pub fn connect(
self,
other: Port<Disconnected>,
) -> Result<(Port<Connected>, Port<Connected>), ConnectionError> {
validate_connection_params(
&self.fluid_id,
self.pressure,
self.enthalpy,
&other.fluid_id,
other.pressure,
other.enthalpy,
)?;
let avg_pressure = Pressure::from_pascals(
(self.pressure.to_pascals() + other.pressure.to_pascals()) / 2.0,
);
let avg_enthalpy = Enthalpy::from_joules_per_kg(
(self.enthalpy.to_joules_per_kg() + other.enthalpy.to_joules_per_kg()) / 2.0,
);
let connected1 = Port {
fluid_id: self.fluid_id,
pressure: avg_pressure,
enthalpy: avg_enthalpy,
_state: PhantomData,
};
let connected2 = Port {
fluid_id: other.fluid_id,
pressure: avg_pressure,
enthalpy: avg_enthalpy,
_state: PhantomData,
};
Ok((connected1, connected2))
}
}
impl Port<Connected> {
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &FluidId {
&self.fluid_id
}
/// Returns the current pressure.
pub fn pressure(&self) -> Pressure {
self.pressure
}
/// Returns the current enthalpy.
pub fn enthalpy(&self) -> Enthalpy {
self.enthalpy
}
/// Updates the pressure at this port.
///
/// # Arguments
///
/// * `pressure` - The new pressure value
pub fn set_pressure(&mut self, pressure: Pressure) {
self.pressure = pressure;
}
/// Updates the enthalpy at this port.
///
/// # Arguments
///
/// * `enthalpy` - The new enthalpy value
pub fn set_enthalpy(&mut self, enthalpy: Enthalpy) {
self.enthalpy = enthalpy;
}
}
/// A connected port reference that can be stored in components.
///
/// This type is object-safe and can be used in trait objects.
pub type ConnectedPort = Port<Connected>;
/// Validates that two connected ports are compatible for a flow connection.
///
/// Uses the same tolerance constants as [`Port::connect`](Port::connect):
/// - Pressure: `max(P * 1e-4, 1 Pa)`
/// - Enthalpy: 100 J/kg
///
/// # Arguments
///
/// * `outlet` - Source port (flow direction: outlet → inlet)
/// * `inlet` - Target port
///
/// # Returns
///
/// `Ok(())` if ports are compatible, `Err(ConnectionError)` otherwise.
pub fn validate_port_continuity(
outlet: &ConnectedPort,
inlet: &ConnectedPort,
) -> Result<(), ConnectionError> {
validate_connection_params(
&outlet.fluid_id,
outlet.pressure,
outlet.enthalpy,
&inlet.fluid_id,
inlet.pressure,
inlet.enthalpy,
)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_port_creation() {
let port = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(400000.0),
);
assert_eq!(port.fluid_id().as_str(), "R134a");
assert_relative_eq!(port.pressure().to_bar(), 1.0, epsilon = 1e-10);
assert_relative_eq!(
port.enthalpy().to_joules_per_kg(),
400000.0,
epsilon = 1e-10
);
}
#[test]
fn test_successful_connection() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let (connected1, connected2) = port1.connect(port2).unwrap();
assert_eq!(connected1.fluid_id().as_str(), "R134a");
assert_eq!(connected2.fluid_id().as_str(), "R134a");
assert_relative_eq!(
connected1.pressure().to_pascals(),
100000.0,
epsilon = 1e-10
);
assert_relative_eq!(
connected2.pressure().to_pascals(),
100000.0,
epsilon = 1e-10
);
}
#[test]
fn test_incompatible_fluid_error() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("Water"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let result = port1.connect(port2);
assert!(matches!(
result,
Err(ConnectionError::IncompatibleFluid { .. })
));
}
#[test]
fn test_pressure_mismatch_error() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(200000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let result = port1.connect(port2);
assert!(matches!(
result,
Err(ConnectionError::PressureMismatch { .. })
));
}
#[test]
fn test_enthalpy_mismatch_error() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(500000.0),
);
let result = port1.connect(port2);
assert!(matches!(
result,
Err(ConnectionError::EnthalpyMismatch { .. })
));
}
#[test]
fn test_connected_port_setters() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let (mut connected1, _) = port1.connect(port2).unwrap();
connected1.set_pressure(Pressure::from_pascals(150000.0));
connected1.set_enthalpy(Enthalpy::from_joules_per_kg(450000.0));
assert_relative_eq!(
connected1.pressure().to_pascals(),
150000.0,
epsilon = 1e-10
);
assert_relative_eq!(
connected1.enthalpy().to_joules_per_kg(),
450000.0,
epsilon = 1e-10
);
}
#[test]
fn test_ports_track_independently() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let (mut connected1, connected2) = port1.connect(port2).unwrap();
connected1.set_pressure(Pressure::from_pascals(150000.0));
// connected2 should NOT see the change - ports are independent
assert_relative_eq!(
connected2.pressure().to_pascals(),
100000.0,
epsilon = 1e-10
);
}
#[test]
fn test_fluid_id_creation() {
let fluid1 = FluidId::new("R134a");
let fluid2 = FluidId::new(String::from("Water"));
assert_eq!(fluid1.as_str(), "R134a");
assert_eq!(fluid2.as_str(), "Water");
}
#[test]
fn test_connection_error_display() {
let err = ConnectionError::IncompatibleFluid {
from: "R134a".to_string(),
to: "Water".to_string(),
};
let msg = format!("{}", err);
assert!(msg.contains("Incompatible fluids"));
assert!(msg.contains("R134a"));
assert!(msg.contains("Water"));
let err = ConnectionError::PressureMismatch {
from_pressure: 100000.0,
to_pressure: 200000.0,
tolerance: 10.0,
};
let msg = format!("{}", err);
assert!(msg.contains("100000 Pa"));
assert!(msg.contains("200000 Pa"));
assert!(msg.contains("tolerance"));
}
#[test]
fn test_pressure_averaging_on_connection() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let (connected1, connected2) = port1.connect(port2).unwrap();
assert_relative_eq!(
connected1.pressure().to_pascals(),
connected2.pressure().to_pascals(),
epsilon = 1e-10
);
}
#[test]
fn test_pressure_tolerance_with_small_difference() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100005.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let result = port1.connect(port2);
assert!(
result.is_ok(),
"5 Pa difference should be within tolerance for 100 kPa pressure"
);
}
#[test]
fn test_clone_disconnected_port() {
let port1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100000.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port2 = port1.clone();
assert_eq!(port1, port2);
}
#[test]
fn test_fluid_id_equality() {
let f1 = FluidId::new("R134a");
let f2 = FluidId::new("R134a");
let f3 = FluidId::new("Water");
assert_eq!(f1, f2);
assert_ne!(f1, f3);
}
#[test]
fn test_already_connected_error() {
let err = ConnectionError::AlreadyConnected;
let msg = format!("{}", err);
assert!(msg.contains("already connected"));
}
#[test]
fn test_cycle_detected_error() {
let err = ConnectionError::CycleDetected;
let msg = format!("{}", err);
assert!(msg.contains("cycle"));
}
#[test]
fn test_validate_port_continuity_ok() {
let p1 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
);
let p2 = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
);
let (c1, c2) = p1.connect(p2).unwrap();
assert!(validate_port_continuity(&c1, &c2).is_ok());
assert!(validate_port_continuity(&c2, &c1).is_ok());
}
#[test]
fn test_validate_port_continuity_incompatible_fluid() {
let (r134a, _) = Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
.connect(Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
))
.unwrap();
let (water, _) = Port::new(
FluidId::new("Water"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
.connect(Port::new(
FluidId::new("Water"),
Pressure::from_pascals(100_000.0),
Enthalpy::from_joules_per_kg(400_000.0),
))
.unwrap();
assert!(matches!(
validate_port_continuity(&r134a, &water),
Err(ConnectionError::IncompatibleFluid { .. })
));
}
}

View File

@@ -0,0 +1,780 @@
//! Pump Component Implementation
//!
//! This module provides a pump component for hydraulic systems using
//! polynomial performance curves and affinity laws for variable speed operation.
//!
//! ## Performance Curves
//!
//! **Head Curve:** H = a₀ + a₁Q + a₂Q² + a₃Q³
//!
//! **Efficiency Curve:** η = b₀ + b₁Q + b₂Q²
//!
//! **Hydraulic Power:** P_hydraulic = ρ × g × Q × H / η
//!
//! ## Affinity Laws (Variable Speed)
//!
//! When operating at reduced speed (VFD):
//! - Q₂/Q₁ = N₂/N₁
//! - H₂/H₁ = (N₂/N₁)²
//! - P₂/P₁ = (N₂/N₁)³
use crate::polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D};
use crate::port::{Connected, Disconnected, FluidId, Port};
use crate::state_machine::StateManageable;
use crate::{
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
ResidualVector, SystemState,
};
use entropyk_core::{MassFlow, Power};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
/// Pump performance curve coefficients.
///
/// Defines the polynomial coefficients for the pump's head-flow curve
/// and efficiency curve.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PumpCurves {
/// Performance curves (head, efficiency, optional power)
curves: PerformanceCurves,
}
impl PumpCurves {
/// Creates pump curves from performance curves.
pub fn new(curves: PerformanceCurves) -> Result<Self, ComponentError> {
curves.validate()?;
Ok(Self { curves })
}
/// Creates pump curves from polynomial coefficients.
///
/// # Arguments
///
/// * `head_coeffs` - Head curve coefficients [a0, a1, a2, ...] for H = a0 + a1*Q + a2*Q²
/// * `eff_coeffs` - Efficiency coefficients [b0, b1, b2, ...] for η = b0 + b1*Q + b2*Q²
///
/// # Units
///
/// * Q (flow) in m³/s
/// * H (head) in meters
/// * η (efficiency) as decimal (0.0 to 1.0)
pub fn from_coefficients(
head_coeffs: Vec<f64>,
eff_coeffs: Vec<f64>,
) -> Result<Self, ComponentError> {
let head_curve = Polynomial1D::new(head_coeffs);
let eff_curve = Polynomial1D::new(eff_coeffs);
let curves = PerformanceCurves::simple(head_curve, eff_curve);
Self::new(curves)
}
/// Creates a quadratic pump curve.
///
/// H = a0 + a1*Q + a2*Q²
/// η = b0 + b1*Q + b2*Q²
pub fn quadratic(
h0: f64,
h1: f64,
h2: f64,
e0: f64,
e1: f64,
e2: f64,
) -> Result<Self, ComponentError> {
Self::from_coefficients(vec![h0, h1, h2], vec![e0, e1, e2])
}
/// Creates a cubic pump curve (3rd-order polynomial for head).
///
/// H = a0 + a1*Q + a2*Q² + a3*Q³
/// η = b0 + b1*Q + b2*Q²
pub fn cubic(
h0: f64,
h1: f64,
h2: f64,
h3: f64,
e0: f64,
e1: f64,
e2: f64,
) -> Result<Self, ComponentError> {
Self::from_coefficients(vec![h0, h1, h2, h3], vec![e0, e1, e2])
}
/// Returns the head at the given flow rate (at full speed).
///
/// # Arguments
///
/// * `flow_m3_per_s` - Volumetric flow rate in m³/s
///
/// # Returns
///
/// Head in meters
pub fn head_at_flow(&self, flow_m3_per_s: f64) -> f64 {
self.curves.head_curve.evaluate(flow_m3_per_s)
}
/// Returns the efficiency at the given flow rate (at full speed).
///
/// # Arguments
///
/// * `flow_m3_per_s` - Volumetric flow rate in m³/s
///
/// # Returns
///
/// Efficiency as decimal (0.0 to 1.0)
pub fn efficiency_at_flow(&self, flow_m3_per_s: f64) -> f64 {
let eta = self.curves.efficiency_curve.evaluate(flow_m3_per_s);
// Clamp efficiency to valid range
eta.clamp(0.0, 1.0)
}
/// Returns reference to the performance curves.
pub fn curves(&self) -> &PerformanceCurves {
&self.curves
}
}
impl Default for PumpCurves {
fn default() -> Self {
Self::quadratic(30.0, 0.0, 0.0, 0.7, 0.0, 0.0).unwrap()
}
}
/// A pump component with polynomial performance curves.
///
/// The pump uses the Type-State pattern to ensure ports are connected
/// before use in simulations.
///
/// # Example
///
/// ```ignore
/// use entropyk_components::pump::{Pump, PumpCurves};
/// use entropyk_components::port::{FluidId, Port};
/// use entropyk_core::{Pressure, Enthalpy};
///
/// // Create pump curves: H = 30 - 10*Q - 50*Q² (in m and m³/s)
/// let curves = PumpCurves::quadratic(30.0, -10.0, -50.0, 0.5, 0.3, -0.5).unwrap();
///
/// let inlet = Port::new(
/// FluidId::new("Water"),
/// Pressure::from_bar(1.0),
/// Enthalpy::from_joules_per_kg(100000.0),
/// );
/// let outlet = Port::new(
/// FluidId::new("Water"),
/// Pressure::from_bar(1.0),
/// Enthalpy::from_joules_per_kg(100000.0),
/// );
///
/// let pump = Pump::new(curves, inlet, outlet, 1000.0).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct Pump<State> {
/// Performance curves
curves: PumpCurves,
/// Inlet port
port_inlet: Port<State>,
/// Outlet port
port_outlet: Port<State>,
/// Fluid density in kg/m³
fluid_density_kg_per_m3: f64,
/// Speed ratio (0.0 to 1.0), default 1.0 (full speed)
speed_ratio: f64,
/// Circuit identifier
circuit_id: CircuitId,
/// Operational state
operational_state: OperationalState,
/// Phantom data for type state
_state: PhantomData<State>,
}
impl Pump<Disconnected> {
/// Creates a new disconnected pump.
///
/// # Arguments
///
/// * `curves` - Pump performance curves
/// * `port_inlet` - Inlet port (disconnected)
/// * `port_outlet` - Outlet port (disconnected)
/// * `fluid_density` - Fluid density in kg/m³
///
/// # Errors
///
/// Returns an error if:
/// - Ports have different fluid types
/// - Fluid density is not positive
pub fn new(
curves: PumpCurves,
port_inlet: Port<Disconnected>,
port_outlet: Port<Disconnected>,
fluid_density: f64,
) -> Result<Self, ComponentError> {
if port_inlet.fluid_id() != port_outlet.fluid_id() {
return Err(ComponentError::InvalidState(
"Inlet and outlet ports must have the same fluid type".to_string(),
));
}
if fluid_density <= 0.0 {
return Err(ComponentError::InvalidState(
"Fluid density must be positive".to_string(),
));
}
Ok(Self {
curves,
port_inlet,
port_outlet,
fluid_density_kg_per_m3: fluid_density,
speed_ratio: 1.0,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
_state: PhantomData,
})
}
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &FluidId {
self.port_inlet.fluid_id()
}
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Disconnected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Disconnected> {
&self.port_outlet
}
/// Returns the fluid density.
pub fn fluid_density(&self) -> f64 {
self.fluid_density_kg_per_m3
}
/// Returns the performance curves.
pub fn curves(&self) -> &PumpCurves {
&self.curves
}
/// Returns the speed ratio.
pub fn speed_ratio(&self) -> f64 {
self.speed_ratio
}
/// Sets the speed ratio (0.0 to 1.0).
pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> {
if !(0.0..=1.0).contains(&ratio) {
return Err(ComponentError::InvalidState(
"Speed ratio must be between 0.0 and 1.0".to_string(),
));
}
self.speed_ratio = ratio;
Ok(())
}
}
impl Pump<Connected> {
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&self.port_outlet
}
/// Calculates the pressure rise across the pump.
///
/// Uses the head curve and converts to pressure:
/// ΔP = ρ × g × H
///
/// Applies affinity laws for variable speed operation.
///
/// # Arguments
///
/// * `flow_m3_per_s` - Volumetric flow rate in m³/s
///
/// # Returns
///
/// Pressure rise in Pascals
pub fn pressure_rise(&self, flow_m3_per_s: f64) -> f64 {
// Handle zero speed - pump produces no pressure
if self.speed_ratio <= 0.0 {
return 0.0;
}
// Handle zero flow
if flow_m3_per_s <= 0.0 {
// At zero flow, use the shut-off head scaled by speed
let head_m = self.curves.head_at_flow(0.0);
let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio);
const G: f64 = 9.80665; // m/s²
return self.fluid_density_kg_per_m3 * G * actual_head;
}
// Apply affinity law to get equivalent flow at full speed
let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio);
// Get head at equivalent flow
let head_m = self.curves.head_at_flow(equivalent_flow);
// Apply affinity law to scale head back to actual speed
let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio);
// Convert head to pressure: P = ρ × g × H
const G: f64 = 9.80665; // m/s²
self.fluid_density_kg_per_m3 * G * actual_head
}
/// Calculates the efficiency at the given flow rate.
///
/// Applies affinity laws to find the equivalent operating point.
pub fn efficiency(&self, flow_m3_per_s: f64) -> f64 {
// Handle zero speed - pump is not running
if self.speed_ratio <= 0.0 {
return 0.0;
}
// Handle zero flow
if flow_m3_per_s <= 0.0 {
return self.curves.efficiency_at_flow(0.0);
}
let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio);
self.curves.efficiency_at_flow(equivalent_flow)
}
/// Calculates the hydraulic power consumption.
///
/// P_hydraulic = Q × ΔP / η
///
/// # Arguments
///
/// * `flow_m3_per_s` - Volumetric flow rate in m³/s
///
/// # Returns
///
/// Power in Watts
pub fn hydraulic_power(&self, flow_m3_per_s: f64) -> Power {
if flow_m3_per_s <= 0.0 || self.speed_ratio <= 0.0 {
return Power::from_watts(0.0);
}
let delta_p = self.pressure_rise(flow_m3_per_s);
let eta = self.efficiency(flow_m3_per_s);
if eta <= 0.0 {
return Power::from_watts(0.0);
}
// P = Q × ΔP / η
let power_w = flow_m3_per_s * delta_p / eta;
Power::from_watts(power_w)
}
/// Calculates mass flow rate from volumetric flow.
pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow {
MassFlow::from_kg_per_s(flow_m3_per_s * self.fluid_density_kg_per_m3)
}
/// Calculates volumetric flow rate from mass flow.
pub fn volumetric_from_mass_flow(&self, mass_flow: MassFlow) -> f64 {
mass_flow.to_kg_per_s() / self.fluid_density_kg_per_m3
}
/// Returns the fluid density.
pub fn fluid_density(&self) -> f64 {
self.fluid_density_kg_per_m3
}
/// Returns the performance curves.
pub fn curves(&self) -> &PumpCurves {
&self.curves
}
/// Returns the speed ratio.
pub fn speed_ratio(&self) -> f64 {
self.speed_ratio
}
/// Sets the speed ratio (0.0 to 1.0).
pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> {
if !(0.0..=1.0).contains(&ratio) {
return Err(ComponentError::InvalidState(
"Speed ratio must be between 0.0 and 1.0".to_string(),
));
}
self.speed_ratio = ratio;
Ok(())
}
/// Returns both ports as a slice for solver topology.
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
}
impl Component for Pump<Connected> {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() != self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
// Handle operational states
match self.operational_state {
OperationalState::Off => {
residuals[0] = state[0]; // Mass flow = 0
residuals[1] = 0.0; // No energy transfer
return Ok(());
}
OperationalState::Bypass => {
// Behaves as a pipe: no pressure rise, no energy change
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
residuals[0] = p_in - p_out;
residuals[1] = h_in - h_out;
return Ok(());
}
OperationalState::On => {}
}
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
}
// State: [mass_flow_kg_s, power_w]
let mass_flow_kg_s = state[0];
let _power_w = state[1];
// Convert to volumetric flow
let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3;
// Calculate pressure rise from curves
let delta_p_calc = self.pressure_rise(flow_m3_s);
// Get port pressures
let p_in = self.port_inlet.pressure().to_pascals();
let p_out = self.port_outlet.pressure().to_pascals();
let delta_p_actual = p_out - p_in;
// Residual 0: Pressure balance
residuals[0] = delta_p_calc - delta_p_actual;
// Residual 1: Power balance
let power_calc = self.hydraulic_power(flow_m3_s).to_watts();
residuals[1] = power_calc - _power_w;
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
if state.len() < 2 {
return Err(ComponentError::InvalidStateDimensions {
expected: 2,
actual: state.len(),
});
}
let mass_flow_kg_s = state[0];
let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3;
// Numerical derivative of pressure with respect to mass flow
let h = 0.001;
let p_plus = self.pressure_rise(flow_m3_s + h / self.fluid_density_kg_per_m3);
let p_minus = self.pressure_rise(flow_m3_s - h / self.fluid_density_kg_per_m3);
let dp_dm = (p_plus - p_minus) / (2.0 * h);
// ∂r₀/∂ṁ = dΔP/dṁ
jacobian.add_entry(0, 0, dp_dm);
// ∂r₀/∂P = -1 (constant)
jacobian.add_entry(0, 1, 0.0);
// Numerical derivative of power with respect to mass flow
let pow_plus = self
.hydraulic_power(flow_m3_s + h / self.fluid_density_kg_per_m3)
.to_watts();
let pow_minus = self
.hydraulic_power(flow_m3_s - h / self.fluid_density_kg_per_m3)
.to_watts();
let dpow_dm = (pow_plus - pow_minus) / (2.0 * h);
// ∂r₁/∂ṁ
jacobian.add_entry(1, 0, dpow_dm);
// ∂r₁/∂P = -1
jacobian.add_entry(1, 1, -1.0);
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
impl StateManageable for Pump<Connected> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
let from = self.operational_state;
self.operational_state = state;
self.on_state_change(from, state);
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::port::FluidId;
use approx::assert_relative_eq;
use entropyk_core::{Enthalpy, Pressure};
fn create_test_curves() -> PumpCurves {
// Typical small pump:
// H = 30 - 10*Q - 50*Q² (m, Q in m³/s)
// η = 0.6 + 1.0*Q - 2.0*Q²
PumpCurves::quadratic(30.0, -10.0, -50.0, 0.6, 1.0, -2.0).unwrap()
}
fn create_test_pump_disconnected() -> Pump<Disconnected> {
let curves = create_test_curves();
let inlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let outlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
Pump::new(curves, inlet, outlet, 1000.0).unwrap()
}
fn create_test_pump_connected() -> Pump<Connected> {
let curves = create_test_curves();
let inlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let outlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
Pump {
curves,
port_inlet: inlet_conn,
port_outlet: outlet_conn,
fluid_density_kg_per_m3: 1000.0,
speed_ratio: 1.0,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
_state: PhantomData,
}
}
#[test]
fn test_pump_curves_creation() {
let curves = create_test_curves();
assert_eq!(curves.head_at_flow(0.0), 30.0);
assert_relative_eq!(curves.efficiency_at_flow(0.0), 0.6);
}
#[test]
fn test_pump_curves_head() {
let curves = create_test_curves();
// H = 30 - 10*0.5 - 50*0.25 = 30 - 5 - 12.5 = 12.5 m
let head = curves.head_at_flow(0.5);
assert_relative_eq!(head, 12.5, epsilon = 1e-10);
}
#[test]
fn test_pump_curves_efficiency_clamped() {
let curves = create_test_curves();
// At very high flow, efficiency might go negative
// Should be clamped to 0
let eff = curves.efficiency_at_flow(10.0);
assert!(eff >= 0.0);
}
#[test]
fn test_pump_creation() {
let pump = create_test_pump_disconnected();
assert_eq!(pump.fluid_density(), 1000.0);
assert_eq!(pump.speed_ratio(), 1.0);
}
#[test]
fn test_pump_invalid_density() {
let curves = create_test_curves();
let inlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let outlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let result = Pump::new(curves, inlet, outlet, -1.0);
assert!(result.is_err());
}
#[test]
fn test_pump_different_fluids() {
let curves = create_test_curves();
let inlet = Port::new(
FluidId::new("Water"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let outlet = Port::new(
FluidId::new("Glycol"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(100000.0),
);
let result = Pump::new(curves, inlet, outlet, 1000.0);
assert!(result.is_err());
}
#[test]
fn test_pump_set_speed_ratio() {
let mut pump = create_test_pump_connected();
assert!(pump.set_speed_ratio(0.8).is_ok());
assert_eq!(pump.speed_ratio(), 0.8);
}
#[test]
fn test_pump_set_speed_ratio_invalid() {
let mut pump = create_test_pump_connected();
assert!(pump.set_speed_ratio(1.5).is_err());
assert!(pump.set_speed_ratio(-0.1).is_err());
}
#[test]
fn test_pump_pressure_rise_full_speed() {
let pump = create_test_pump_connected();
// At Q=0: H=30m, P = 1000 * 9.8 * 30 ≈ 294200 Pa
let delta_p = pump.pressure_rise(0.0);
let expected = 1000.0 * 9.80665 * 30.0;
assert_relative_eq!(delta_p, expected, epsilon = 100.0);
}
#[test]
fn test_pump_pressure_rise_reduced_speed() {
let mut pump = create_test_pump_connected();
pump.set_speed_ratio(0.5).unwrap();
// At 50% speed, shut-off head is 25% of full speed
// H = 0.25 * 30 = 7.5 m
let delta_p = pump.pressure_rise(0.0);
let expected = 1000.0 * 9.80665 * 7.5;
assert_relative_eq!(delta_p, expected, epsilon = 100.0);
}
#[test]
fn test_pump_hydraulic_power() {
let pump = create_test_pump_connected();
// At Q=0.1 m³/s: H ≈ 30 - 1 - 0.5 = 28.5 m
// η ≈ 0.6 + 0.1 - 0.02 = 0.68
// P = 1000 * 9.8 * 0.1 * 28.5 / 0.68 ≈ 4110 W
let power = pump.hydraulic_power(0.1);
assert!(power.to_watts() > 0.0);
assert!(power.to_watts() < 50000.0);
}
#[test]
fn test_pump_affinity_laws_power() {
let pump_full = create_test_pump_connected();
let mut pump_half = create_test_pump_connected();
pump_half.set_speed_ratio(0.5).unwrap();
// Power at half speed should be ~12.5% of full speed (cube law)
// At the same equivalent flow point
let power_full = pump_full.hydraulic_power(0.1);
let power_half = pump_half.hydraulic_power(0.05); // Half the flow
// P_half / P_full ≈ 0.5³ = 0.125
let ratio = power_half.to_watts() / power_full.to_watts();
assert_relative_eq!(ratio, 0.125, epsilon = 0.05);
}
#[test]
fn test_pump_component_n_equations() {
let pump = create_test_pump_connected();
assert_eq!(pump.n_equations(), 2);
}
#[test]
fn test_pump_component_compute_residuals() {
let pump = create_test_pump_connected();
let state = vec![50.0, 2000.0]; // mass flow, power
let mut residuals = vec![0.0; 2];
let result = pump.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_pump_state_manageable() {
let pump = create_test_pump_connected();
assert_eq!(pump.state(), OperationalState::On);
assert!(pump.can_transition_to(OperationalState::Off));
}
}

View File

@@ -0,0 +1,940 @@
//! Component State Machine and Circuit Management
//!
//! This module provides types for managing component operational states (ON/OFF/BYPASS)
//! and circuit identification, as required by FR6-FR9 of the Entropyk specification.
//!
//! ## Operational States
//!
//! Components can be in one of three operational states:
//! - **On**: Normal operation with full thermodynamic behavior
//! - **Off**: Component contributes zero mass flow (FR7)
//! - **Bypass**: Component behaves as an adiabatic pipe (FR8)
//!
//! ## Circuit Identification
//!
//! Each component belongs to a specific circuit identified by a `CircuitId`.
//! Multi-circuit machines allow simulation of complex systems like dual-circuit
//! heat pumps (FR9).
//!
//! ## State Management
//!
//! The [`StateManageable`] trait provides a common interface for components
//! that support operational state management. All major components (Compressor,
//! ExpansionValve, HeatExchanger) implement this trait.
//!
//! ## State History
//!
//! For debugging purposes, the [`StateHistory`] type can track state transitions
//! with timestamps.
//!
//! ## Example
//!
//! ```rust
//! use entropyk_components::state_machine::{OperationalState, CircuitId, StateManageable};
//!
//! // Create a circuit identifier
//! let circuit = CircuitId::new("primary");
//!
//! // Set component state
//! let state = OperationalState::On;
//!
//! // Check transitions
//! assert!(state.can_transition_to(OperationalState::Off));
//! ```
use std::collections::VecDeque;
use std::time::Instant;
use crate::ComponentError;
/// Error type for invalid state transitions.
///
/// This error is returned when attempting an invalid state transition.
/// Currently, all transitions between states are allowed, but this type
/// is provided for future extensibility and custom transition rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StateTransitionError {
/// The state we're transitioning from
pub from: OperationalState,
/// The state we're attempting to transition to
pub to: OperationalState,
/// Human-readable reason for the failure
pub reason: &'static str,
}
impl std::fmt::Display for StateTransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Invalid state transition from {:?} to {:?}: {}",
self.from, self.to, self.reason
)
}
}
impl std::error::Error for StateTransitionError {}
impl From<StateTransitionError> for crate::ComponentError {
fn from(err: StateTransitionError) -> Self {
crate::ComponentError::InvalidStateTransition {
from: err.from,
to: err.to,
reason: err.reason.to_string(),
}
}
}
/// Operational state of a component.
///
/// This enum represents the three possible operational states of a component
/// as defined in FR6-FR8:
///
/// - **On**: Normal operation with full thermodynamic calculations
/// - **Off**: Component contributes zero mass flow to the system
/// - **Bypass**: Component behaves as an adiabatic pipe (P_in = P_out, h_in = h_out)
///
/// # State Behavior
///
/// | State | Mass Flow | Energy Transfer | Pressure Drop |
/// |-------|-----------|-----------------|---------------|
/// | On | Normal | Full | Normal |
/// | Off | Zero | None | Infinite |
/// | Bypass| Continuity| None (adiabatic)| Zero |
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// let state = OperationalState::On;
/// assert!(state.is_active());
///
/// let state = OperationalState::Off;
/// assert!(!state.is_active());
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OperationalState {
/// Normal operation with full thermodynamic behavior.
///
/// In this state, the component performs its full thermodynamic calculations,
/// including heat transfer, pressure changes, and work transfer.
On,
/// Component is turned off, contributing zero mass flow.
///
/// When a component is in the Off state (FR7):
/// - Mass flow through the component is forced to zero
/// - The component acts as a blockage in the circuit
/// - No heat transfer or work transfer occurs
/// - This state is used for simulating component failures or seasonal operation
Off,
/// Bypass mode - component behaves as an adiabatic pipe.
///
/// When a component is in the Bypass state (FR8):
/// - Pressure at inlet equals pressure at outlet (P_in = P_out)
/// - Enthalpy at inlet equals enthalpy at outlet (h_in = h_out)
/// - No heat transfer occurs (adiabatic)
/// - Mass flow continues through the bypass path
/// - This state is useful for economizers in summer mode or valve bypasses
Bypass,
}
impl OperationalState {
/// Returns true if the component is active (On or Bypass).
///
/// An active component allows mass flow through it, though Bypass
/// mode has different thermodynamic behavior than On mode.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert!(OperationalState::On.is_active());
/// assert!(OperationalState::Bypass.is_active());
/// assert!(!OperationalState::Off.is_active());
/// ```
pub fn is_active(&self) -> bool {
matches!(self, OperationalState::On | OperationalState::Bypass)
}
/// Returns true if the component is in normal operation mode.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert!(OperationalState::On.is_on());
/// assert!(!OperationalState::Off.is_on());
/// assert!(!OperationalState::Bypass.is_on());
/// ```
pub fn is_on(&self) -> bool {
matches!(self, OperationalState::On)
}
/// Returns true if the component is off.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert!(OperationalState::Off.is_off());
/// assert!(!OperationalState::On.is_off());
/// ```
pub fn is_off(&self) -> bool {
matches!(self, OperationalState::Off)
}
/// Returns true if the component is in bypass mode.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert!(OperationalState::Bypass.is_bypass());
/// assert!(!OperationalState::On.is_bypass());
/// ```
pub fn is_bypass(&self) -> bool {
matches!(self, OperationalState::Bypass)
}
/// Returns the mass flow multiplier for this state.
///
/// This multiplier is used in residual calculations:
/// - On: 1.0 (full mass flow)
/// - Bypass: 1.0 (mass flow continues through bypass)
/// - Off: 0.0 (no mass flow)
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert_eq!(OperationalState::On.mass_flow_multiplier(), 1.0);
/// assert_eq!(OperationalState::Bypass.mass_flow_multiplier(), 1.0);
/// assert_eq!(OperationalState::Off.mass_flow_multiplier(), 0.0);
/// ```
pub fn mass_flow_multiplier(&self) -> f64 {
match self {
OperationalState::On => 1.0,
OperationalState::Off => 0.0,
OperationalState::Bypass => 1.0,
}
}
/// Checks if a transition to the target state is valid.
///
/// Currently, all state transitions are allowed. This method is provided
/// for components that may have custom transition rules.
///
/// # Arguments
///
/// * `target` - The target operational state
///
/// # Returns
///
/// Returns `true` if the transition is valid.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// assert!(OperationalState::On.can_transition_to(OperationalState::Off));
/// assert!(OperationalState::Off.can_transition_to(OperationalState::Bypass));
/// assert!(OperationalState::Bypass.can_transition_to(OperationalState::On));
/// ```
pub fn can_transition_to(&self, target: OperationalState) -> bool {
matches!(
(self, target),
(OperationalState::On, OperationalState::Off)
| (OperationalState::On, OperationalState::Bypass)
| (OperationalState::Off, OperationalState::On)
| (OperationalState::Off, OperationalState::Bypass)
| (OperationalState::Bypass, OperationalState::On)
| (OperationalState::Bypass, OperationalState::Off)
| (OperationalState::On, OperationalState::On)
| (OperationalState::Off, OperationalState::Off)
| (OperationalState::Bypass, OperationalState::Bypass)
)
}
/// Attempts to transition to the target state.
///
/// # Arguments
///
/// * `target` - The target operational state
///
/// # Returns
///
/// Returns `Ok(target)` if the transition is valid, or a [`StateTransitionError`]
/// if the transition is not allowed.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::OperationalState;
///
/// let state = OperationalState::On;
/// let new_state = state.transition_to(OperationalState::Off).unwrap();
/// assert_eq!(new_state, OperationalState::Off);
/// ```
pub fn transition_to(
&self,
target: OperationalState,
) -> Result<OperationalState, StateTransitionError> {
if self.can_transition_to(target) {
Ok(target)
} else {
Err(StateTransitionError {
from: *self,
to: target,
reason: "Transition not allowed",
})
}
}
}
impl Default for OperationalState {
/// Default operational state is On.
fn default() -> Self {
OperationalState::On
}
}
/// Unique identifier for a thermodynamic circuit.
///
/// A `CircuitId` identifies a complete fluid circuit within a machine.
/// Multi-circuit machines (e.g., dual-circuit heat pumps) require distinct
/// identifiers for each independent fluid loop (FR9).
///
/// # Use Cases
///
/// - Single-circuit machines: Use "default" or "main"
/// - Dual-circuit heat pumps: Use "circuit_1" and "circuit_2"
/// - Complex systems: Use descriptive names like "primary", "secondary", "economizer"
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::CircuitId;
///
/// let main_circuit = CircuitId::new("main");
/// let secondary = CircuitId::new("secondary");
///
/// assert_ne!(main_circuit, secondary);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CircuitId(String);
impl CircuitId {
/// Creates a new circuit identifier from a string.
///
/// # Arguments
///
/// * `id` - A unique string identifier for the circuit
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::CircuitId;
///
/// let circuit = CircuitId::new("primary");
/// ```
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
/// Returns the circuit identifier as a string slice.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::CircuitId;
///
/// let circuit = CircuitId::new("main");
/// assert_eq!(circuit.as_str(), "main");
/// ```
pub fn as_str(&self) -> &str {
&self.0
}
/// Creates a default circuit identifier.
///
/// Returns a CircuitId with value "default".
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::CircuitId;
///
/// let default = CircuitId::default_circuit();
/// assert_eq!(default.as_str(), "default");
/// ```
pub fn default_circuit() -> Self {
Self("default".to_string())
}
}
impl Default for CircuitId {
/// Default circuit identifier is "default".
fn default() -> Self {
Self("default".to_string())
}
}
impl AsRef<str> for CircuitId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CircuitId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// Record of a state transition for debugging purposes.
///
/// Tracks when a component changed states, what the previous state was,
/// and what the new state is.
#[derive(Debug, Clone)]
pub struct StateTransitionRecord {
/// Timestamp when the transition occurred
pub timestamp: Instant,
/// State before the transition
pub from_state: OperationalState,
/// State after the transition
pub to_state: OperationalState,
}
impl StateTransitionRecord {
/// Creates a new state transition record.
///
/// # Arguments
///
/// * `from_state` - The state before transition
/// * `to_state` - The state after transition
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::{StateTransitionRecord, OperationalState};
///
/// let record = StateTransitionRecord::new(OperationalState::On, OperationalState::Off);
/// assert_eq!(record.from_state, OperationalState::On);
/// assert_eq!(record.to_state, OperationalState::Off);
/// ```
pub fn new(from_state: OperationalState, to_state: OperationalState) -> Self {
Self {
timestamp: Instant::now(),
from_state,
to_state,
}
}
/// Returns the elapsed time since this transition occurred.
pub fn elapsed(&self) -> std::time::Duration {
self.timestamp.elapsed()
}
}
/// History buffer for tracking state transitions.
///
/// Maintains a configurable-size buffer of recent state transitions
/// for debugging and analysis purposes.
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::{StateHistory, OperationalState};
///
/// let mut history = StateHistory::new(10);
/// history.record(OperationalState::On, OperationalState::Off);
///
/// assert_eq!(history.len(), 1);
/// assert_eq!(history.records()[0].from_state, OperationalState::On);
/// ```
#[derive(Debug, Clone)]
pub struct StateHistory {
records: VecDeque<StateTransitionRecord>,
max_depth: usize,
}
impl StateHistory {
/// Default maximum history depth.
pub const DEFAULT_MAX_DEPTH: usize = 10;
/// Creates a new state history with the specified maximum depth.
///
/// # Arguments
///
/// * `max_depth` - Maximum number of records to keep
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::StateHistory;
///
/// let history = StateHistory::new(20);
/// assert_eq!(history.max_depth(), 20);
/// ```
pub fn new(max_depth: usize) -> Self {
Self {
records: VecDeque::with_capacity(max_depth),
max_depth,
}
}
/// Creates a new state history with default depth (10 records).
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::StateHistory;
///
/// let history = StateHistory::default();
/// assert_eq!(history.max_depth(), 10);
/// ```
pub fn with_default_depth() -> Self {
Self::new(Self::DEFAULT_MAX_DEPTH)
}
/// Returns the maximum number of records this history can hold.
pub fn max_depth(&self) -> usize {
self.max_depth
}
/// Records a state transition.
///
/// If the history is full, the oldest record is removed.
///
/// # Arguments
///
/// * `from_state` - The state before transition
/// * `to_state` - The state after transition
///
/// # Examples
///
/// ```
/// use entropyk_components::state_machine::{StateHistory, OperationalState};
///
/// let mut history = StateHistory::default();
/// history.record(OperationalState::On, OperationalState::Off);
///
/// assert_eq!(history.len(), 1);
/// ```
pub fn record(&mut self, from_state: OperationalState, to_state: OperationalState) {
if self.records.len() >= self.max_depth {
self.records.pop_front();
}
self.records
.push_back(StateTransitionRecord::new(from_state, to_state));
}
/// Returns the number of records in the history.
pub fn len(&self) -> usize {
self.records.len()
}
/// Returns true if there are no records in the history.
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
/// Returns a slice of all records, oldest first.
pub fn records(&self) -> &[StateTransitionRecord] {
self.records.as_slices().0
}
/// Returns the most recent transition record, if any.
pub fn last(&self) -> Option<&StateTransitionRecord> {
self.records.back()
}
/// Clears all records from the history.
pub fn clear(&mut self) {
self.records.clear();
}
}
impl Default for StateHistory {
fn default() -> Self {
Self::with_default_depth()
}
}
/// Trait for components that support operational state management.
///
/// This trait provides a common interface for managing the operational
/// state of thermodynamic components. All major components (Compressor,
/// ExpansionValve, HeatExchanger) implement this trait.
///
/// # Object Safety
///
/// This trait is object-safe and can be used with dynamic dispatch.
///
/// # Callback Hooks
///
/// The trait provides optional callback hooks via `on_state_change()` which
/// can be overridden to perform actions when state transitions occur.
///
/// # Examples
///
/// ```rust,ignore
/// use entropyk_components::state_machine::{StateManageable, OperationalState, CircuitId};
/// use entropyk_components::ComponentError;
///
/// fn check_component_state(component: &dyn StateManageable) {
/// println!("Component state: {:?}", component.state());
/// println!("Circuit: {}", component.circuit_id().as_str());
/// }
/// ```
pub trait StateManageable {
/// Returns the current operational state.
fn state(&self) -> OperationalState;
/// Sets the operational state with validation.
///
/// # Arguments
///
/// * `state` - The new operational state
///
/// # Returns
///
/// Returns `Ok(())` if the transition is valid, or a [`ComponentError`]
/// if the transition is not allowed.
///
/// # Errors
///
/// Returns [`ComponentError::InvalidStateTransition`] if the transition
/// is not valid for this component.
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError>;
/// Checks if a transition to the target state is valid.
///
/// # Arguments
///
/// * `target` - The target operational state
///
/// # Returns
///
/// Returns `true` if the transition is valid.
fn can_transition_to(&self, target: OperationalState) -> bool;
/// Returns the circuit identifier.
fn circuit_id(&self) -> &CircuitId;
/// Sets the circuit identifier.
///
/// # Arguments
///
/// * `circuit_id` - The new circuit identifier
fn set_circuit_id(&mut self, circuit_id: CircuitId);
/// Optional callback invoked after a state change.
///
/// Override this method to perform actions when the component's state changes,
/// such as logging, updating internal state, or triggering side effects.
///
/// # Arguments
///
/// * `from` - The previous operational state
/// * `to` - The new operational state
///
/// # Default Implementation
///
/// The default implementation does nothing. Override to add custom behavior.
fn on_state_change(&mut self, _from: OperationalState, _to: OperationalState) {
// Default: no-op. Override to add callback behavior.
}
/// Returns the state transition history, if tracking is enabled.
///
/// Components can optionally track state transition history for debugging.
/// By default, this returns `None`. Override to return a reference to
/// the component's state history.
///
/// # Returns
///
/// Returns `Some(&StateHistory)` if history tracking is enabled, or `None` otherwise.
fn state_history(&self) -> Option<&StateHistory> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_operational_state_on() {
let state = OperationalState::On;
assert!(state.is_on());
assert!(!state.is_off());
assert!(!state.is_bypass());
assert!(state.is_active());
assert_eq!(state.mass_flow_multiplier(), 1.0);
}
#[test]
fn test_operational_state_off() {
let state = OperationalState::Off;
assert!(!state.is_on());
assert!(state.is_off());
assert!(!state.is_bypass());
assert!(!state.is_active());
assert_eq!(state.mass_flow_multiplier(), 0.0);
}
#[test]
fn test_operational_state_bypass() {
let state = OperationalState::Bypass;
assert!(!state.is_on());
assert!(!state.is_off());
assert!(state.is_bypass());
assert!(state.is_active());
assert_eq!(state.mass_flow_multiplier(), 1.0);
}
#[test]
fn test_operational_state_default() {
let state: OperationalState = Default::default();
assert!(state.is_on());
}
#[test]
fn test_operational_state_equality() {
assert_eq!(OperationalState::On, OperationalState::On);
assert_eq!(OperationalState::Off, OperationalState::Off);
assert_eq!(OperationalState::Bypass, OperationalState::Bypass);
assert_ne!(OperationalState::On, OperationalState::Off);
assert_ne!(OperationalState::On, OperationalState::Bypass);
}
#[test]
fn test_can_transition_to_all_combinations() {
let states = [
OperationalState::On,
OperationalState::Off,
OperationalState::Bypass,
];
for from in states {
for to in states {
assert!(
from.can_transition_to(to),
"Transition from {:?} to {:?} should be allowed",
from,
to
);
}
}
}
#[test]
fn test_transition_to_success() {
let state = OperationalState::On;
let result = state.transition_to(OperationalState::Off);
assert!(result.is_ok());
assert_eq!(result.unwrap(), OperationalState::Off);
}
#[test]
fn test_transition_to_same_state() {
let state = OperationalState::On;
let result = state.transition_to(OperationalState::On);
assert!(result.is_ok());
assert_eq!(result.unwrap(), OperationalState::On);
}
#[test]
fn test_state_transition_error_display() {
let err = StateTransitionError {
from: OperationalState::On,
to: OperationalState::Off,
reason: "Test reason",
};
let msg = format!("{}", err);
assert!(msg.contains("Invalid state transition"));
assert!(msg.contains("On"));
assert!(msg.contains("Off"));
assert!(msg.contains("Test reason"));
}
#[test]
fn test_circuit_id_creation() {
let circuit = CircuitId::new("main");
assert_eq!(circuit.as_str(), "main");
}
#[test]
fn test_circuit_id_from_string() {
let name = String::from("secondary");
let circuit = CircuitId::new(name);
assert_eq!(circuit.as_str(), "secondary");
}
#[test]
fn test_circuit_id_default() {
let circuit = CircuitId::default();
assert_eq!(circuit.as_str(), "default");
}
#[test]
fn test_circuit_id_default_circuit() {
let circuit = CircuitId::default_circuit();
assert_eq!(circuit.as_str(), "default");
}
#[test]
fn test_circuit_id_equality() {
let c1 = CircuitId::new("circuit_1");
let c2 = CircuitId::new("circuit_1");
let c3 = CircuitId::new("circuit_2");
assert_eq!(c1, c2);
assert_ne!(c1, c3);
}
#[test]
fn test_circuit_id_as_ref() {
let circuit = CircuitId::new("test");
let s: &str = circuit.as_ref();
assert_eq!(s, "test");
}
#[test]
fn test_circuit_id_display() {
let circuit = CircuitId::new("main_circuit");
assert_eq!(format!("{}", circuit), "main_circuit");
}
#[test]
fn test_circuit_id_hash() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(CircuitId::new("c1"), 1);
map.insert(CircuitId::new("c2"), 2);
assert_eq!(map.get(&CircuitId::new("c1")), Some(&1));
assert_eq!(map.get(&CircuitId::new("c2")), Some(&2));
}
#[test]
fn test_state_transition_record_creation() {
let record = StateTransitionRecord::new(OperationalState::On, OperationalState::Off);
assert_eq!(record.from_state, OperationalState::On);
assert_eq!(record.to_state, OperationalState::Off);
}
#[test]
fn test_state_transition_record_elapsed() {
let record = StateTransitionRecord::new(OperationalState::On, OperationalState::Off);
let elapsed = record.elapsed();
assert!(elapsed.as_nanos() >= 0);
}
#[test]
fn test_state_history_creation() {
let history = StateHistory::new(5);
assert_eq!(history.max_depth(), 5);
assert!(history.is_empty());
assert_eq!(history.len(), 0);
}
#[test]
fn test_state_history_default() {
let history = StateHistory::default();
assert_eq!(history.max_depth(), StateHistory::DEFAULT_MAX_DEPTH);
}
#[test]
fn test_state_history_with_default_depth() {
let history = StateHistory::with_default_depth();
assert_eq!(history.max_depth(), 10);
}
#[test]
fn test_state_history_record() {
let mut history = StateHistory::new(10);
history.record(OperationalState::On, OperationalState::Off);
assert_eq!(history.len(), 1);
assert!(!history.is_empty());
let records = history.records();
assert_eq!(records.len(), 1);
assert_eq!(records[0].from_state, OperationalState::On);
assert_eq!(records[0].to_state, OperationalState::Off);
}
#[test]
fn test_state_history_last() {
let mut history = StateHistory::new(10);
assert!(history.last().is_none());
history.record(OperationalState::On, OperationalState::Off);
let last = history.last().unwrap();
assert_eq!(last.from_state, OperationalState::On);
assert_eq!(last.to_state, OperationalState::Off);
history.record(OperationalState::Off, OperationalState::Bypass);
let last = history.last().unwrap();
assert_eq!(last.from_state, OperationalState::Off);
assert_eq!(last.to_state, OperationalState::Bypass);
}
#[test]
fn test_state_history_max_depth() {
let mut history = StateHistory::new(3);
history.record(OperationalState::On, OperationalState::Off);
history.record(OperationalState::Off, OperationalState::Bypass);
history.record(OperationalState::Bypass, OperationalState::On);
assert_eq!(history.len(), 3);
history.record(OperationalState::On, OperationalState::Off);
assert_eq!(history.len(), 3);
let records = history.records();
assert_eq!(records[0].from_state, OperationalState::Off);
assert_eq!(records[0].to_state, OperationalState::Bypass);
}
#[test]
fn test_state_history_clear() {
let mut history = StateHistory::new(10);
history.record(OperationalState::On, OperationalState::Off);
history.record(OperationalState::Off, OperationalState::Bypass);
assert_eq!(history.len(), 2);
history.clear();
assert!(history.is_empty());
assert_eq!(history.len(), 0);
}
#[test]
fn test_state_history_multiple_transitions() {
let mut history = StateHistory::new(10);
history.record(OperationalState::On, OperationalState::Off);
history.record(OperationalState::Off, OperationalState::Bypass);
history.record(OperationalState::Bypass, OperationalState::On);
assert_eq!(history.len(), 3);
let records = history.records();
assert_eq!(records[0].from_state, OperationalState::On);
assert_eq!(records[1].from_state, OperationalState::Off);
assert_eq!(records[2].from_state, OperationalState::Bypass);
}
}

View File

@@ -13,3 +13,4 @@ serde.workspace = true
[dev-dependencies]
approx = "0.5"
serde_json = "1.0"

175
crates/core/src/calib.rs Normal file
View File

@@ -0,0 +1,175 @@
//! Calibration factors (Calib) for matching simulation to real machine test data.
//!
//! Short name: Calib. Default 1.0 = no correction. Typical range [0.8, 1.2].
//! Refs: Buildings Modelica, EnergyPlus, TRNSYS, TIL Suite, alphaXiv.
//!
//! ## Recommended calibration order
//!
//! To avoid parameter fighting, calibrate in this order:
//! 1. **f_m** — mass flow (compressor power + ṁ measurements)
//! 2. **f_dp** — pressure drops (inlet/outlet pressures)
//! 3. **f_ua** — heat transfer (superheat, subcooling, capacity)
//! 4. **f_power** — compressor power (if f_m insufficient)
use serde::{Deserialize, Serialize};
fn one() -> f64 {
1.0
}
/// Calibration factors for matching simulation to real machine test data.
///
/// Default 1.0 = no correction. Typical range [0.8, 1.2]. All factors are validated to lie in [0.5, 2.0].
///
/// | Field | Full name | Effect | Components |
/// |-----------|------------------------|---------------------------------|-----------------------------|
/// | `f_m` | mass flow factor | ṁ_eff = f_m × ṁ_nominal | Compressor, Expansion Valve |
/// | `f_dp` | pressure drop factor | ΔP_eff = f_dp × ΔP_nominal | Pipe, Heat Exchanger |
/// | `f_ua` | UA factor | UA_eff = f_ua × UA_nominal | Evaporator, Condenser |
/// | `f_power` | power factor | Ẇ_eff = f_power × Ẇ_nominal | Compressor |
/// | `f_etav` | volumetric efficiency | η_v,eff = f_etav × η_v,nominal | Compressor (displacement) |
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Calib {
/// f_m: ṁ_eff = f_m × ṁ_nominal (Compressor, Valve)
#[serde(default = "one", alias = "calib_flow")]
pub f_m: f64,
/// f_dp: ΔP_eff = f_dp × ΔP_nominal (Pipe, HX)
#[serde(default = "one", alias = "calib_dpr")]
pub f_dp: f64,
/// f_ua: UA_eff = f_ua × UA_nominal (Evaporator, Condenser)
#[serde(default = "one", alias = "calib_ua")]
pub f_ua: f64,
/// f_power: Ẇ_eff = f_power × Ẇ_nominal (Compressor)
#[serde(default = "one")]
pub f_power: f64,
/// f_etav: η_v,eff = f_etav × η_v,nominal (Compressor displacement)
#[serde(default = "one")]
pub f_etav: f64,
}
impl Default for Calib {
fn default() -> Self {
Self {
f_m: 1.0,
f_dp: 1.0,
f_ua: 1.0,
f_power: 1.0,
f_etav: 1.0,
}
}
}
/// Error returned when a calibration factor is outside the allowed range [0.5, 2.0].
#[derive(Debug, Clone, PartialEq)]
pub struct CalibValidationError {
/// Factor name (e.g. "f_m")
pub factor: &'static str,
/// Value that failed validation
pub value: f64,
}
impl std::fmt::Display for CalibValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"calib {} = {} is outside allowed range [0.5, 2.0]",
self.factor, self.value
)
}
}
impl std::error::Error for CalibValidationError {}
const MIN_F: f64 = 0.5;
const MAX_F: f64 = 2.0;
impl Calib {
/// Validates that all factors lie in [0.5, 2.0]. Returns `Ok(())` or the first invalid factor.
pub fn validate(&self) -> Result<(), CalibValidationError> {
let check = |name: &'static str, value: f64| {
if !(MIN_F..=MAX_F).contains(&value) {
Err(CalibValidationError {
factor: name,
value,
})
} else {
Ok(())
}
};
check("f_m", self.f_m)?;
check("f_dp", self.f_dp)?;
check("f_ua", self.f_ua)?;
check("f_power", self.f_power)?;
check("f_etav", self.f_etav)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calib_default_all_one() {
let c = Calib::default();
assert_eq!(c.f_m, 1.0);
assert_eq!(c.f_dp, 1.0);
assert_eq!(c.f_ua, 1.0);
assert_eq!(c.f_power, 1.0);
assert_eq!(c.f_etav, 1.0);
assert!(c.validate().is_ok());
}
#[test]
fn test_calib_validation_bounds() {
let ok = Calib {
f_m: 0.5,
f_dp: 1.0,
f_ua: 2.0,
f_power: 1.0,
f_etav: 1.0,
};
assert!(ok.validate().is_ok());
let bad_m = Calib {
f_m: 0.4,
..Default::default()
};
let err = bad_m.validate().unwrap_err();
assert_eq!(err.factor, "f_m");
assert!((err.value - 0.4).abs() < 1e-9);
let bad_high = Calib {
f_ua: 2.1,
..Default::default()
};
let err2 = bad_high.validate().unwrap_err();
assert_eq!(err2.factor, "f_ua");
}
#[test]
fn test_calib_json_roundtrip() {
let c = Calib {
f_m: 1.1,
f_dp: 0.9,
f_ua: 1.0,
f_power: 1.05,
f_etav: 1.0,
};
let json = serde_json::to_string(&c).unwrap();
let c2: Calib = serde_json::from_str(&json).unwrap();
assert_eq!(c, c2);
}
#[test]
fn test_calib_aliases_backward_compat() {
// calib_flow → f_m
let json = r#"{"calib_flow": 1.2}"#;
let c: Calib = serde_json::from_str(json).unwrap();
assert_eq!(c.f_m, 1.2);
assert_eq!(c.f_dp, 1.0);
assert_eq!(c.f_ua, 1.0);
assert_eq!(c.f_power, 1.0);
assert_eq!(c.f_etav, 1.0);
}
}

View File

@@ -37,7 +37,14 @@
#![deny(warnings)]
#![warn(missing_docs)]
pub mod calib;
pub mod types;
// Re-export all physical types for convenience
pub use types::{Enthalpy, MassFlow, Pressure, Temperature};
pub use types::{
Enthalpy, MassFlow, MIN_MASS_FLOW_REGULARIZATION_KG_S, Power, Pressure, Temperature,
ThermalConductance,
};
// Re-export calibration types
pub use calib::{Calib, CalibValidationError};

View File

@@ -303,10 +303,24 @@ impl Div<f64> for Enthalpy {
}
}
/// Minimum mass flow used in denominators to avoid division by zero (zero-flow regularization).
///
/// When mass flow is zero or below this value, use [`MassFlow::regularized`] in any expression
/// that divides by mass flow (e.g. Q/ṁ, ΔP/ṁ²) or by quantities derived from it (e.g. Reynolds,
/// capacity rate C = ṁ·Cp). This prevents NaN/Inf while preserving solver convergence.
///
/// Value: 1e-12 kg/s (small enough to not affect physical results when ṁ >> ε).
pub const MIN_MASS_FLOW_REGULARIZATION_KG_S: f64 = 1e-12;
/// Mass flow rate in kilograms per second (kg/s).
///
/// Internally stores the value in kilograms per second (SI base unit).
///
/// # Zero-flow regularization
///
/// When dividing by mass flow (or using it in denominators), use [`MassFlow::regularized`] so that
/// zero-flow branches do not cause division by zero. See [`MIN_MASS_FLOW_REGULARIZATION_KG_S`].
///
/// # Example
///
/// ```
@@ -338,6 +352,15 @@ impl MassFlow {
pub fn to_grams_per_s(&self) -> f64 {
self.0 * 1_000.0
}
/// Returns mass flow clamped to at least [`MIN_MASS_FLOW_REGULARIZATION_KG_S`] for use in denominators.
///
/// Use this whenever dividing by mass flow (e.g. Q/ṁ) or by a quantity derived from it (e.g. Re ∝ ṁ)
/// to avoid division by zero when the branch has zero flow (e.g. component in Off state).
#[must_use]
pub fn regularized(self) -> Self {
MassFlow(self.0.max(MIN_MASS_FLOW_REGULARIZATION_KG_S))
}
}
impl fmt::Display for MassFlow {
@@ -392,6 +415,148 @@ impl Div<f64> for MassFlow {
}
}
/// Power in Watts (W).
///
/// Internally stores the value in Watts (SI base unit).
/// Provides conversions to/from common units like kilowatts.
///
/// # Example
///
/// ```
/// use entropyk_core::Power;
///
/// let p = Power::from_kilowatts(1.0);
/// assert_eq!(p.to_watts(), 1000.0);
/// assert_eq!(p.to_kilowatts(), 1.0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Power(pub f64);
impl Power {
/// Creates a Power from a value in Watts.
pub fn from_watts(value: f64) -> Self {
Power(value)
}
/// Creates a Power from a value in kilowatts.
pub fn from_kilowatts(value: f64) -> Self {
Power(value * 1_000.0)
}
/// Creates a Power from a value in megawatts.
pub fn from_megawatts(value: f64) -> Self {
Power(value * 1_000_000.0)
}
/// Returns the power in Watts.
pub fn to_watts(&self) -> f64 {
self.0
}
/// Returns the power in kilowatts.
pub fn to_kilowatts(&self) -> f64 {
self.0 / 1_000.0
}
/// Returns the power in megawatts.
pub fn to_megawatts(&self) -> f64 {
self.0 / 1_000_000.0
}
}
impl fmt::Display for Power {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} W", self.0)
}
}
impl From<f64> for Power {
fn from(value: f64) -> Self {
Power(value)
}
}
impl Add<Power> for Power {
type Output = Power;
fn add(self, other: Power) -> Power {
Power(self.0 + other.0)
}
}
impl Sub<Power> for Power {
type Output = Power;
fn sub(self, other: Power) -> Power {
Power(self.0 - other.0)
}
}
impl Mul<f64> for Power {
type Output = Power;
fn mul(self, scalar: f64) -> Power {
Power(self.0 * scalar)
}
}
impl Mul<Power> for f64 {
type Output = Power;
fn mul(self, p: Power) -> Power {
Power(self * p.0)
}
}
impl Div<f64> for Power {
type Output = Power;
fn div(self, scalar: f64) -> Power {
Power(self.0 / scalar)
}
}
/// Thermal conductance in Watts per Kelvin (W/K).
///
/// Represents the heat transfer coefficient (UA value) for thermal coupling
/// between circuits or components.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct ThermalConductance(pub f64);
impl ThermalConductance {
/// Creates a ThermalConductance from a value in Watts per Kelvin (W/K).
pub fn from_watts_per_kelvin(value: f64) -> Self {
ThermalConductance(value)
}
/// Creates a ThermalConductance from a value in kilowatts per Kelvin (kW/K).
pub fn from_kilowatts_per_kelvin(value: f64) -> Self {
ThermalConductance(value * 1_000.0)
}
/// Returns the thermal conductance in Watts per Kelvin.
pub fn to_watts_per_kelvin(&self) -> f64 {
self.0
}
/// Returns the thermal conductance in kilowatts per Kelvin.
pub fn to_kilowatts_per_kelvin(&self) -> f64 {
self.0 / 1_000.0
}
}
impl fmt::Display for ThermalConductance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} W/K", self.0)
}
}
impl From<f64> for ThermalConductance {
fn from(value: f64) -> Self {
ThermalConductance(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -656,6 +821,20 @@ mod tests {
assert_relative_eq!(m1.to_grams_per_s(), 500.0, epsilon = 1e-6);
}
#[test]
fn test_mass_flow_regularized() {
use super::MIN_MASS_FLOW_REGULARIZATION_KG_S;
let zero = MassFlow::from_kg_per_s(0.0);
let r = zero.regularized();
assert_relative_eq!(r.to_kg_per_s(), MIN_MASS_FLOW_REGULARIZATION_KG_S, epsilon = 1e-15);
let small = MassFlow::from_kg_per_s(1e-14);
let r2 = small.regularized();
assert_relative_eq!(r2.to_kg_per_s(), MIN_MASS_FLOW_REGULARIZATION_KG_S, epsilon = 1e-15);
let normal = MassFlow::from_kg_per_s(0.5);
let r3 = normal.regularized();
assert_relative_eq!(r3.to_kg_per_s(), 0.5, epsilon = 1e-10);
}
// ==================== TYPE SAFETY TESTS ====================
#[test]
@@ -748,4 +927,53 @@ mod tests {
let m = MassFlow::from_kg_per_s(1e-12);
assert_relative_eq!(m.to_kg_per_s(), 1e-12, epsilon = 1e-17);
}
// ==================== POWER TESTS ====================
#[test]
fn test_power_from_watts() {
let p = Power::from_watts(1000.0);
assert_relative_eq!(p.0, 1000.0, epsilon = 1e-10);
assert_relative_eq!(p.to_watts(), 1000.0, epsilon = 1e-10);
}
#[test]
fn test_power_from_kilowatts() {
let p = Power::from_kilowatts(1.0);
assert_relative_eq!(p.to_watts(), 1000.0, epsilon = 1e-6);
assert_relative_eq!(p.to_kilowatts(), 1.0, epsilon = 1e-6);
}
#[test]
fn test_power_from_megawatts() {
let p = Power::from_megawatts(1.0);
assert_relative_eq!(p.to_watts(), 1_000_000.0, epsilon = 1e-6);
assert_relative_eq!(p.to_megawatts(), 1.0, epsilon = 1e-6);
}
#[test]
fn test_power_display() {
let p = Power::from_watts(5000.0);
assert_eq!(format!("{}", p), "5000 W");
}
#[test]
fn test_power_arithmetic() {
let p1 = Power::from_watts(1000.0);
let p2 = Power::from_watts(500.0);
let p3 = p1 + p2;
assert_relative_eq!(p3.to_watts(), 1500.0, epsilon = 1e-10);
let p4 = p1 - p2;
assert_relative_eq!(p4.to_watts(), 500.0, epsilon = 1e-10);
let p5 = p1 * 2.0;
assert_relative_eq!(p5.to_watts(), 2000.0, epsilon = 1e-10);
let p6 = p1 / 2.0;
assert_relative_eq!(p6.to_watts(), 500.0, epsilon = 1e-10);
let p7 = 2.0 * p1;
assert_relative_eq!(p7.to_watts(), 2000.0, epsilon = 1e-10);
}
}

28
crates/fluids/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "entropyk-fluids"
version.workspace = true
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Fluid properties backend for Entropyk thermodynamic simulation library"
[dependencies]
entropyk-core = { path = "../core" }
thiserror.workspace = true
serde.workspace = true
serde_json = "1.0"
lru = "0.12"
entropyk-coolprop-sys = { path = "coolprop-sys", optional = true }
[features]
default = []
coolprop = ["entropyk-coolprop-sys"]
[dev-dependencies]
approx = "0.5"
criterion = "0.5"
[[bench]]
name = "cache_10k"
harness = false

View File

@@ -0,0 +1,54 @@
//! Benchmark: 10k repeated (P,T) queries — cached vs uncached (Story 2.4 AC#4).
//!
//! Compares throughput of CachedBackend vs raw backend for repeated same-state queries.
//! Cached path should show significant speedup when the backend is expensive (e.g. CoolProp).
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use entropyk_fluids::{
CachedBackend, FluidBackend, FluidId, Property, ThermoState, TestBackend,
};
use entropyk_core::{Pressure, Temperature};
const N_QUERIES: u32 = 10_000;
fn bench_uncached_10k(c: &mut Criterion) {
let backend = TestBackend::new();
let state = ThermoState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
);
let fluid = FluidId::new("R134a");
c.bench_function("uncached_10k_same_state", |b| {
b.iter(|| {
for _ in 0..N_QUERIES {
black_box(
backend.property(fluid.clone(), Property::Density, state.clone()).unwrap(),
);
}
});
});
}
fn bench_cached_10k(c: &mut Criterion) {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = ThermoState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
);
let fluid = FluidId::new("R134a");
c.bench_function("cached_10k_same_state", |b| {
b.iter(|| {
for _ in 0..N_QUERIES {
black_box(
cached.property(fluid.clone(), Property::Density, state.clone()).unwrap(),
);
}
});
});
}
criterion_group!(benches, bench_uncached_10k, bench_cached_10k);
criterion_main!(benches);

18
crates/fluids/build.rs Normal file
View File

@@ -0,0 +1,18 @@
//! Build script for entropyk-fluids crate.
//!
//! This build script can optionally compile CoolProp C++ library when the
//! "coolprop" feature is enabled.
use std::env;
fn main() {
let coolprop_enabled = env::var("CARGO_FEATURE_COOLPROP").is_ok();
if coolprop_enabled {
println!("cargo:rustc-link-lib=dylib=coolprop");
println!("cargo:rerun-if-changed=build.rs");
}
// Tell Cargo to rerun this script if any source files change
println!("cargo:rerun-if-changed=build.rs");
}

View File

@@ -0,0 +1,64 @@
//! Build script for coolprop-sys.
//!
//! This compiles the CoolProp C++ library statically.
use std::env;
use std::path::PathBuf;
fn coolprop_src_path() -> Option<PathBuf> {
// Try to find CoolProp source in common locations
let possible_paths = vec![
// Vendor directory (recommended)
PathBuf::from("vendor/coolprop"),
// External directory
PathBuf::from("external/coolprop"),
// System paths
PathBuf::from("/usr/local/src/CoolProp"),
PathBuf::from("/opt/CoolProp"),
];
for path in possible_paths {
if path.join("CMakeLists.txt").exists() {
return Some(path);
}
}
None
}
fn main() {
let static_linking = env::var("CARGO_FEATURE_STATIC").is_ok();
// Check if CoolProp source is available
if let Some(coolprop_path) = coolprop_src_path() {
println!("cargo:rerun-if-changed={}", coolprop_path.display());
// Configure build for CoolProp
println!(
"cargo:rustc-link-search=native={}/build",
coolprop_path.display()
);
}
// Link against CoolProp
if static_linking {
// Static linking - find libCoolProp.a
println!("cargo:rustc-link-lib=static=CoolProp");
} else {
// Dynamic linking
println!("cargo:rustc-link-lib=dylib=CoolProp");
}
// Link required system libraries
println!("cargo:rustc-link-lib=dylib=m");
println!("cargo:rustc-link-lib=dylib=stdc++");
// Tell Cargo to rerun if build.rs changes
println!("cargo:rerun-if-changed=build.rs");
println!(
"cargo:warning=CoolProp source not found in vendor/.
For full static build, run:
git clone https://github.com/CoolProp/CoolProp.git vendor/coolprop"
);
}

View File

@@ -0,0 +1,336 @@
//! FFI bindings to CoolProp C++ library.
//!
//! This module provides low-level FFI bindings to the CoolProp library.
//! All functions are unsafe and require proper error handling.
#![allow(dead_code)]
use libc::{c_char, c_double, c_int};
use std::ffi::CString;
/// Error codes returned by CoolProp
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum CoolPropError {
/// No error occurred
NoError = 0,
/// Input error code
InputError = 1,
/// Library not loaded
LibraryNotLoaded = 2,
/// Unknown property value
UnknownPropertyValue = 3,
/// Unknown fluid
UnknownFluid = 4,
/// Unknown parameter
UnknownParameter = 5,
/// Not implemented
NotImplemented = 6,
/// Invalid number of parameters
InvalidNumber = 7,
/// Could not load library
CouldNotLoadLibrary = 8,
/// Invalid fluid pair
InvalidFluidPair = 9,
/// Version mismatch
VersionMismatch = 10,
/// Internal error
InternalError = 11,
}
impl CoolPropError {
/// Convert CoolProp error code to Rust result
pub fn from_code(code: i32) -> Result<(), CoolPropError> {
match code {
0 => Ok(()),
_ => Err(match code {
1 => CoolPropError::InputError,
2 => CoolPropError::LibraryNotLoaded,
3 => CoolPropError::UnknownPropertyValue,
4 => CoolPropError::UnknownFluid,
5 => CoolPropError::UnknownParameter,
6 => CoolPropError::NotImplemented,
7 => CoolPropError::InvalidNumber,
8 => CoolPropError::CouldNotLoadLibrary,
9 => CoolPropError::InvalidFluidPair,
10 => CoolPropError::VersionMismatch,
_ => CoolPropError::InternalError,
}),
}
}
}
/// Output parameters for CoolProp
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum CoolPropParam {
/// Nothing
Nothing = 0,
/// Pressure [Pa]
Pressure = 1,
/// Temperature [K]
Temperature = 2,
/// Density [kg/m³]
Density = 3,
/// Specific enthalpy [J/kg]
Enthalpy = 4,
/// Specific entropy [J/kg/K]
Entropy = 5,
/// Specific internal energy [J/kg]
InternalEnergy = 6,
/// Specific heat at constant pressure [J/kg/K]
Cv = 7,
/// Specific heat at constant pressure [J/kg/K]
Cp = 8,
/// Quality [-]
Quality = 9,
/// Viscosity [Pa·s]
Viscosity = 10,
/// Thermal conductivity [W/m/K]
Conductivity = 11,
/// Surface tension [N/m]
SurfaceTension = 12,
/// Prandtl number [-]
Prandtl = 13,
}
/// Input parameters for CoolProp
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum CoolPropInputPair {
/// No input
None = 0,
/// Pressure & Temperature
PT = 1,
/// Pressure & Density
PD = 2,
/// Pressure & Enthalpy
PH = 3,
/// Pressure & Entropy
PS = 4,
/// Pressure & Internal Energy
PU = 5,
/// Temperature & Density
TD = 6,
/// Temperature & Enthalpy
TH = 7,
/// Temperature & Entropy
TS = 8,
/// Temperature & Internal Energy
TU = 9,
/// Enthalpy & Entropy
HS = 10,
/// Density & Internal Energy
DU = 11,
/// Pressure & Quality
PQ = 12,
/// Temperature & Quality
TQ = 13,
}
// CoolProp C functions
extern "C" {
/// Get a property value using pressure and temperature
fn CoolProp_PropsSI(
Output: c_char,
Name1: c_char,
Value1: c_double,
Name2: c_char,
Value2: c_double,
Fluid: *const c_char,
) -> c_double;
/// Get a property value using input pair
fn CoolProp_Props1SI(Fluid: *const c_char, Output: c_char) -> c_double;
/// Get CoolProp version string
fn CoolProp_get_global_param_string(
Param: *const c_char,
Output: *mut c_char,
OutputLength: c_int,
) -> c_int;
/// Get fluid info
fn CoolProp_get_fluid_param_string(
Fluid: *const c_char,
Param: *const c_char,
Output: *mut c_char,
OutputLength: c_int,
) -> c_int;
/// Check if fluid exists
fn CoolProp_isfluid(Fluid: *const c_char) -> c_int;
/// Get saturation temperature
fn CoolProp_Saturation_T(Fluid: *const c_char, Par: c_char, Value: c_double) -> c_double;
/// Get critical point
fn CoolProp_CriticalPoint(Fluid: *const c_char, Output: c_char) -> c_double;
}
/// Get a thermodynamic property using pressure and temperature.
///
/// # Arguments
/// * `property` - The property to retrieve (e.g., "D" for density, "H" for enthalpy)
/// * `p` - Pressure in Pa
/// * `t` - Temperature in K
/// * `fluid` - Fluid name (e.g., "R134a")
///
/// # Returns
/// The property value in SI units, or NaN if an error occurs
pub unsafe fn props_si_pt(property: &str, p: f64, t: f64, fluid: &str) -> f64 {
let prop = property.as_bytes()[0] as c_char;
let fluid_c = CString::new(fluid).unwrap();
CoolProp_PropsSI(prop, b'P' as c_char, p, b'T' as c_char, t, fluid_c.as_ptr())
}
/// Get a thermodynamic property using pressure and enthalpy.
///
/// # Arguments
/// * `property` - The property to retrieve
/// * `p` - Pressure in Pa
/// * `h` - Specific enthalpy in J/kg
/// * `fluid` - Fluid name
///
/// # Returns
/// The property value in SI units, or NaN if an error occurs
pub unsafe fn props_si_ph(property: &str, p: f64, h: f64, fluid: &str) -> f64 {
let prop = property.as_bytes()[0] as c_char;
let fluid_c = CString::new(fluid).unwrap();
CoolProp_PropsSI(prop, b'P' as c_char, p, b'H' as c_char, h, fluid_c.as_ptr())
}
/// Get a thermodynamic property using temperature and quality (saturation).
///
/// # Arguments
/// * `property` - The property to retrieve (D, H, S, P, etc.)
/// * `t` - Temperature in K
/// * `q` - Quality (0 = saturated liquid, 1 = saturated vapor)
/// * `fluid` - Fluid name
///
/// # Returns
/// The property value in SI units, or NaN if an error occurs
pub unsafe fn props_si_tq(property: &str, t: f64, q: f64, fluid: &str) -> f64 {
let prop = property.as_bytes()[0] as c_char;
let fluid_c = CString::new(fluid).unwrap();
CoolProp_PropsSI(prop, b'T' as c_char, t, b'Q' as c_char, q, fluid_c.as_ptr())
}
/// Get a thermodynamic property using pressure and quality.
///
/// # Arguments
/// * `property` - The property to retrieve
/// * `p` - Pressure in Pa
/// * `x` - Quality (0-1)
/// * `fluid` - Fluid name
///
/// # Returns
/// The property value in SI units, or NaN if an error occurs
pub unsafe fn props_si_px(property: &str, p: f64, x: f64, fluid: &str) -> f64 {
let prop = property.as_bytes()[0] as c_char;
let fluid_c = CString::new(fluid).unwrap();
CoolProp_PropsSI(
prop,
b'P' as c_char,
p,
b'Q' as c_char, // Q for quality
x,
fluid_c.as_ptr(),
)
}
/// Get critical point temperature for a fluid.
///
/// # Arguments
/// * `fluid` - Fluid name
///
/// # Returns
/// Critical temperature in K, or NaN if unavailable
pub unsafe fn critical_temperature(fluid: &str) -> f64 {
let fluid_c = CString::new(fluid).unwrap();
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'T' as c_char)
}
/// Get critical point pressure for a fluid.
///
/// # Arguments
/// * `fluid` - Fluid name
///
/// # Returns
/// Critical pressure in Pa, or NaN if unavailable
pub unsafe fn critical_pressure(fluid: &str) -> f64 {
let fluid_c = CString::new(fluid).unwrap();
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'P' as c_char)
}
/// Get critical point density for a fluid.
///
/// # Arguments
/// * `fluid` - Fluid name
///
/// # Returns
/// Critical density in kg/m³, or NaN if unavailable
pub unsafe fn critical_density(fluid: &str) -> f64 {
let fluid_c = CString::new(fluid).unwrap();
CoolProp_CriticalPoint(fluid_c.as_ptr(), b'D' as c_char)
}
/// Check if a fluid is available in CoolProp.
///
/// # Arguments
/// * `fluid` - Fluid name
///
/// # Returns
/// `true` if the fluid is available
pub unsafe fn is_fluid_available(fluid: &str) -> bool {
let fluid_c = CString::new(fluid).unwrap();
CoolProp_isfluid(fluid_c.as_ptr()) != 0
}
/// Get CoolProp version string.
///
/// # Returns
/// Version string (e.g., "6.14.0")
pub fn get_version() -> String {
unsafe {
let mut buffer = vec![0u8; 32];
let result = CoolProp_get_global_param_string(
b"version\0".as_ptr() as *const c_char,
buffer.as_mut_ptr() as *mut c_char,
buffer.len() as c_int,
);
if result == 0 {
String::from_utf8_lossy(&buffer)
.trim_end_matches('\0')
.to_string()
} else {
String::from("Unknown")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version() {
let version = get_version();
assert!(!version.is_empty());
}
#[test]
fn test_fluid_available() {
// Test some common refrigerants
unsafe {
assert!(is_fluid_available("R134a"));
assert!(is_fluid_available("R410A"));
assert!(is_fluid_available("CO2"));
}
}
}

View File

@@ -0,0 +1,63 @@
{
"fluid": "R134a",
"critical_point": {
"tc": 374.21,
"pc": 4059000,
"rho_c": 512
},
"single_phase": {
"pressure": [100000, 200000, 500000, 1000000, 2000000, 3000000],
"temperature": [250, 270, 290, 298.15, 320, 350],
"density": [
5.2, 4.9, 4.5, 4.4, 4.0, 3.6,
12.0, 10.5, 9.0, 8.5, 7.5, 6.5,
35.0, 28.0, 22.0, 20.0, 16.0, 12.0,
75.0, 55.0, 40.0, 35.0, 25.0, 18.0,
150.0, 100.0, 65.0, 55.0, 38.0, 25.0,
220.0, 140.0, 85.0, 70.0, 48.0, 30.0
],
"enthalpy": [
380000, 395000, 410000, 415000, 430000, 450000,
370000, 388000, 405000, 412000, 428000, 448000,
355000, 378000, 398000, 406000, 424000, 445000,
340000, 365000, 390000, 400000, 420000, 442000,
320000, 350000, 378000, 392000, 415000, 438000,
300000, 335000, 368000, 384000, 410000, 435000
],
"entropy": [
1750, 1780, 1810, 1820, 1850, 1890,
1720, 1760, 1795, 1805, 1840, 1880,
1680, 1730, 1775, 1788, 1825, 1870,
1630, 1695, 1750, 1765, 1810, 1860,
1570, 1650, 1715, 1735, 1790, 1845,
1510, 1605, 1685, 1710, 1770, 1830
],
"cp": [
900, 920, 950, 960, 1000, 1050,
880, 910, 940, 950, 990, 1040,
850, 890, 925, 940, 980, 1030,
820, 870, 910, 928, 970, 1020,
790, 850, 900, 920, 965, 1015,
765, 835, 890, 915, 962, 1010
],
"cv": [
750, 770, 800, 810, 850, 900,
730, 760, 790, 800, 840, 890,
700, 740, 775, 790, 830, 880,
670, 720, 760, 778, 820, 870,
640, 700, 745, 765, 812, 862,
615, 680, 730, 752, 805, 855
]
},
"saturation": {
"temperature": [250, 260, 270, 280, 290, 298.15, 310, 320, 330, 340, 350],
"pressure": [164000, 232000, 320000, 430000, 565000, 666000, 890000, 1165000, 1500000, 1900000, 2370000],
"h_liq": [200000, 215000, 230000, 245000, 260000, 272000, 288000, 305000, 322000, 340000, 358000],
"h_vap": [395000, 402000, 408000, 413000, 417000, 420000, 423000, 425000, 426000, 427000, 427500],
"rho_liq": [1350, 1320, 1290, 1255, 1218, 1188, 1145, 1098, 1045, 985, 915],
"rho_vap": [8.2, 11.2, 15.0, 19.8, 25.8, 30.5, 39.5, 50.5, 64.0, 80.5, 101.0],
"s_liq": [950, 1000, 1050, 1095, 1140, 1175, 1225, 1275, 1325, 1375, 1425],
"s_vap": [1720, 1710, 1700, 1690, 1680, 1675, 1668, 1660, 1652, 1643, 1633
]
}
}

View File

@@ -0,0 +1,166 @@
//! Fluid backend trait and implementations.
//!
//! This module defines the core `FluidBackend` trait that abstracts the source
//! of thermodynamic property data, allowing the solver to switch between different
//! backends (CoolProp, tabular data, mock for testing).
use crate::errors::FluidResult;
use crate::mixture::Mixture;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState, ThermoState};
use entropyk_core::{Pressure, Temperature};
/// Trait for fluid property backends.
///
/// Implementors must provide methods to query thermodynamic properties
/// for various fluids. This allows the solver to work with different
/// property sources (CoolProp, tabular data, mock data for testing).
///
/// # Example
///
/// ```
/// use entropyk_fluids::{FluidBackend, FluidId, Property, FluidState, ThermoState, FluidError, FluidResult, CriticalPoint};
///
/// struct MyBackend;
/// impl FluidBackend for MyBackend {
/// fn property(&self, _fluid: FluidId, _property: Property, _state: FluidState) -> FluidResult<f64> {
/// Ok(1.0)
/// }
/// fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
/// Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
/// }
/// fn is_fluid_available(&self, _fluid: &FluidId) -> bool { false }
/// fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<entropyk_fluids::Phase> {
/// Ok(entropyk_fluids::Phase::Unknown)
/// }
/// fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<ThermoState> {
/// Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
/// }
/// fn list_fluids(&self) -> Vec<FluidId> { vec![] }
/// }
/// ```
pub trait FluidBackend: Send + Sync {
/// Query a thermodynamic property for a fluid at a given state.
///
/// # Arguments
/// * `fluid` - The fluid identifier (e.g., "R134a", "CO2")
/// * `property` - The property to query
/// * `state` - The thermodynamic state specification
///
/// # Returns
/// The property value in SI units, or an error if the property
/// cannot be computed (unknown fluid, invalid state, etc.)
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64>;
/// Compute the complete thermodynamic state of a fluid at a given pressure and enthalpy.
///
/// This method is intended to be implemented by backends capable of natively calculating
/// all key parameters (phase, saturation temperatures, qualities, limits) without the user
/// needing to query them individually.
///
/// # Arguments
/// * `fluid` - The fluid identifier
/// * `p` - The absolute pressure
/// * `h` - The specific enthalpy
///
/// # Returns
/// The comprehensive `ThermoState` Snapshot, or an Error.
fn full_state(&self, fluid: FluidId, p: Pressure, h: entropyk_core::Enthalpy) -> FluidResult<ThermoState>;
/// Get critical point data for a fluid.
///
/// # Arguments
/// * `fluid` - The fluid identifier
///
/// # Returns
/// The critical point (Tc, Pc, density), or an error if not available
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint>;
/// Check if a fluid is available in this backend.
///
/// # Arguments
/// * `fluid` - The fluid identifier
///
/// # Returns
/// `true` if the fluid is available, `false` otherwise
fn is_fluid_available(&self, fluid: &FluidId) -> bool;
/// Get the phase of a fluid at a given state.
///
/// # Arguments
/// * `fluid` - The fluid identifier
/// * `state` - The thermodynamic state
///
/// # Returns
/// The phase (Liquid, Vapor, TwoPhase, etc.)
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase>;
/// List all available fluids in this backend.
fn list_fluids(&self) -> Vec<FluidId>;
/// Calculate the bubble point temperature for a mixture at given pressure.
///
/// The bubble point is the temperature at which a liquid mixture begins to boil
/// (saturated liquid temperature).
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The bubble point temperature in Kelvin
fn bubble_point(&self, _pressure: Pressure, _mixture: &Mixture) -> FluidResult<Temperature> {
Err(crate::errors::FluidError::UnsupportedProperty {
property: "Bubble point calculation not supported by this backend".to_string(),
})
}
/// Calculate the dew point temperature for a mixture at given pressure.
///
/// The dew point is the temperature at which a vapor mixture begins to condense
/// (saturated vapor temperature).
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The dew point temperature in Kelvin
fn dew_point(&self, _pressure: Pressure, _mixture: &Mixture) -> FluidResult<Temperature> {
Err(crate::errors::FluidError::UnsupportedProperty {
property: "Dew point calculation not supported by this backend".to_string(),
})
}
/// Calculate the temperature glide for a mixture at given pressure.
///
/// Temperature glide is the difference between dew point and bubble point
/// temperatures: T_glide = T_dew - T_bubble.
/// This is non-zero for zeotropic mixtures and zero for azeotropes/pure fluids.
///
/// # Arguments
/// * `pressure` - The pressure in Pa
/// * `mixture` - The mixture composition
///
/// # Returns
/// The temperature glide in Kelvin
fn temperature_glide(&self, pressure: Pressure, mixture: &Mixture) -> FluidResult<f64> {
let t_bubble = self.bubble_point(pressure, mixture)?;
let t_dew = self.dew_point(pressure, mixture)?;
Ok(t_dew.to_kelvin() - t_bubble.to_kelvin())
}
/// Check if a mixture is supported by this backend.
///
/// # Arguments
/// * `mixture` - The mixture to check
///
/// # Returns
/// `true` if the mixture is supported, `false` otherwise
fn is_mixture_supported(&self, mixture: &Mixture) -> bool {
// Default implementation: check if all components are available
mixture
.components()
.iter()
.all(|c| self.is_fluid_available(&FluidId::new(c)))
}
}

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);
}
}

View File

@@ -0,0 +1,174 @@
//! Cached backend wrapper for fluid property queries.
//!
//! Wraps any `FluidBackend` with a thread-local LRU cache to avoid
//! redundant calculations. No mutex contention; zero allocation on cache hit.
use crate::backend::FluidBackend;
use crate::cache::{cache_clear, cache_get, cache_insert};
use crate::errors::FluidResult;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_BACKEND_ID: AtomicUsize = AtomicUsize::new(0);
/// Backend wrapper that caches property queries in a thread-local LRU cache.
///
/// Wraps any `FluidBackend` and caches successful property() results.
/// Other trait methods (critical_point, phase, etc.) delegate to the inner backend
/// without caching, as they are typically called less frequently.
///
/// # Example
///
/// ```
/// use entropyk_fluids::{CachedBackend, FluidBackend, FluidId, Property, FluidState, TestBackend};
/// use entropyk_core::{Pressure, Temperature};
///
/// let inner = TestBackend::new();
/// let cached = CachedBackend::new(inner);
///
/// let state = FluidState::from_pt(
/// Pressure::from_bar(1.0),
/// Temperature::from_celsius(25.0),
/// );
///
/// let v1 = cached.property(FluidId::new("R134a"), Property::Density, state.clone()).unwrap();
/// let v2 = cached.property(FluidId::new("R134a"), Property::Density, state).unwrap();
/// assert_eq!(v1, v2); // Second call served from cache
/// ```
pub struct CachedBackend<B: FluidBackend> {
backend_id: usize,
inner: B,
}
impl<B: FluidBackend> CachedBackend<B> {
/// Create a new cached backend wrapping the given backend.
pub fn new(inner: B) -> Self {
let backend_id = NEXT_BACKEND_ID.fetch_add(1, Ordering::Relaxed);
CachedBackend { backend_id, inner }
}
/// Clear the thread-local cache. Call at solver iteration boundaries if needed.
///
/// **Note:** This clears the cache for *all* `CachedBackend` instances on the current
/// thread, since they share one thread-local cache. If you need per-backend invalidation,
/// use separate threads or a different caching strategy.
pub fn clear_cache(&self) {
cache_clear();
}
/// Get a reference to the inner backend.
pub fn inner(&self) -> &B {
&self.inner
}
}
impl<B: FluidBackend> FluidBackend for CachedBackend<B> {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
if let Some(v) = cache_get(self.backend_id, &fluid, property, &state) {
return Ok(v);
}
let v = self.inner.property(fluid.clone(), property, state.clone())?;
cache_insert(self.backend_id, &fluid, property, &state, v);
Ok(v)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
self.inner.critical_point(fluid)
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.inner.is_fluid_available(fluid)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
self.inner.phase(fluid, state)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.inner.list_fluids()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
self.inner.full_state(fluid, p, h)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_backend::TestBackend;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_cache_hit_returns_same_value() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let v1 = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
let v2 = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_eq!(v1, v2);
}
#[test]
fn test_cache_miss_delegates_to_backend() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let v = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(v > 0.0);
}
#[test]
fn test_cache_invalidation() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let _ = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
cached.clear_cache();
// After clear, next query should still work (delegates to backend)
let v = cached
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(v > 0.0);
}
#[test]
fn test_cached_benchmark_10k_queries() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
for _ in 0..10_000 {
let _ = cached
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
}
}
#[test]
fn test_cached_backend_implements_fluid_backend() {
let inner = TestBackend::new();
let cached = CachedBackend::new(inner);
assert!(cached.is_fluid_available(&FluidId::new("R134a")));
let cp = cached.critical_point(FluidId::new("R134a")).unwrap();
assert!(cp.temperature_kelvin() > 300.0);
let fluids = cached.list_fluids();
assert!(!fluids.is_empty());
}
}

View File

@@ -0,0 +1,647 @@
//! CoolProp backend implementation.
//!
//! This module provides the `CoolPropBackend` struct that implements the `FluidBackend` trait
//! using the CoolProp C++ library for thermodynamic property calculations.
#[cfg(feature = "coolprop")]
use crate::damped_backend::DampedBackend;
use crate::errors::{FluidError, FluidResult};
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
#[cfg(feature = "coolprop")]
use crate::mixture::Mixture;
#[cfg(feature = "coolprop")]
use std::collections::HashMap;
#[cfg(feature = "coolprop")]
use std::sync::RwLock;
#[cfg(feature = "coolprop")]
use entropyk_coolprop_sys as coolprop;
/// A fluid property backend using the CoolProp C++ library.
///
/// This backend provides high-accuracy thermodynamic properties using the
/// CoolProp library, which implements the NIST REFPROP equations of state.
#[cfg(feature = "coolprop")]
pub struct CoolPropBackend {
/// Cache for critical point data
critical_cache: RwLock<HashMap<String, CriticalPoint>>,
/// List of available fluids
available_fluids: Vec<FluidId>,
}
#[cfg(feature = "coolprop")]
impl CoolPropBackend {
/// Creates a new CoolPropBackend.
pub fn new() -> Self {
let backend = CoolPropBackend {
critical_cache: RwLock::new(HashMap::new()),
available_fluids: vec![
FluidId::new("R134a"),
FluidId::new("R410A"),
FluidId::new("R404A"),
FluidId::new("R407C"),
FluidId::new("R32"),
FluidId::new("R125"),
FluidId::new("R744"),
FluidId::new("R290"),
FluidId::new("R600"),
FluidId::new("R600a"),
FluidId::new("Water"),
FluidId::new("Air"),
],
};
backend
}
/// Creates a new CoolPropBackend with critical point damping enabled.
///
/// This wraps the backend with a `DampedBackend` to apply C1-continuous
/// damping to derivative properties (Cp, Cv, etc.) near the critical point,
/// preventing NaN values in Newton-Raphson iterations.
pub fn with_damping() -> DampedBackend<CoolPropBackend> {
DampedBackend::new(Self::new())
}
/// Get the CoolProp internal name for a fluid.
fn fluid_name(&self, fluid: &FluidId) -> String {
// Map common names to CoolProp internal names
match fluid.0.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(),
}
}
/// Convert Property to CoolProp character code.
fn property_code(property: Property) -> &'static str {
match property {
Property::Density => "D",
Property::Enthalpy => "H",
Property::Entropy => "S",
Property::InternalEnergy => "U",
Property::Cp => "C",
Property::Cv => "O", // Cv in CoolProp
Property::SpeedOfSound => "A",
Property::Viscosity => "V",
Property::ThermalConductivity => "L",
Property::SurfaceTension => "I",
Property::Quality => "Q",
Property::Temperature => "T",
Property::Pressure => "P",
}
}
}
#[cfg(feature = "coolprop")]
impl Default for CoolPropBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "coolprop")]
impl crate::backend::FluidBackend for CoolPropBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
// Handle mixture states
if state.is_mixture() {
return self.property_mixture(fluid, property, state);
}
let coolprop_fluid = self.fluid_name(&fluid);
let prop_code = Self::property_code(property);
// Check if fluid is available
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
// Query property based on state input type
let result = match state {
FluidState::PressureTemperature(p, t) => unsafe {
coolprop::props_si_pt(prop_code, p.to_pascals(), t.to_kelvin(), &coolprop_fluid)
},
FluidState::PressureEnthalpy(p, h) => unsafe {
coolprop::props_si_ph(
prop_code,
p.to_pascals(),
h.to_joules_per_kg(),
&coolprop_fluid,
)
},
FluidState::PressureEntropy(_p, _s) => {
// CoolProp doesn't have direct PS, use iterative approach or PH
return Err(FluidError::UnsupportedProperty {
property: format!("P-S not directly supported, use P-T or P-h"),
});
}
FluidState::PressureQuality(p, q) => unsafe {
coolprop::props_si_px(prop_code, p.to_pascals(), q.value(), &coolprop_fluid)
},
// Mixture variants handled above
FluidState::PressureTemperatureMixture(_, _, _) => unreachable!(),
FluidState::PressureEnthalpyMixture(_, _, _) => unreachable!(),
FluidState::PressureQualityMixture(_, _, _) => unreachable!(),
};
// Check for NaN (indicates error in CoolProp)
if result.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for {} at {:?}", fluid, state),
});
}
Ok(result)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
// Check cache first
if let Some(cp) = self.critical_cache.read().unwrap().get(&fluid.0) {
return Ok(*cp);
}
let coolprop_fluid = self.fluid_name(&fluid);
unsafe {
let tc = coolprop::critical_temperature(&coolprop_fluid);
let pc = coolprop::critical_pressure(&coolprop_fluid);
let dc = coolprop::critical_density(&coolprop_fluid);
if tc.is_nan() || pc.is_nan() || dc.is_nan() {
return Err(FluidError::NoCriticalPoint { fluid: fluid.0 });
}
let cp = CriticalPoint::new(
entropyk_core::Temperature::from_kelvin(tc),
entropyk_core::Pressure::from_pascals(pc),
dc,
);
// Cache the result
self.critical_cache.write().unwrap().insert(fluid.0, cp);
Ok(cp)
}
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
let coolprop_fluid = self.fluid_name(fluid);
unsafe { coolprop::is_fluid_available(&coolprop_fluid) }
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
// Handle mixture states
if state.is_mixture() {
return self.phase_mix(fluid, state);
}
let quality = self.property(fluid.clone(), Property::Quality, state)?;
if quality < 0.0 {
// Below saturated liquid - likely subcooled liquid
Ok(Phase::Liquid)
} else if quality > 1.0 {
// Above saturated vapor - superheated
Ok(Phase::Vapor)
} else if (quality - 0.0).abs() < 1e-6 {
// Saturated liquid
Ok(Phase::Liquid)
} else if (quality - 1.0).abs() < 1e-6 {
// Saturated vapor
Ok(Phase::Vapor)
} else {
// Two-phase region
Ok(Phase::TwoPhase)
}
}
fn list_fluids(&self) -> Vec<FluidId> {
self.available_fluids.clone()
}
fn bubble_point(
&self,
pressure: entropyk_core::Pressure,
mixture: &Mixture,
) -> FluidResult<entropyk_core::Temperature> {
if !self.is_mixture_supported(mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let p_pa = pressure.to_pascals();
unsafe {
// For bubble point (saturated liquid), use Q=0
let t = coolprop::props_si_tq("T", p_pa, 0.0, &cp_string);
if t.is_nan() {
return Err(FluidError::NumericalError(
"CoolProp returned NaN for bubble point calculation".to_string(),
));
}
Ok(entropyk_core::Temperature::from_kelvin(t))
}
}
fn dew_point(
&self,
pressure: entropyk_core::Pressure,
mixture: &Mixture,
) -> FluidResult<entropyk_core::Temperature> {
if !self.is_mixture_supported(mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let p_pa = pressure.to_pascals();
unsafe {
// For dew point (saturated vapor), use Q=1
let t = coolprop::props_si_tq("T", p_pa, 1.0, &cp_string);
if t.is_nan() {
return Err(FluidError::NumericalError(
"CoolProp returned NaN for dew point calculation".to_string(),
));
}
Ok(entropyk_core::Temperature::from_kelvin(t))
}
}
fn is_mixture_supported(&self, mixture: &Mixture) -> bool {
mixture
.components()
.iter()
.all(|c| self.is_fluid_available(&FluidId::new(c)))
}
/// Property calculation for mixtures.
fn property_mixture(
&self,
fluid: FluidId,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
// Extract mixture from state
let mixture = match state {
FluidState::PressureTemperatureMixture(_, _, m) => m,
FluidState::PressureEnthalpyMixture(_, _, m) => m,
FluidState::PressureQualityMixture(_, _, m) => m,
_ => unreachable!(),
};
if !self.is_mixture_supported(&mixture) {
return Err(FluidError::MixtureNotSupported(format!(
"One or more components not available: {:?}",
mixture.components()
)));
}
let cp_string = mixture.to_coolprop_string();
let prop_code = Self::property_code(property);
let result = match state {
FluidState::PressureTemperatureMixture(p, t, _) => unsafe {
coolprop::props_si_pt(prop_code, p.to_pascals(), t.to_kelvin(), &cp_string)
},
FluidState::PressureEnthalpyMixture(p, h, _) => unsafe {
coolprop::props_si_ph(prop_code, p.to_pascals(), h.to_joules_per_kg(), &cp_string)
},
FluidState::PressureQualityMixture(p, q, _) => unsafe {
coolprop::props_si_px(prop_code, p.to_pascals(), q.value(), &cp_string)
},
_ => unreachable!(),
};
if result.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for mixture at {:?}", state),
});
}
Ok(result)
}
/// Phase calculation for mixtures.
fn phase_mix(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
let quality = self.property_mixture(fluid, Property::Quality, state)?;
if quality < 0.0 {
Ok(Phase::Liquid)
} else if quality > 1.0 {
Ok(Phase::Vapor)
} else if (quality - 0.0).abs() < 1e-6 {
Ok(Phase::Liquid)
} else if (quality - 1.0).abs() < 1e-6 {
Ok(Phase::Vapor)
} else {
Ok(Phase::TwoPhase)
}
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let coolprop_fluid = self.fluid_name(&fluid);
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
let p_pa = p.to_pascals();
let h_j_kg = h.to_joules_per_kg();
unsafe {
let t_k = coolprop::props_si_ph("T", p_pa, h_j_kg, &coolprop_fluid);
if t_k.is_nan() {
return Err(FluidError::InvalidState {
reason: format!("CoolProp returned NaN for Temperature at P={}, h={} for {}", p_pa, h_j_kg, fluid),
});
}
let s_j_kg_k = coolprop::props_si_ph("S", p_pa, h_j_kg, &coolprop_fluid);
let d_kg_m3 = coolprop::props_si_ph("D", p_pa, h_j_kg, &coolprop_fluid);
let q = coolprop::props_si_ph("Q", p_pa, h_j_kg, &coolprop_fluid);
let phase = self.phase(fluid.clone(), FluidState::from_ph(p, h))?;
let quality = if (0.0..=1.0).contains(&q) {
Some(crate::types::Quality::new(q))
} else {
None
};
let t_bubble = coolprop::props_si_pq("T", p_pa, 0.0, &coolprop_fluid);
let t_dew = coolprop::props_si_pq("T", p_pa, 1.0, &coolprop_fluid);
let (t_bubble_opt, subcooling) = if !t_bubble.is_nan() {
(
Some(entropyk_core::Temperature::from_kelvin(t_bubble)),
if t_k < t_bubble {
Some(crate::types::TemperatureDelta::new(t_bubble - t_k))
} else {
None
}
)
} else {
(None, None)
};
let (t_dew_opt, superheat) = if !t_dew.is_nan() {
(
Some(entropyk_core::Temperature::from_kelvin(t_dew)),
if t_k > t_dew {
Some(crate::types::TemperatureDelta::new(t_k - t_dew))
} else {
None
}
)
} else {
(None, None)
};
Ok(crate::types::ThermoState {
fluid,
pressure: p,
temperature: entropyk_core::Temperature::from_kelvin(t_k),
enthalpy: h,
entropy: crate::types::Entropy::from_joules_per_kg_kelvin(s_j_kg_k),
density: d_kg_m3,
phase,
quality,
superheat,
subcooling,
t_bubble: t_bubble_opt,
t_dew: t_dew_opt,
})
}
}
}
/// A placeholder backend when CoolProp is not available.
///
/// This allows the crate to compile without CoolProp, but property
/// queries will return errors.
#[cfg(not(feature = "coolprop"))]
pub struct CoolPropBackend;
#[cfg(not(feature = "coolprop"))]
impl CoolPropBackend {
/// Creates a new CoolPropBackend (placeholder).
pub fn new() -> Self {
CoolPropBackend
}
}
#[cfg(not(feature = "coolprop"))]
impl Default for CoolPropBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(feature = "coolprop"))]
impl crate::backend::FluidBackend for CoolPropBackend {
fn property(
&self,
_fluid: FluidId,
_property: Property,
_state: FluidState,
) -> FluidResult<f64> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
false
}
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
fn list_fluids(&self) -> Vec<FluidId> {
Vec::new()
}
fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
Err(FluidError::CoolPropError(
"CoolProp not available. Enable 'coolprop' feature to use this backend.".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::FluidBackend;
#[cfg(feature = "coolprop")]
use crate::mixture::Mixture;
#[cfg(feature = "coolprop")]
use entropyk_core::{Pressure, Temperature};
#[test]
#[cfg(feature = "coolprop")]
fn test_backend_creation() {
let backend = CoolPropBackend::new();
let fluids = backend.list_fluids();
assert!(!fluids.is_empty());
}
#[test]
#[cfg(not(feature = "coolprop"))]
fn test_backend_without_feature() {
use crate::types::FluidState;
use entropyk_core::{Pressure, Temperature};
let backend = CoolPropBackend::new();
let result = backend.property(
FluidId::new("R134a"),
Property::Density,
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0)),
);
assert!(result.is_err());
}
#[test]
fn test_fluid_name_mapping() {
#[cfg(feature = "coolprop")]
{
let backend = CoolPropBackend::new();
assert_eq!(backend.fluid_name(&FluidId::new("R134a")), "R134a");
assert_eq!(backend.fluid_name(&FluidId::new("CO2")), "CO2");
assert_eq!(backend.fluid_name(&FluidId::new("R744")), "CO2");
}
}
#[test]
#[cfg(feature = "coolprop")]
fn test_mixture_is_supported() {
let backend = CoolPropBackend::new();
// R454B = R32 + R1234yf (both available in CoolProp)
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
assert!(backend.is_mixture_supported(&mixture));
// Unknown component should fail
let bad_mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R999", 0.5)]).unwrap();
assert!(!backend.is_mixture_supported(&bad_mixture));
}
#[test]
#[cfg(feature = "coolprop")]
fn test_bubble_point_r454b() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
// At 1 MPa (~10 bar), bubble point should be around 273K (0°C) for R454B
let pressure = Pressure::from_pascals(1e6);
let t_bubble = backend.bubble_point(pressure, &mixture).unwrap();
// Should be in reasonable range (250K - 300K)
assert!(t_bubble.to_kelvin() > 250.0 && t_bubble.to_kelvin() < 300.0);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_dew_point_r454b() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let pressure = Pressure::from_pascals(1e6);
let t_dew = backend.dew_point(pressure, &mixture).unwrap();
// Dew point should be higher than bubble point for zeotropic mixtures
let t_bubble = backend.bubble_point(pressure, &mixture).unwrap();
assert!(t_dew.to_kelvin() > t_bubble.to_kelvin());
}
#[test]
#[cfg(feature = "coolprop")]
fn test_temperature_glide_nonzero() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let pressure = Pressure::from_pascals(1e6);
let glide = backend.temperature_glide(pressure, &mixture).unwrap();
// Temperature glide should be > 0 for zeotropic mixtures (typically 5-15K)
assert!(
glide > 0.0,
"Expected positive temperature glide for zeotropic mixture"
);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_mixture_property_lookup() {
let backend = CoolPropBackend::new();
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
// Test (P, T) mixture state
let state = FluidState::from_pt_mix(
Pressure::from_bar(10.0),
Temperature::from_celsius(50.0),
mixture,
);
let density = backend
.property(FluidId::new("R454B"), Property::Density, state)
.unwrap();
assert!(density > 0.0);
}
#[test]
#[cfg(feature = "coolprop")]
fn test_full_state_extraction() {
let backend = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
let pressure = Pressure::from_bar(1.0);
let enthalpy = entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0); // Superheated vapor region
let state = backend.full_state(fluid.clone(), pressure, enthalpy).unwrap();
assert_eq!(state.fluid, fluid);
assert_eq!(state.pressure, pressure);
assert_eq!(state.enthalpy, enthalpy);
// Temperature should be valid
assert!(state.temperature.to_celsius() > -30.0);
assert!(state.density > 0.0);
assert!(state.entropy.to_joules_per_kg_kelvin() > 0.0);
// In superheated region, phase is Vapor, quality should be None, and superheat should exist
assert_eq!(state.phase, Phase::Vapor);
assert_eq!(state.quality, None);
assert!(state.superheat.is_some());
assert!(state.superheat.unwrap().kelvin() > 0.0);
assert!(state.subcooling.is_none());
assert!(state.t_dew.is_some());
assert!(state.t_bubble.is_some());
}
}

View File

@@ -0,0 +1,341 @@
//! Damped backend wrapper for fluid property queries.
//!
//! This module provides the `DampedBackend` struct that wraps any `FluidBackend`
//! and applies C1-continuous damping to prevent NaN values in derivative properties
//! near the critical point.
use crate::backend::FluidBackend;
use crate::damping::{calculate_damping_state, damp_property, should_damp_property, DampingParams};
use crate::errors::FluidResult;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
/// Backend wrapper that applies critical point damping to property queries.
///
/// Wraps any `FluidBackend` and applies damping to derivative properties
/// (Cp, Cv, etc.) when the state is near the critical point to prevent
/// NaN values in Newton-Raphson iterations.
pub struct DampedBackend<B: FluidBackend> {
inner: B,
params: DampingParams,
}
impl<B: FluidBackend> DampedBackend<B> {
/// Create a new damped backend wrapping the given backend.
pub fn new(inner: B) -> Self {
DampedBackend {
inner,
params: DampingParams::default(),
}
}
/// Create a new damped backend with custom parameters.
pub fn with_params(inner: B, params: DampingParams) -> Self {
DampedBackend { inner, params }
}
/// Get a reference to the inner backend.
pub fn inner(&self) -> &B {
&self.inner
}
/// Get a mutable reference to the inner backend.
pub fn inner_mut(&mut self) -> &mut B {
&mut self.inner
}
/// Get the damping parameters.
pub fn params(&self) -> &DampingParams {
&self.params
}
/// Get critical point for a fluid.
fn critical_point_internal(&self, fluid: &FluidId) -> Option<CriticalPoint> {
self.inner.critical_point(fluid.clone()).ok()
}
/// Apply damping to a property value if needed.
fn apply_damping(
&self,
fluid: &FluidId,
property: Property,
state: &FluidState,
value: f64,
) -> FluidResult<f64> {
// Only damp derivative properties
if !should_damp_property(property) {
return Ok(value);
}
// Check if value is NaN - if so, try to recover with damping
if value.is_nan() {
// Try to get critical point
if let Some(cp) = self.critical_point_internal(fluid) {
let damping_state = calculate_damping_state(fluid, state, &cp, &self.params);
if damping_state.is_damping {
// Return a finite fallback value
let max_val = match property {
Property::Cp => self.params.cp_max,
Property::Cv => self.params.cv_max,
Property::Density => 1e5,
Property::SpeedOfSound => 1e4,
_ => self.params.derivative_max,
};
return Ok(max_val * damping_state.blend_factor);
}
}
// No critical point info - return error
return Ok(self.params.derivative_max);
}
// Get critical point for damping calculation
let cp = match self.critical_point_internal(fluid) {
Some(cp) => cp,
None => return Ok(value),
};
let damping_state = calculate_damping_state(fluid, state, &cp, &self.params);
if !damping_state.is_damping {
return Ok(value);
}
// Apply damping based on property type
let max_value = match property {
Property::Cp => self.params.cp_max,
Property::Cv => self.params.cv_max,
Property::Density => 1e5,
Property::SpeedOfSound => 1e4,
_ => self.params.derivative_max,
};
let damped = damp_property(value, max_value, damping_state.blend_factor);
Ok(damped)
}
}
impl<B: FluidBackend> FluidBackend for DampedBackend<B> {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let value = self
.inner
.property(fluid.clone(), property, state.clone())?;
self.apply_damping(&fluid, property, &state, value)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
self.inner.critical_point(fluid)
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.inner.is_fluid_available(fluid)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
self.inner.phase(fluid, state)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.inner.list_fluids()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
self.inner.full_state(fluid, p, h)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::FluidBackend;
use crate::errors::{FluidError, FluidResult};
use crate::test_backend::TestBackend;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_damped_backend_creation() {
let inner = TestBackend::new();
let damped = DampedBackend::new(inner);
assert!(damped.is_fluid_available(&FluidId::new("R134a")));
}
#[test]
fn test_damped_backend_delegates_non_derivative() {
let inner = TestBackend::new();
let damped = DampedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
// Enthalpy should be delegated without damping
let h = damped
.property(FluidId::new("R134a"), Property::Enthalpy, state.clone())
.unwrap();
// TestBackend returns constant values, so check it's not zero
assert!(h > 0.0);
}
#[test]
fn test_damped_backend_with_custom_params() {
let inner = TestBackend::new();
let params = DampingParams {
reduced_temp_threshold: 0.1,
reduced_pressure_threshold: 0.1,
smoothness: 0.02,
cp_max: 5000.0,
cv_max: 3000.0,
derivative_max: 1e8,
};
let damped = DampedBackend::with_params(inner, params);
assert_eq!(damped.params().cp_max, 5000.0);
}
#[test]
fn test_damped_backend_returns_finite_values() {
let inner = TestBackend::new();
let damped = DampedBackend::new(inner);
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
// Cp should return a finite value (not NaN)
let cp = damped
.property(FluidId::new("R134a"), Property::Cp, state.clone())
.unwrap();
assert!(!cp.is_nan(), "Cp should not be NaN");
assert!(cp.is_finite(), "Cp should be finite");
}
#[test]
fn test_damped_backend_handles_nan_input() {
// Create a backend that returns NaN
struct NaNBackend;
impl FluidBackend for NaNBackend {
fn property(
&self,
_fluid: FluidId,
property: Property,
_state: FluidState,
) -> FluidResult<f64> {
if matches!(property, Property::Cp) {
Ok(f64::NAN)
} else {
Ok(1000.0)
}
}
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
Ok(CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.6,
))
}
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
true
}
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
Ok(Phase::Unknown)
}
fn list_fluids(&self) -> Vec<FluidId> {
vec![FluidId::new("CO2")]
}
fn full_state(&self, _fluid: FluidId, _p: entropyk_core::Pressure, _h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
Err(FluidError::CoolPropError(
"full_state not supported on NaNBackend".to_string(),
))
}
}
let inner = NaNBackend;
let damped = DampedBackend::new(inner);
let state = FluidState::from_pt(
Pressure::from_pascals(7.3773e6),
Temperature::from_kelvin(304.13),
);
// Should return a finite value instead of NaN
let cp = damped
.property(FluidId::new("CO2"), Property::Cp, state)
.unwrap();
assert!(!cp.is_nan(), "Should return finite value instead of NaN");
}
#[test]
#[cfg(feature = "coolprop")]
fn test_co2_near_critical_no_nan() {
use crate::coolprop::CoolPropBackend;
let inner = CoolPropBackend::new();
let damped = DampedBackend::new(inner);
// CO2 at 0.99*Tc, 0.99*Pc - near critical
let tc = 304.13;
let pc = 7.3773e6;
let state = FluidState::from_pt(
Pressure::from_pascals(0.99 * pc),
Temperature::from_kelvin(0.99 * tc),
);
// Should not return NaN
let cp = damped
.property(FluidId::new("CO2"), Property::Cp, state)
.unwrap();
assert!(!cp.is_nan(), "Cp should not be NaN near critical point");
assert!(cp.is_finite(), "Cp should be finite");
}
#[test]
#[cfg(feature = "coolprop")]
fn test_co2_supercritical_no_nan() {
use crate::coolprop::CoolPropBackend;
let inner = CoolPropBackend::new();
let damped = DampedBackend::new(inner);
// CO2 at 1.01*Tc, 1.01*Pc - supercritical
let tc = 304.13;
let pc = 7.3773e6;
let state = FluidState::from_pt(
Pressure::from_pascals(1.01 * pc),
Temperature::from_kelvin(1.01 * tc),
);
// Should not return NaN
let cp = damped
.property(FluidId::new("CO2"), Property::Cp, state)
.unwrap();
assert!(!cp.is_nan(), "Cp should not be NaN in supercritical region");
assert!(cp.is_finite(), "Cp should be finite");
}
#[test]
#[cfg(feature = "coolprop")]
fn test_r134a_unchanged_far_from_critical() {
use crate::coolprop::CoolPropBackend;
let inner_no_damp = CoolPropBackend::new();
let inner_damped = CoolPropBackend::new();
let damped = DampedBackend::new(inner_damped);
// R134a far from critical (room temp, 1 bar)
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let cp_no_damp = inner_no_damp
.property(FluidId::new("R134a"), Property::Cp, state.clone())
.unwrap();
let cp_damped = damped
.property(FluidId::new("R134a"), Property::Cp, state)
.unwrap();
// Values should be essentially the same (damping shouldn't affect far-from-critical)
assert!(
(cp_no_damp - cp_damped).abs() < 1.0,
"R134a far from critical should be unchanged"
);
}
}

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;
}
}
}

104
crates/fluids/src/errors.rs Normal file
View File

@@ -0,0 +1,104 @@
//! Error types for fluid properties calculations.
//!
//! This module defines the `FluidError` enum that represents all possible errors
//! that can occur when querying fluid properties.
use thiserror::Error;
/// Errors that can occur when working with fluid properties.
#[derive(Error, Debug, Clone)]
pub enum FluidError {
/// The requested fluid is not available in the backend.
#[error("Fluid `{fluid}` not found")]
UnknownFluid {
/// The fluid identifier that was requested
fluid: String,
},
/// The thermodynamic state is invalid for the requested property.
#[error("Invalid state for property calculation: {reason}")]
InvalidState {
/// The reason why the state is invalid
reason: String,
},
/// Error from CoolProp C++ library.
#[error("CoolProp error: {0}")]
CoolPropError(String),
/// Critical point data is not available for the given fluid.
#[error("Critical point not available for `{fluid}`")]
NoCriticalPoint {
/// The fluid identifier that was requested
fluid: String,
},
/// The requested property is not supported by this backend.
#[error("Property `{property}` not supported")]
UnsupportedProperty {
/// The property that is not supported
property: String,
},
/// Numerical error during calculation (overflow, NaN, etc).
#[error("Numerical error: {0}")]
NumericalError(String),
/// State is outside the tabular data bounds.
#[error("State ({p:.2} Pa, {t:.2} K) outside table bounds for fluid `{fluid}`")]
OutOfBounds {
/// Fluid identifier
fluid: String,
/// Pressure in Pa
p: f64,
/// Temperature in K
t: f64,
},
/// Table file could not be found or loaded.
#[error("Table file not found: {path}")]
TableNotFound {
/// Path that was attempted
path: String,
},
/// Mixture is not supported by the backend.
#[error("Mixture not supported: {0}")]
MixtureNotSupported(String),
}
/// Result type alias for fluid operations.
pub type FluidResult<T> = Result<T, FluidError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_unknown_fluid_error() {
let err = FluidError::UnknownFluid {
fluid: "R999".to_string(),
};
assert_eq!(format!("{}", err), "Fluid `R999` not found");
}
#[test]
fn test_invalid_state_error() {
let err = FluidError::InvalidState {
reason: "Pressure below triple point".to_string(),
};
assert_eq!(
format!("{}", err),
"Invalid state for property calculation: Pressure below triple point"
);
}
#[test]
fn test_error_clone() {
let err1 = FluidError::UnknownFluid {
fluid: "R134a".to_string(),
};
let err2 = err1.clone();
assert_eq!(format!("{}", err1), format!("{}", err2));
}
}

View File

@@ -0,0 +1,578 @@
//! Incompressible fluid properties backend.
//!
//! Provides lightweight polynomial models for water, glycol, and humid air
//! without external library calls. Properties obtained from IAPWS-IF97
//! (water) and ASHRAE (glycol) reference data.
use crate::backend::FluidBackend;
use crate::errors::{FluidError, FluidResult};
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
/// Incompressible fluid identifier.
///
/// Maps FluidId strings to internal fluid types. Supports:
/// - Water
/// - EthyleneGlycol with concentration 0.00.6 mass fraction
/// - PropyleneGlycol with concentration 0.00.6 mass fraction
/// - HumidAir
#[derive(Debug, Clone, PartialEq)]
pub enum IncompFluid {
/// Pure water (liquid phase)
Water,
/// Ethylene glycol aqueous solution, concentration = mass fraction (0.00.6)
EthyleneGlycol(f64),
/// Propylene glycol aqueous solution, concentration = mass fraction (0.00.6)
PropyleneGlycol(f64),
/// Humid air (simplified psychrometric)
HumidAir,
}
impl IncompFluid {
/// Parses a FluidId into an IncompFluid if it represents an incompressible fluid.
///
/// Recognized formats:
/// - "Water"
/// - "EthyleneGlycol" or "EthyleneGlycol30" (30% = 0.3)
/// - "PropyleneGlycol" or "PropyleneGlycol50" (50% = 0.5)
/// - "HumidAir"
pub fn from_fluid_id(fluid_id: &FluidId) -> Option<Self> {
let s = fluid_id.0.as_str();
if s.eq_ignore_ascii_case("Water") {
return Some(IncompFluid::Water);
}
if s.eq_ignore_ascii_case("HumidAir") {
return Some(IncompFluid::HumidAir);
}
if s.to_lowercase().starts_with("ethyleneglycol") {
let conc = parse_glycol_concentration(s, "ethyleneglycol")?;
if (0.0..=0.6).contains(&conc) {
return Some(IncompFluid::EthyleneGlycol(conc));
}
}
if s.to_lowercase().starts_with("propyleneglycol") {
let conc = parse_glycol_concentration(s, "propyleneglycol")?;
if (0.0..=0.6).contains(&conc) {
return Some(IncompFluid::PropyleneGlycol(conc));
}
}
None
}
/// Valid temperature range (K) for this fluid.
pub fn valid_temp_range(&self) -> (f64, f64) {
match self {
IncompFluid::Water => (273.15, 373.15),
IncompFluid::EthyleneGlycol(_) | IncompFluid::PropyleneGlycol(_) => (243.15, 373.15),
IncompFluid::HumidAir => (233.15, 353.15),
}
}
}
fn parse_glycol_concentration(s: &str, prefix: &str) -> Option<f64> {
let rest = s.get(prefix.len()..)?.trim();
if rest.is_empty() {
return Some(0.0); // Pure water in glycol context = 0%
}
rest.parse::<f64>().ok().map(|x| x / 100.0)
}
/// Valid temperature range for incompressible fluids.
#[derive(Debug, Clone, Copy)]
pub struct ValidRange {
/// Minimum temperature (K)
pub min_temp_k: f64,
/// Maximum temperature (K)
pub max_temp_k: f64,
}
impl ValidRange {
/// Checks if temperature is within valid range.
pub fn contains(&self, t_k: f64) -> bool {
t_k >= self.min_temp_k && t_k <= self.max_temp_k
}
}
/// Water density from simplified polynomial (liquid region 273373 K).
///
/// Fitted to IAPWS-IF97 reference: 20°C→998.2, 50°C→988.0, 80°C→971.8 kg/m³ (within 0.1%).
/// ρ(kg/m³) = 1001.7 - 0.107*T°C - 0.00333*(T°C)²
fn water_density_kelvin(t_k: f64) -> f64 {
let t_c = t_k - 273.15;
1001.7 - 0.107 * t_c - 0.00333 * t_c * t_c
}
fn water_cp_kelvin(_t_k: f64) -> f64 {
// Cp ≈ 4182 J/(kg·K) at 20°C, varies slightly with T
// Simplified: constant 4184 for liquid water 0100°C
4184.0
}
fn water_viscosity_kelvin(t_k: f64) -> f64 {
let t_c = t_k - 273.15;
// μ(Pa·s) for liquid water: 20°C→0.001, 40°C→0.00065
// Rational form: μ = 0.001 / (1 + 0.02*(T-20)) for T in °C
0.001 / (1.0 + 0.02 * (t_c - 20.0).max(0.0))
}
/// Incompressible fluid properties backend.
///
/// Implements FluidBackend for water, ethylene glycol, propylene glycol,
/// and humid air using lightweight polynomial models. No external library calls.
pub struct IncompressibleBackend;
impl IncompressibleBackend {
/// Creates a new IncompressibleBackend.
pub fn new() -> Self {
IncompressibleBackend
}
fn property_water(&self, property: Property, t_k: f64) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::Water.valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"Water temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
match property {
Property::Density => Ok(water_density_kelvin(t_k)),
Property::Cp => Ok(water_cp_kelvin(t_k)),
Property::Viscosity => Ok(water_viscosity_kelvin(t_k)),
Property::Enthalpy => {
// h ≈ Cp * (T - 273.15) relative to 0°C liquid
Ok(water_cp_kelvin(t_k) * (t_k - 273.15))
}
Property::Temperature => Ok(t_k),
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for Water", property),
}),
}
}
fn property_glycol(
&self,
property: Property,
t_k: f64,
concentration: f64,
is_ethylene: bool,
) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::EthyleneGlycol(0.0).valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"Glycol temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
if concentration < 0.0 || concentration > 0.6 {
return Err(FluidError::InvalidState {
reason: format!(
"Glycol concentration {} outside valid range [0, 0.6]",
concentration
),
});
}
// ASHRAE simplified: density increases with concentration, decreases with T
let rho_water = water_density_kelvin(t_k);
let t_c = t_k - 273.15;
match (property, is_ethylene) {
(Property::Density, true) => {
// EG: ρρ_water*(1 - 0.4*X) + 1115*X for X=concentration (approx)
Ok(rho_water * (1.0 - concentration) + 1115.0 * concentration)
}
(Property::Density, false) => {
Ok(rho_water * (1.0 - concentration) + 1036.0 * concentration)
}
(Property::Cp, true) => {
// EG 30%: ~3900, EG 50%: ~3400 J/(kg·K) at 20°C
Ok(4184.0 * (1.0 - concentration) + 2400.0 * concentration)
}
(Property::Cp, false) => {
Ok(4184.0 * (1.0 - concentration) + 2500.0 * concentration)
}
(Property::Viscosity, _) => {
// Viscosity increases strongly with concentration and decreases with T
let mu_water = water_viscosity_kelvin(t_k);
let conc_factor = 1.0 + 10.0 * concentration;
let temp_factor = (-0.02 * (t_c - 20.0)).exp();
Ok(mu_water * conc_factor * temp_factor)
}
(Property::Enthalpy, _) => {
let cp = if is_ethylene {
4184.0 * (1.0 - concentration) + 2400.0 * concentration
} else {
4184.0 * (1.0 - concentration) + 2500.0 * concentration
};
Ok(cp * (t_k - 273.15))
}
(Property::Temperature, _) => Ok(t_k),
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for glycol", property),
}),
}
}
fn property_humid_air(&self, property: Property, t_k: f64) -> FluidResult<f64> {
if !t_k.is_finite() {
return Err(FluidError::InvalidState {
reason: format!("Temperature {} K is not finite", t_k),
});
}
let (min_t, max_t) = IncompFluid::HumidAir.valid_temp_range();
if t_k < min_t || t_k > max_t {
return Err(FluidError::InvalidState {
reason: format!(
"HumidAir temperature {} K outside valid range [{}, {}]",
t_k, min_t, max_t
),
});
}
match property {
Property::Cp => Ok(1005.0), // Dry air Cp
Property::Temperature => Ok(t_k),
Property::Density => Ok(1.2), // Approximate at 20°C, 1 atm
_ => Err(FluidError::UnsupportedProperty {
property: format!("{} for HumidAir", property),
}),
}
}
}
impl Default for IncompressibleBackend {
fn default() -> Self {
Self::new()
}
}
impl FluidBackend for IncompressibleBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let (t_k, _p) = match &state {
FluidState::PressureTemperature(p, t) => (t.to_kelvin(), p.to_pascals()),
_ => {
return Err(FluidError::InvalidState {
reason: "IncompressibleBackend requires PressureTemperature state".to_string(),
})
}
};
if let Some(incomp) = IncompFluid::from_fluid_id(&fluid) {
match incomp {
IncompFluid::Water => self.property_water(property, t_k),
IncompFluid::EthyleneGlycol(conc) => {
self.property_glycol(property, t_k, conc, true)
}
IncompFluid::PropyleneGlycol(conc) => {
self.property_glycol(property, t_k, conc, false)
}
IncompFluid::HumidAir => self.property_humid_air(property, t_k),
}
} else {
Err(FluidError::UnknownFluid { fluid: fluid.0 })
}
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
if IncompFluid::from_fluid_id(&fluid).is_none() {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
IncompFluid::from_fluid_id(fluid).is_some()
}
fn phase(&self, fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
match IncompFluid::from_fluid_id(&fluid) {
Some(IncompFluid::HumidAir) => Ok(Phase::Vapor),
Some(_) => Ok(Phase::Liquid),
None => Err(FluidError::UnknownFluid { fluid: fluid.0 }),
}
}
fn list_fluids(&self) -> Vec<FluidId> {
vec![
FluidId::new("Water"),
FluidId::new("EthyleneGlycol"),
FluidId::new("EthyleneGlycol30"),
FluidId::new("EthyleneGlycol50"),
FluidId::new("PropyleneGlycol"),
FluidId::new("PropyleneGlycol30"),
FluidId::new("PropyleneGlycol50"),
FluidId::new("HumidAir"),
]
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for IncompressibleBackend: Temperature is {:.2} K but full state not natively implemented yet", t_k),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use entropyk_core::{Pressure, Temperature};
#[test]
fn test_incomp_fluid_from_fluid_id() {
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("Water")),
Some(IncompFluid::Water)
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("water")),
Some(IncompFluid::Water)
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("EthyleneGlycol30")),
Some(IncompFluid::EthyleneGlycol(c)) if (c - 0.3).abs() < 0.01
));
assert!(matches!(
IncompFluid::from_fluid_id(&FluidId::new("PropyleneGlycol50")),
Some(IncompFluid::PropyleneGlycol(c)) if (c - 0.5).abs() < 0.01
));
assert!(IncompFluid::from_fluid_id(&FluidId::new("R134a")).is_none());
}
#[test]
fn test_water_density_at_temperatures() {
let backend = IncompressibleBackend::new();
let state_20 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let state_50 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(50.0),
);
let state_80 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(80.0),
);
let rho_20 = backend
.property(FluidId::new("Water"), Property::Density, state_20)
.unwrap();
let rho_50 = backend
.property(FluidId::new("Water"), Property::Density, state_50)
.unwrap();
let rho_80 = backend
.property(FluidId::new("Water"), Property::Density, state_80)
.unwrap();
// IAPWS-IF97 reference: 20°C→998.2, 50°C→988.0, 80°C→971.8 kg/m³ (AC #2: within 0.1%)
assert!((rho_20 - 998.2).abs() / 998.2 < 0.001, "rho_20={}", rho_20);
assert!((rho_50 - 988.0).abs() / 988.0 < 0.001, "rho_50={}", rho_50);
assert!((rho_80 - 971.8).abs() / 971.8 < 0.001, "rho_80={}", rho_80);
}
#[test]
fn test_water_cp_accuracy() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let cp = backend
.property(FluidId::new("Water"), Property::Cp, state)
.unwrap();
// IAPWS: Cp ≈ 4182 J/(kg·K) at 20°C (AC #2: within 0.1%)
assert!((cp - 4182.0).abs() / 4182.0 < 0.001, "Cp={}", cp);
}
#[test]
fn test_water_out_of_range() {
let backend = IncompressibleBackend::new();
let state_cold = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(-10.0),
);
let state_hot = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(150.0),
);
assert!(backend
.property(FluidId::new("Water"), Property::Density, state_cold)
.is_err());
assert!(backend
.property(FluidId::new("Water"), Property::Density, state_hot)
.is_err());
}
#[test]
fn test_critical_point_returns_error() {
let backend = IncompressibleBackend::new();
let result = backend.critical_point(FluidId::new("Water"));
assert!(matches!(result, Err(FluidError::NoCriticalPoint { .. })));
}
#[test]
fn test_critical_point_unknown_fluid() {
let backend = IncompressibleBackend::new();
let result = backend.critical_point(FluidId::new("R134a"));
assert!(matches!(result, Err(FluidError::UnknownFluid { .. })));
}
#[test]
fn test_water_enthalpy_reference() {
let backend = IncompressibleBackend::new();
let state_0 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(0.0),
);
let state_20 = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let h_0 = backend
.property(FluidId::new("Water"), Property::Enthalpy, state_0)
.unwrap();
let h_20 = backend
.property(FluidId::new("Water"), Property::Enthalpy, state_20)
.unwrap();
// h = Cp * (T - 273.15) relative to 0°C: h_0 ≈ 0, h_20 ≈ 4184 * 20 = 83680 J/kg
assert!(h_0.abs() < 1.0, "h at 0°C should be ~0");
assert!((h_20 - 83680.0).abs() / 83680.0 < 0.01, "h at 20°C={}", h_20);
}
#[test]
fn test_glycol_concentration_effect() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let rho_water = backend
.property(FluidId::new("Water"), Property::Density, state.clone())
.unwrap();
let rho_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state.clone())
.unwrap();
let rho_eg50 = backend
.property(FluidId::new("EthyleneGlycol50"), Property::Density, state.clone())
.unwrap();
let cp_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Cp, state.clone())
.unwrap();
let cp_eg50 = backend
.property(FluidId::new("EthyleneGlycol50"), Property::Cp, state.clone())
.unwrap();
// Higher concentration → higher density, lower Cp (ASHRAE)
assert!(rho_eg30 > rho_water && rho_eg50 > rho_eg30);
assert!(cp_eg50 < cp_eg30 && cp_eg30 < 4184.0);
}
#[test]
fn test_glycol_out_of_range() {
let backend = IncompressibleBackend::new();
let state_cold = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(-40.0),
);
let state_hot = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(150.0),
);
assert!(backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state_cold)
.is_err());
assert!(backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state_hot)
.is_err());
}
#[test]
fn test_humid_air_psychrometrics() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let cp = backend
.property(FluidId::new("HumidAir"), Property::Cp, state.clone())
.unwrap();
let rho = backend
.property(FluidId::new("HumidAir"), Property::Density, state)
.unwrap();
// Dry air Cp ≈ 1005 J/(kg·K), ρ ≈ 1.2 kg/m³ at 20°C, 1 atm
assert!((cp - 1005.0).abs() < 1.0, "Cp={}", cp);
assert!((rho - 1.2).abs() < 0.2, "ρ={}", rho);
}
#[test]
fn test_phase_humid_air_is_vapor() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let phase = backend.phase(FluidId::new("HumidAir"), state).unwrap();
assert_eq!(phase, Phase::Vapor);
}
#[test]
fn test_nan_temperature_rejected() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_kelvin(f64::NAN),
);
assert!(backend
.property(FluidId::new("Water"), Property::Density, state)
.is_err());
}
#[test]
fn test_glycol_properties() {
let backend = IncompressibleBackend::new();
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(20.0),
);
let rho_eg30 = backend
.property(FluidId::new("EthyleneGlycol30"), Property::Density, state.clone())
.unwrap();
let rho_water = backend
.property(FluidId::new("Water"), Property::Density, state.clone())
.unwrap();
// EG 30% should be denser than water
assert!(rho_eg30 > rho_water, "EG30 ρ={} should be > water ρ={}", rho_eg30, rho_water);
}
#[test]
fn test_cached_backend_wrapper() {
use crate::cached_backend::CachedBackend;
let inner = IncompressibleBackend::new();
let backend = CachedBackend::new(inner);
let state = FluidState::from_pt(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
);
let rho = backend
.property(FluidId::new("Water"), Property::Density, state)
.unwrap();
assert!((rho - 997.0).abs() < 5.0);
}
}

69
crates/fluids/src/lib.rs Normal file
View File

@@ -0,0 +1,69 @@
//! # Entropyk Fluids
//!
//! Fluid properties backend for the Entropyk thermodynamic simulation library.
//!
//! This crate provides the abstraction layer for thermodynamic property calculations,
//! allowing the solver to work with different backends (CoolProp, tabular interpolation,
//! test mocks) through a unified trait-based interface.
//!
//! ## Key Components
//!
//! - [`FluidBackend`] - The core trait that all backends implement
//! - [`TestBackend`] - A mock backend for unit testing
//! - [`CoolPropBackend`] - A backend using the CoolProp C++ library
//! - [`FluidError`] - Error types for fluid operations
//! - [`types`] - Core types like `FluidId`, `Property`, `FluidState`, `CriticalPoint`
//! - [`mixture`] - Mixture types for multi-component refrigerants
//!
//! ## Example
//!
//! ```rust
//! use entropyk_fluids::{FluidBackend, FluidId, Property, FluidState, TestBackend};
//! use entropyk_core::{Pressure, Temperature};
//!
//! // Create a test backend for unit testing
//! let backend = TestBackend::new();
//!
//! // Query properties
//! let state = FluidState::from_pt(
//! Pressure::from_bar(1.0),
//! Temperature::from_celsius(25.0),
//! );
//!
//! let density = backend.property(
//! FluidId::new("R134a"),
//! Property::Density,
//! state,
//! ).unwrap();
//!
//! // In production use tracing::info! for observability (never println!)
//! ```
#![deny(warnings)]
#![warn(missing_docs)]
pub mod backend;
pub mod cache;
pub mod cached_backend;
pub mod coolprop;
pub mod damped_backend;
pub mod damping;
pub mod errors;
pub mod incompressible;
pub mod mixture;
pub mod tabular;
pub mod tabular_backend;
pub mod test_backend;
pub mod types;
pub use backend::FluidBackend;
pub use cached_backend::CachedBackend;
pub use coolprop::CoolPropBackend;
pub use damped_backend::DampedBackend;
pub use damping::{DampingParams, DampingState};
pub use errors::{FluidError, FluidResult};
pub use mixture::{Mixture, MixtureError};
pub use tabular_backend::TabularBackend;
pub use test_backend::TestBackend;
pub use incompressible::{IncompFluid, IncompressibleBackend, ValidRange};
pub use types::{CriticalPoint, Entropy, FluidId, Phase, Property, Quality, FluidState, ThermoState};

View File

@@ -0,0 +1,357 @@
//! Mixture types and utilities for multi-component refrigerants.
//!
//! This module provides types for representing refrigerant mixtures
//! (e.g., R454B = R32/R1234yf) and their thermodynamic properties.
use std::fmt;
use std::hash::{Hash, Hasher};
/// A refrigerant mixture composed of multiple components.
///
/// # Example
///
/// ```
/// use entropyk_fluids::mixture::Mixture;
///
/// let r454b = Mixture::from_mass_fractions(&[
/// ("R32", 0.5),
/// ("R1234yf", 0.5),
/// ]).unwrap();
///
/// let r410a = Mixture::from_mole_fractions(&[
/// ("R32", 0.5),
/// ("R125", 0.5),
/// ]).unwrap();
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct Mixture {
/// Components in the mixture (names as used by CoolProp)
components: Vec<String>,
/// Fractions (either mass or mole basis, depending on constructor)
fractions: Vec<f64>,
/// Whether fractions are mole-based (true) or mass-based (false)
mole_fractions: bool,
}
impl Mixture {
/// Create a mixture from mass fractions.
///
/// # Arguments
/// * `fractions` - Pairs of (fluid name, mass fraction)
///
/// # Errors
/// Returns an error if fractions don't sum to 1.0 or are invalid
pub fn from_mass_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
Self::validate_fractions(fractions)?;
Ok(Mixture {
components: fractions
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
mole_fractions: false,
})
}
/// Create a mixture from mole fractions.
///
/// # Arguments
/// * `fractions` - Pairs of (fluid name, mole fraction)
///
/// # Errors
/// Returns an error if fractions don't sum to 1.0 or are invalid
pub fn from_mole_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
Self::validate_fractions(fractions)?;
Ok(Mixture {
components: fractions
.iter()
.map(|(name, _)| (*name).to_string())
.collect(),
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
mole_fractions: true,
})
}
/// Validate that fractions are valid (sum to 1.0, all non-negative)
fn validate_fractions(fractions: &[(&str, f64)]) -> Result<(), MixtureError> {
if fractions.is_empty() {
return Err(MixtureError::InvalidComposition(
"Mixture must have at least one component".to_string(),
));
}
let sum: f64 = fractions.iter().map(|(_, frac)| frac).sum();
if (sum - 1.0).abs() > 1e-6 {
return Err(MixtureError::InvalidComposition(format!(
"Fractions must sum to 1.0, got {}",
sum
)));
}
for (_, frac) in fractions {
if *frac < 0.0 || *frac > 1.0 {
return Err(MixtureError::InvalidComposition(format!(
"Fraction must be between 0 and 1, got {}",
frac
)));
}
}
Ok(())
}
/// Get the components in this mixture.
pub fn components(&self) -> &[String] {
&self.components
}
/// Get the fractions (mass or mole basis depending on constructor).
pub fn fractions(&self) -> &[f64] {
&self.fractions
}
/// Check if fractions are mole-based.
pub fn is_mole_fractions(&self) -> bool {
self.mole_fractions
}
/// Check if fractions are mass-based.
pub fn is_mass_fractions(&self) -> bool {
!self.mole_fractions
}
/// Convert to CoolProp mixture string format.
///
/// CoolProp format: "R32[0.5]&R125[0.5]" (mole fractions)
pub fn to_coolprop_string(&self) -> String {
self.components
.iter()
.zip(self.fractions.iter())
.map(|(name, frac)| format!("{}[{}]", name, frac))
.collect::<Vec<_>>()
.join("&")
}
/// Get the number of components in this mixture.
pub fn len(&self) -> usize {
self.components.len()
}
/// Check if this mixture has no components.
pub fn is_empty(&self) -> bool {
self.components.is_empty()
}
/// Convert mass fractions to mole fractions.
///
/// Requires molar masses for each component.
/// Uses simplified molar masses for common refrigerants.
pub fn to_mole_fractions(&self) -> Result<Vec<f64>, MixtureError> {
if self.mole_fractions {
return Ok(self.fractions.to_vec());
}
let total: f64 = self
.components
.iter()
.zip(self.fractions.iter())
.map(|(c, frac)| frac / Self::molar_mass(c))
.sum();
Ok(self
.components
.iter()
.zip(self.fractions.iter())
.map(|(c, frac)| (frac / Self::molar_mass(c)) / total)
.collect())
}
/// Get molar mass (g/mol) for common refrigerants.
fn molar_mass(fluid: &str) -> f64 {
match fluid.to_uppercase().as_str() {
"R32" => 52.02,
"R125" => 120.02,
"R134A" => 102.03,
"R1234YF" => 114.04,
"R1234ZE" => 114.04,
"R410A" => 72.58,
"R404A" => 97.60,
"R407C" => 86.20,
"R290" | "PROPANE" => 44.10,
"R600" | "BUTANE" => 58.12,
"R600A" | "ISOBUTANE" => 58.12,
"CO2" | "R744" => 44.01,
"WATER" | "H2O" => 18.02,
"AIR" => 28.97,
"NITROGEN" | "N2" => 28.01,
"OXYGEN" | "O2" => 32.00,
_ => 50.0, // Default fallback
}
}
}
impl Hash for Mixture {
fn hash<H: Hasher>(&self, state: &mut H) {
// Use CoolProp string as stable hash representation
self.to_coolprop_string().hash(state);
}
}
impl Eq for Mixture {}
impl fmt::Display for Mixture {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let fraction_type = if self.mole_fractions { "mole" } else { "mass" };
write!(f, "Mixture ({} fractions): ", fraction_type)?;
for (i, (comp, frac)) in self
.components
.iter()
.zip(self.fractions.iter())
.enumerate()
{
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}={:.2}", comp, frac)?;
}
Ok(())
}
}
/// Errors that can occur when working with mixtures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MixtureError {
/// Invalid mixture composition
InvalidComposition(String),
/// Mixture not supported by backend
MixtureNotSupported(String),
/// Invalid fraction type
InvalidFractionType(String),
}
impl fmt::Display for MixtureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MixtureError::InvalidComposition(msg) => {
write!(f, "Invalid mixture composition: {}", msg)
}
MixtureError::MixtureNotSupported(msg) => write!(f, "Mixture not supported: {}", msg),
MixtureError::InvalidFractionType(msg) => write!(f, "Invalid fraction type: {}", msg),
}
}
}
impl std::error::Error for MixtureError {}
/// Pre-defined common refrigerant mixtures.
pub mod predefined {
use super::*;
/// R454B: R32 (50%) / R1234yf (50%) - mass fractions
pub fn r454b() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap()
}
/// R410A: R32 (50%) / R125 (50%) - mass fractions
pub fn r410a() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
}
/// R407C: R32 (23%) / R125 (25%) / R134a (52%) - mass fractions
pub fn r407c() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.23), ("R125", 0.25), ("R134a", 0.52)]).unwrap()
}
/// R404A: R125 (44%) / R143a (52%) / R134a (4%) - mass fractions
pub fn r404a() -> Mixture {
Mixture::from_mass_fractions(&[("R125", 0.44), ("R143a", 0.52), ("R134a", 0.04)]).unwrap()
}
/// R32/R125 (50/50) mixture - mass fractions
pub fn r32_r125_5050() -> Mixture {
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mixture_creation_mass() {
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
assert_eq!(mixture.components().len(), 2);
assert!(mixture.is_mass_fractions());
}
#[test]
fn test_mixture_creation_mole() {
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
assert_eq!(mixture.components().len(), 2);
assert!(mixture.is_mole_fractions());
}
#[test]
fn test_coolprop_string() {
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let cp_string = mixture.to_coolprop_string();
assert!(cp_string.contains("R32[0.5]"));
assert!(cp_string.contains("R1234yf[0.5]"));
}
#[test]
fn test_predefined_r454b() {
let mixture = predefined::r454b();
assert_eq!(mixture.components().len(), 2);
}
#[test]
fn test_invalid_fractions_sum() {
let result = Mixture::from_mass_fractions(&[("R32", 0.3), ("R125", 0.5)]);
assert!(result.is_err());
}
#[test]
fn test_invalid_fraction_negative() {
let result = Mixture::from_mass_fractions(&[("R32", -0.5), ("R125", 1.5)]);
assert!(result.is_err());
}
#[test]
fn test_mixture_hash() {
let m1 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let m2 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
use std::collections::hash_map::DefaultHasher;
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
m1.hash(&mut h1);
m2.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_mass_to_mole_conversion() {
// R454B: 50% mass R32, 50% mass R1234yf
// Molar masses: R32=52.02, R1234yf=114.04
// Mole fraction R32 = (0.5/52.02) / (0.5/52.02 + 0.5/114.04) ≈ 0.687
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let mole_fracs = mixture.to_mole_fractions().unwrap();
// Verify sum = 1.0
let sum: f64 = mole_fracs.iter().sum();
assert!((sum - 1.0).abs() < 1e-6);
// R32 should be ~69% mole fraction (higher due to lower molar mass)
assert!(mole_fracs[0] > 0.6 && mole_fracs[0] < 0.8);
}
#[test]
fn test_mole_fractions_passthrough() {
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
let mole_fracs = mixture.to_mole_fractions().unwrap();
assert!((mole_fracs[0] - 0.5).abs() < 1e-6);
assert!((mole_fracs[1] - 0.5).abs() < 1e-6);
}
}

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,
})
}
}

View File

@@ -0,0 +1,543 @@
//! Tabular interpolation backend for fluid properties.
//!
//! Provides 100x faster property lookups via pre-computed tables
//! with bilinear interpolation. Results deviate < 0.01% from NIST REFPROP.
use crate::backend::FluidBackend;
use crate::damped_backend::DampedBackend;
use crate::errors::{FluidError, FluidResult};
use crate::tabular::FluidTable;
#[allow(unused_imports)]
use crate::types::Entropy;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use std::collections::HashMap;
use std::path::Path;
/// Tabular backend using pre-computed property tables.
///
/// Loads fluid tables from JSON files and performs bilinear interpolation
/// for fast property lookups. No heap allocation in the property() hot path.
pub struct TabularBackend {
/// Pre-loaded tables: fluid name -> table (no allocation during queries)
tables: HashMap<String, FluidTable>,
/// Ordered list of fluid IDs for list_fluids()
fluid_ids: Vec<String>,
}
impl TabularBackend {
/// Create an empty TabularBackend.
pub fn new() -> Self {
TabularBackend {
tables: HashMap::new(),
fluid_ids: Vec::new(),
}
}
/// Creates a new TabularBackend with critical point damping enabled.
///
/// This wraps the backend with a `DampedBackend` to apply C1-continuous
/// damping to derivative properties (Cp, Cv, etc.) near the critical point.
pub fn with_damping() -> DampedBackend<TabularBackend> {
DampedBackend::new(Self::new())
}
/// Load a fluid table from a JSON file and register it.
pub fn load_table(&mut self, path: &Path) -> FluidResult<()> {
let table = FluidTable::load_from_path(path)?;
let id = table.fluid_id.clone();
if !self.fluid_ids.contains(&id) {
self.fluid_ids.push(id.clone());
}
self.tables.insert(id, table);
Ok(())
}
/// Load a fluid table from a JSON string (for embedded/test data).
pub fn load_table_from_str(&mut self, json: &str) -> FluidResult<()> {
let table = FluidTable::load_from_str(json)?;
let id = table.fluid_id.clone();
if !self.fluid_ids.contains(&id) {
self.fluid_ids.push(id.clone());
}
self.tables.insert(id, table);
Ok(())
}
/// Get a reference to a fluid table. Returns None if not loaded.
#[inline]
fn get_table(&self, fluid: &FluidId) -> Option<&FluidTable> {
self.tables.get(&fluid.0)
}
/// Resolve FluidState to (p, t) in Pascals and Kelvin.
/// For (P,x) uses saturation temperature at P.
fn resolve_state(&self, fluid: &FluidId, state: FluidState) -> FluidResult<(f64, f64)> {
match state {
FluidState::PressureTemperature(p, t) => Ok((p.to_pascals(), t.to_kelvin())),
FluidState::PressureEnthalpy(p, h) => {
let table = self.get_table(fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
self.find_t_from_ph(table, p.to_pascals(), h.to_joules_per_kg())
}
FluidState::PressureQuality(p, _x) => {
let table = self.get_table(fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
if let Some(ref sat) = table.saturation {
let (t_sat, _, _, _, _, _, _) =
sat.at_pressure(p.to_pascals())
.ok_or(FluidError::OutOfBounds {
fluid: fluid.0.clone(),
p: p.to_pascals(),
t: 0.0,
})?;
Ok((p.to_pascals(), t_sat))
} else {
Err(FluidError::InvalidState {
reason: "Two-phase (P,x) requires saturation table".to_string(),
})
}
}
FluidState::PressureEntropy(_, _) => Err(FluidError::InvalidState {
reason: "TabularBackend does not yet support (P,s) state".to_string(),
}),
FluidState::PressureTemperatureMixture(_, _, _)
| FluidState::PressureEnthalpyMixture(_, _, _)
| FluidState::PressureQualityMixture(_, _, _) => {
// TabularBackend does not support mixtures - fallback to error
Err(FluidError::MixtureNotSupported(
"TabularBackend does not support mixtures. Use CoolPropBackend.".to_string(),
))
}
}
}
/// Find T given (P, h) using Newton iteration on the enthalpy table.
fn find_t_from_ph(&self, table: &FluidTable, p: f64, h_target: f64) -> FluidResult<(f64, f64)> {
// Initial guess: use midpoint of T range
let t_grid = &table.single_phase.temperature;
if t_grid.len() < 2 {
return Err(FluidError::InvalidState {
reason: "Table too small for (P,h) lookup".to_string(),
});
}
let mut t = (t_grid[0] + t_grid[t_grid.len() - 1]) / 2.0;
let dt_fd = 0.1; // K, for finite difference
for _ in 0..20 {
let h = table
.single_phase
.interpolate("enthalpy", p, t, &table.fluid_id)?;
let err = h - h_target;
if err.abs() < 1.0 {
return Ok((p, t));
}
let h_plus = table
.single_phase
.interpolate("enthalpy", p, t + dt_fd, &table.fluid_id)
.unwrap_or(h);
let dh_dt = (h_plus - h) / dt_fd;
if dh_dt.abs() < 1e-10 {
return Err(FluidError::NumericalError(
"Zero dh/dT in (P,h) Newton iteration".to_string(),
));
}
t -= err / dh_dt;
if t < t_grid[0] || t > t_grid[t_grid.len() - 1] {
return Err(FluidError::OutOfBounds {
fluid: table.fluid_id.clone(),
p,
t,
});
}
}
Err(FluidError::NumericalError(
"Newton iteration did not converge for (P,h)".to_string(),
))
}
/// Get property for two-phase (P, x) via linear blend.
fn property_two_phase(
&self,
table: &FluidTable,
p: f64,
x: f64,
property: Property,
) -> FluidResult<f64> {
let sat = table.saturation.as_ref().ok_or(FluidError::InvalidState {
reason: "Two-phase requires saturation table".to_string(),
})?;
let (t_sat, h_liq, h_vap, rho_liq, rho_vap, s_liq, s_vap) =
sat.at_pressure(p).ok_or(FluidError::OutOfBounds {
fluid: table.fluid_id.clone(),
p,
t: 0.0,
})?;
let val = match property {
Property::Enthalpy => h_liq * (1.0 - x) + h_vap * x,
Property::Density => {
let v_liq = 1.0 / rho_liq;
let v_vap = 1.0 / rho_vap;
let v = v_liq * (1.0 - x) + v_vap * x;
1.0 / v
}
Property::Entropy => s_liq * (1.0 - x) + s_vap * x,
Property::Quality => x,
Property::Temperature => t_sat,
Property::Pressure => p,
_ => {
return Err(FluidError::UnsupportedProperty {
property: property.to_string(),
})
}
};
Ok(val)
}
/// Map Property enum to table property name.
fn property_table_name(property: Property) -> Option<&'static str> {
match property {
Property::Density => Some("density"),
Property::Enthalpy => Some("enthalpy"),
Property::Entropy => Some("entropy"),
Property::Cp => Some("cp"),
Property::Cv => Some("cv"),
Property::Temperature => Some("temperature"),
Property::Pressure => Some("pressure"),
_ => None,
}
}
}
impl Default for TabularBackend {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::FluidBackend;
use approx::assert_relative_eq;
use entropyk_core::{Pressure, Temperature};
fn make_test_backend() -> TabularBackend {
let mut backend = TabularBackend::new();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/r134a.json");
backend.load_table(&path).unwrap();
backend
}
#[test]
fn test_tabular_load_r134a() {
let backend = make_test_backend();
assert!(backend.is_fluid_available(&FluidId::new("R134a")));
assert!(!backend.is_fluid_available(&FluidId::new("R999")));
}
#[test]
fn test_tabular_property_pt() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let density = backend
.property(FluidId::new("R134a"), Property::Density, state.clone())
.unwrap();
assert!(density > 1.0 && density < 100.0);
let enthalpy = backend
.property(FluidId::new("R134a"), Property::Enthalpy, state)
.unwrap();
assert!(enthalpy > 300_000.0 && enthalpy < 500_000.0);
}
/// Accuracy: at grid point (200 kPa, 290 K), density must match table exactly.
#[test]
fn test_tabular_accuracy_at_grid_point() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(290.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_relative_eq!(density, 9.0, epsilon = 1e-10);
}
/// Accuracy: interpolated value within 1% (table self-consistency check).
#[test]
fn test_tabular_accuracy_interpolated() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(200_000.0),
Temperature::from_kelvin(300.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert_relative_eq!(density, 8.415, epsilon = 0.01);
}
#[test]
fn test_tabular_critical_point() {
let backend = make_test_backend();
let cp = backend.critical_point(FluidId::new("R134a")).unwrap();
assert!((cp.temperature_kelvin() - 374.21).abs() < 1.0);
assert!((cp.pressure_pascals() - 4.059e6).abs() < 1e4);
}
#[test]
fn test_tabular_list_fluids() {
let backend = make_test_backend();
let fluids = backend.list_fluids();
assert_eq!(fluids.len(), 1);
assert_eq!(fluids[0].0, "R134a");
}
#[test]
fn test_tabular_unknown_fluid() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let result = backend.property(FluidId::new("R999"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_tabular_out_of_bounds() {
let backend = make_test_backend();
let state = FluidState::from_pt(
Pressure::from_pascals(50_000.0),
Temperature::from_kelvin(200.0),
);
let result = backend.property(FluidId::new("R134a"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_tabular_ph_state() {
let backend = make_test_backend();
let state = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0),
);
let density = backend
.property(FluidId::new("R134a"), Property::Density, state)
.unwrap();
assert!(density > 1.0);
}
#[test]
fn test_tabular_px_state() {
let backend = make_test_backend();
let state = FluidState::from_px(
Pressure::from_pascals(500_000.0),
crate::types::Quality::new(0.5),
);
let enthalpy = backend
.property(FluidId::new("R134a"), Property::Enthalpy, state)
.unwrap();
assert!(enthalpy > 300_000.0 && enthalpy < 450_000.0);
}
#[test]
fn test_tabular_benchmark_10k_queries() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let start = std::time::Instant::now();
for _ in 0..10_000 {
let _ = backend.property(FluidId::new("R134a"), Property::Density, state.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 100,
"10k queries took {}ms, expected < 100ms (debug mode)",
elapsed.as_millis()
);
}
/// Release build: 10k queries must complete in < 10ms (AC #3).
#[test]
#[cfg_attr(debug_assertions, ignore = "run with cargo test --release")]
fn test_tabular_benchmark_10k_queries_release() {
let backend = make_test_backend();
let state = FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let start = std::time::Instant::now();
for _ in 0..10_000 {
let _ = backend.property(FluidId::new("R134a"), Property::Density, state.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 10,
"10k queries took {}ms, expected < 10ms in release",
elapsed.as_millis()
);
}
/// Compare TabularBackend vs CoolPropBackend. Embedded r134a.json may be from
/// reference data; use epsilon 1% for compatibility. CoolProp-generated tables
/// achieve < 0.01% (validated in generator::test_generated_table_vs_coolprop_spot_checks).
#[test]
#[cfg(feature = "coolprop")]
fn test_tabular_vs_coolprop_accuracy() {
use crate::coolprop::CoolPropBackend;
use crate::types::Quality;
let mut tabular = TabularBackend::new();
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/r134a.json");
tabular.load_table(&path).unwrap();
let coolprop = CoolPropBackend::new();
let fluid = FluidId::new("R134a");
// (P, T) at 1 bar, 25°C
let state_pt =
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
let rho_t = tabular
.property(fluid.clone(), Property::Density, state_pt)
.unwrap();
let rho_c = coolprop
.property(fluid.clone(), Property::Density, state_pt)
.unwrap();
assert_relative_eq!(rho_t, rho_c, epsilon = 0.01 * rho_c.max(1.0));
let h_t = tabular
.property(fluid.clone(), Property::Enthalpy, state_pt)
.unwrap();
let h_c = coolprop
.property(fluid.clone(), Property::Enthalpy, state_pt)
.unwrap();
assert_relative_eq!(h_t, h_c, epsilon = 0.01 * h_c.max(1.0));
// (P, h) at 1 bar, h ≈ 415 kJ/kg
let state_ph = FluidState::from_ph(
Pressure::from_bar(1.0),
entropyk_core::Enthalpy::from_kilojoules_per_kg(415.0),
);
let rho_t_ph = tabular
.property(fluid.clone(), Property::Density, state_ph)
.unwrap();
let rho_c_ph = coolprop
.property(fluid.clone(), Property::Density, state_ph)
.unwrap();
assert_relative_eq!(rho_t_ph, rho_c_ph, epsilon = 0.01 * rho_c_ph.max(1.0));
// (P, x) at 500 kPa, x = 0.5
let state_px = FluidState::from_px(Pressure::from_pascals(500_000.0), Quality::new(0.5));
let h_t_px = tabular
.property(fluid.clone(), Property::Enthalpy, state_px)
.unwrap();
let h_c_px = coolprop
.property(fluid.clone(), Property::Enthalpy, state_px)
.unwrap();
assert_relative_eq!(h_t_px, h_c_px, epsilon = 0.01 * h_c_px.max(1.0));
}
}
impl FluidBackend for TabularBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
// Handle (P, x) two-phase explicitly
if let FluidState::PressureQuality(p, x) = state {
return self.property_two_phase(table, p.to_pascals(), x.value(), property);
}
let (p, t) = self.resolve_state(&fluid, state)?;
// Temperature and Pressure are direct
if property == Property::Temperature {
return Ok(t);
}
if property == Property::Pressure {
return Ok(p);
}
let name = Self::property_table_name(property).ok_or(FluidError::UnsupportedProperty {
property: property.to_string(),
})?;
table.single_phase.interpolate(name, p, t, &fluid.0)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
let table = self.get_table(&fluid).ok_or(FluidError::NoCriticalPoint {
fluid: fluid.0.clone(),
})?;
let cp = &table.critical_point;
Ok(CriticalPoint::new(cp.temperature, cp.pressure, cp.density))
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.tables.contains_key(&fluid.0)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
let table = self.get_table(&fluid).ok_or(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
})?;
let (p, t) = self.resolve_state(&fluid, state.clone())?;
let pc = table.critical_point.pressure.to_pascals();
let tc = table.critical_point.temperature.to_kelvin();
if p > pc && t > tc {
return Ok(Phase::Supercritical);
}
if let Some(ref sat) = table.saturation {
if let Some((_, h_liq, h_vap, _, _, _, _)) = sat.at_pressure(p) {
if let FluidState::PressureEnthalpy(_, h) = state {
let hv = h.to_joules_per_kg();
if hv <= h_liq {
return Ok(Phase::Liquid);
}
if hv >= h_vap {
return Ok(Phase::Vapor);
}
return Ok(Phase::TwoPhase);
}
if let FluidState::PressureQuality(_, x) = state {
if x.value() <= 0.0 {
return Ok(Phase::Liquid);
}
if x.value() >= 1.0 {
return Ok(Phase::Vapor);
}
return Ok(Phase::TwoPhase);
}
}
}
Ok(Phase::Unknown)
}
fn list_fluids(&self) -> Vec<FluidId> {
self.fluid_ids
.iter()
.map(|s| FluidId::new(s.clone()))
.collect()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for TabularBackend: Temperature is {:.2} K", t_k),
})
}
}

View File

@@ -0,0 +1,430 @@
//! Test backend implementation for unit testing.
//!
//! This module provides a mock backend that returns simplified/idealized
//! property values for testing without requiring external dependencies
//! like CoolProp.
use crate::backend::FluidBackend;
use crate::errors::{FluidError, FluidResult};
#[cfg(test)]
use crate::mixture::Mixture;
use crate::types::{CriticalPoint, FluidId, Phase, Property, FluidState};
use entropyk_core::{Pressure, Temperature};
use std::collections::HashMap;
/// Test backend for unit testing.
///
/// This backend provides simplified thermodynamic property calculations
/// suitable for testing without external dependencies. Values are idealized
/// approximations and should NOT be used for real simulations.
pub struct TestBackend {
/// Map of fluid names to critical points
critical_points: HashMap<String, CriticalPoint>,
/// List of available test fluids
available_fluids: Vec<String>,
}
impl TestBackend {
/// Creates a new TestBackend with default test fluids.
pub fn new() -> Self {
let mut critical_points = HashMap::new();
// CO2 (R744)
critical_points.insert(
"CO2".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.0,
),
);
// R134a
critical_points.insert(
"R134a".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(374.21),
Pressure::from_pascals(4.059e6),
512.0,
),
);
// R410A
critical_points.insert(
"R410A".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(344.49),
Pressure::from_pascals(4.926e6),
458.0,
),
);
// R32
critical_points.insert(
"R32".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(351.25),
Pressure::from_pascals(5.782e6),
360.0,
),
);
// Water
critical_points.insert(
"Water".to_string(),
CriticalPoint::new(
Temperature::from_kelvin(647.096),
Pressure::from_pascals(22.064e6),
322.0,
),
);
let available_fluids = vec![
"CO2".to_string(),
"R134a".to_string(),
"R410A".to_string(),
"R32".to_string(),
"Water".to_string(),
"Nitrogen".to_string(),
"Oxygen".to_string(),
"Air".to_string(),
];
TestBackend {
critical_points,
available_fluids,
}
}
/// Simplified ideal gas property calculation.
fn ideal_property(
&self,
fluid: &str,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
// Simple ideal gas approximations for testing
// Real implementation would use proper equations of state
match fluid {
"Nitrogen" | "Oxygen" | "Air" => self.ideal_gas_property(property, state, 29.0),
"Water" => self.water_property(property, state),
_ => {
// For refrigerants, use simplified correlations
self.refrigerant_property(fluid, property, state)
}
}
}
fn ideal_gas_property(
&self,
property: Property,
state: FluidState,
_molar_mass: f64,
) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for ideal gases".to_string(),
})
}
};
// Simplified ideal gas: R = 8.314 J/(mol·K), approximate
let r_specific = 287.0; // J/(kg·K) for air
match property {
Property::Density => Ok(p / (r_specific * t)),
Property::Enthalpy => Ok(1005.0 * t), // Cp * T, Cp ≈ 1005 J/(kg·K) for air
Property::Entropy => Ok(r_specific * t.ln()), // Simplified
Property::Cp => Ok(1005.0), // Constant pressure specific heat
Property::Cv => Ok(718.0), // Constant volume specific heat
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
Property::ThermalConductivity => Ok(0.025), // W/(m·K) for air
Property::Viscosity => Ok(1.8e-5), // Pa·s for air
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
}
fn water_property(&self, property: Property, state: FluidState) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for water".to_string(),
})
}
};
// Simplified water properties at ~1 atm
if p < 1.1e5 && t > 273.15 && t < 373.15 {
match property {
Property::Density => Ok(1000.0), // kg/m³
Property::Enthalpy => Ok(4200.0 * (t - 273.15)), // Cp * ΔT
Property::Cp => Ok(4184.0), // J/(kg·K)
Property::ThermalConductivity => Ok(0.6), // W/(m·K)
Property::Viscosity => Ok(0.001), // Pa·s
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
} else {
Err(FluidError::InvalidState {
reason: "Water property only valid in liquid region".to_string(),
})
}
}
fn refrigerant_property(
&self,
_fluid: &str,
property: Property,
state: FluidState,
) -> FluidResult<f64> {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => {
return Err(FluidError::InvalidState {
reason: "TestBackend only supports P-T state for refrigerants".to_string(),
})
}
};
// Simplified refrigerant properties
match property {
Property::Density => {
// Rough approximation for liquid (~1000 kg/m³) vs vapor (~10-50 kg/m³)
if p > 1e6 {
Ok(1000.0) // Liquid
} else {
Ok(30.0) // Vapor
}
}
Property::Enthalpy => {
if p > 1e6 {
Ok(200000.0) // Liquid region
} else {
Ok(400000.0) // Vapor region
}
}
Property::Cp => Ok(1500.0), // Approximate
Property::Temperature => Ok(t),
Property::Pressure => Ok(p),
Property::ThermalConductivity => Ok(0.015),
Property::Viscosity => Ok(1.5e-5),
_ => Err(FluidError::UnsupportedProperty {
property: property.to_string(),
}),
}
}
fn determine_phase(&self, fluid: &str, state: FluidState) -> Phase {
let (p, t) = match state {
FluidState::PressureTemperature(p, t) => (p.to_pascals(), t.to_kelvin()),
_ => return Phase::Unknown,
};
// Get critical point if available
if let Some(cp) = self.critical_points.get(fluid) {
let pc = cp.pressure_pascals();
let tc = cp.temperature_kelvin();
if p > pc && t > tc {
return Phase::Supercritical;
}
if (p - pc).abs() / pc < 0.05 || (t - tc).abs() / tc < 0.05 {
return Phase::Supercritical;
}
}
// Simplified phase determination
if p > 5e5 {
Phase::Liquid
} else {
Phase::Vapor
}
}
}
impl Default for TestBackend {
fn default() -> Self {
Self::new()
}
}
impl FluidBackend for TestBackend {
fn property(&self, fluid: FluidId, property: Property, state: FluidState) -> FluidResult<f64> {
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid {
fluid: fluid.0.clone(),
});
}
self.ideal_property(&fluid.0, property, state)
}
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
self.critical_points
.get(&fluid.0)
.copied()
.ok_or(FluidError::NoCriticalPoint { fluid: fluid.0 })
}
fn is_fluid_available(&self, fluid: &FluidId) -> bool {
self.available_fluids.iter().any(|f| f == &fluid.0)
}
fn phase(&self, fluid: FluidId, state: FluidState) -> FluidResult<Phase> {
if !self.is_fluid_available(&fluid) {
return Err(FluidError::UnknownFluid { fluid: fluid.0 });
}
Ok(self.determine_phase(&fluid.0, state))
}
fn list_fluids(&self) -> Vec<FluidId> {
self.available_fluids
.iter()
.map(|s| FluidId::new(s.clone()))
.collect()
}
fn full_state(&self, fluid: FluidId, p: entropyk_core::Pressure, h: entropyk_core::Enthalpy) -> FluidResult<crate::types::ThermoState> {
let t_k = self.property(fluid.clone(), Property::Temperature, FluidState::from_ph(p, h))?;
Err(FluidError::UnsupportedProperty {
property: format!("full_state for TestBackend: Temperature is {:.2} K", t_k),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backend_available_fluids() {
let backend = TestBackend::new();
assert!(backend.is_fluid_available(&FluidId::new("CO2")));
assert!(backend.is_fluid_available(&FluidId::new("R134a")));
assert!(!backend.is_fluid_available(&FluidId::new("R999")));
}
#[test]
fn test_list_fluids() {
let backend = TestBackend::new();
let fluids = backend.list_fluids();
assert!(fluids.len() > 0);
assert!(fluids.iter().any(|f| f.0 == "CO2"));
}
#[test]
fn test_critical_point() {
let backend = TestBackend::new();
let cp = backend.critical_point(FluidId::new("CO2")).unwrap();
assert!((cp.temperature_kelvin() - 304.13).abs() < 0.1);
assert!((cp.pressure_pascals() - 7.3773e6).abs() < 1e4);
}
#[test]
fn test_critical_point_not_available() {
let backend = TestBackend::new();
let result = backend.critical_point(FluidId::new("UnknownFluid"));
assert!(result.is_err());
}
#[test]
fn test_property_nitrogen() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_kelvin(300.0),
);
let density = backend
.property(FluidId::new("Nitrogen"), Property::Density, state)
.unwrap();
assert!(density > 1.0); // Should be ~1.16 kg/m³
}
#[test]
fn test_property_water() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_celsius(25.0),
);
let density = backend
.property(FluidId::new("Water"), Property::Density, state)
.unwrap();
assert!((density - 1000.0).abs() < 1.0);
}
#[test]
fn test_property_unknown_fluid() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(101325.0),
Temperature::from_kelvin(300.0),
);
let result = backend.property(FluidId::new("R999"), Property::Density, state);
assert!(result.is_err());
}
#[test]
fn test_phase_co2_supercritical() {
let backend = TestBackend::new();
// Above critical point
let state =
FluidState::from_pt(Pressure::from_pascals(8e6), Temperature::from_kelvin(320.0));
let phase = backend.phase(FluidId::new("CO2"), state).unwrap();
assert_eq!(phase, Phase::Supercritical);
}
#[test]
fn test_phase_liquid() {
let backend = TestBackend::new();
let state = FluidState::from_pt(
Pressure::from_pascals(10e5),
Temperature::from_celsius(25.0),
);
let phase = backend.phase(FluidId::new("Water"), state).unwrap();
assert_eq!(phase, Phase::Liquid);
}
#[test]
fn test_thermo_state_is_mixture() {
let mix = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
let state_pure =
FluidState::from_pt(Pressure::from_bar(1.0), Temperature::from_celsius(25.0));
assert!(!state_pure.is_mixture());
let state_mix = FluidState::from_pt_mix(
Pressure::from_bar(1.0),
Temperature::from_celsius(25.0),
mix,
);
assert!(state_mix.is_mixture());
}
}

369
crates/fluids/src/types.rs Normal file
View File

@@ -0,0 +1,369 @@
//! Types for fluid property calculations.
//!
//! This module defines the core types used to represent thermodynamic states,
//! fluid identifiers, and properties in the fluid backend system.
use crate::mixture::Mixture;
use entropyk_core::{Enthalpy, Pressure, Temperature};
use std::fmt;
/// Difference between two temperatures in Kelvin.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct TemperatureDelta(pub f64);
impl TemperatureDelta {
/// Creates a new TemperatureDelta from a difference in Kelvin.
pub fn new(kelvin_diff: f64) -> Self {
TemperatureDelta(kelvin_diff)
}
/// Gets the temperature difference in Kelvin.
pub fn kelvin(&self) -> f64 {
self.0
}
}
impl From<f64> for TemperatureDelta {
fn from(val: f64) -> Self {
TemperatureDelta(val)
}
}
/// Unique identifier for a fluid.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FluidId(pub String);
impl FluidId {
/// Creates a new FluidId from a string.
pub fn new(name: impl Into<String>) -> Self {
FluidId(name.into())
}
}
impl fmt::Display for FluidId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for FluidId {
fn from(s: &str) -> Self {
FluidId(s.to_string())
}
}
impl From<String> for FluidId {
fn from(s: String) -> Self {
FluidId(s)
}
}
/// Thermodynamic property that can be queried from a backend.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Property {
/// Density (kg/m³)
Density,
/// Specific enthalpy (J/kg)
Enthalpy,
/// Specific entropy (J/(kg·K))
Entropy,
/// Specific internal energy (J/kg)
InternalEnergy,
/// Specific heat capacity at constant pressure (J/(kg·K))
Cp,
/// Specific heat capacity at constant volume (J/(kg·K))
Cv,
/// Speed of sound (m/s)
SpeedOfSound,
/// Dynamic viscosity (Pa·s)
Viscosity,
/// Thermal conductivity (W/(m·K))
ThermalConductivity,
/// Surface tension (N/m)
SurfaceTension,
/// Quality (0-1 for two-phase)
Quality,
/// Temperature (K)
Temperature,
/// Pressure (Pa)
Pressure,
}
impl fmt::Display for Property {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Property::Density => write!(f, "Density"),
Property::Enthalpy => write!(f, "Enthalpy"),
Property::Entropy => write!(f, "Entropy"),
Property::InternalEnergy => write!(f, "InternalEnergy"),
Property::Cp => write!(f, "Cp"),
Property::Cv => write!(f, "Cv"),
Property::SpeedOfSound => write!(f, "SpeedOfSound"),
Property::Viscosity => write!(f, "Viscosity"),
Property::ThermalConductivity => write!(f, "ThermalConductivity"),
Property::SurfaceTension => write!(f, "SurfaceTension"),
Property::Quality => write!(f, "Quality"),
Property::Temperature => write!(f, "Temperature"),
Property::Pressure => write!(f, "Pressure"),
}
}
}
/// Input specification for thermodynamic state calculation.
///
/// Defines what inputs are available to look up a thermodynamic property.
#[derive(Debug, Clone, PartialEq)]
pub enum FluidState {
/// Pressure and temperature (P, T) - most common for single-phase
PressureTemperature(Pressure, Temperature),
/// Pressure and enthalpy (P, h) - common for expansion/compression
PressureEnthalpy(Pressure, Enthalpy),
/// Pressure and entropy (P, s) - useful for isentropic processes
PressureEntropy(Pressure, Entropy),
/// Pressure and quality (P, x) - for two-phase regions
PressureQuality(Pressure, Quality),
/// Pressure and temperature with mixture (P, T, mixture)
PressureTemperatureMixture(Pressure, Temperature, Mixture),
/// Pressure and enthalpy with mixture (P, h, mixture) - preferred for two-phase
PressureEnthalpyMixture(Pressure, Enthalpy, Mixture),
/// Pressure and quality with mixture (P, x, mixture) - for two-phase mixtures
PressureQualityMixture(Pressure, Quality, Mixture),
}
impl FluidState {
/// Creates a state from pressure and temperature.
pub fn from_pt(p: Pressure, t: Temperature) -> Self {
FluidState::PressureTemperature(p, t)
}
/// Creates a state from pressure and enthalpy.
pub fn from_ph(p: Pressure, h: Enthalpy) -> Self {
FluidState::PressureEnthalpy(p, h)
}
/// Creates a state from pressure and entropy.
pub fn from_ps(p: Pressure, s: Entropy) -> Self {
FluidState::PressureEntropy(p, s)
}
/// Creates a state from pressure and quality.
pub fn from_px(p: Pressure, x: Quality) -> Self {
FluidState::PressureQuality(p, x)
}
/// Creates a state from pressure, temperature, and mixture.
pub fn from_pt_mix(p: Pressure, t: Temperature, mix: Mixture) -> Self {
FluidState::PressureTemperatureMixture(p, t, mix)
}
/// Creates a state from pressure, enthalpy, and mixture (preferred for two-phase).
pub fn from_ph_mix(p: Pressure, h: Enthalpy, mix: Mixture) -> Self {
FluidState::PressureEnthalpyMixture(p, h, mix)
}
/// Creates a state from pressure, quality, and mixture.
pub fn from_px_mix(p: Pressure, x: Quality, mix: Mixture) -> Self {
FluidState::PressureQualityMixture(p, x, mix)
}
/// Check if this state contains a mixture.
pub fn is_mixture(&self) -> bool {
matches!(
self,
FluidState::PressureTemperatureMixture(_, _, _)
| FluidState::PressureEnthalpyMixture(_, _, _)
| FluidState::PressureQualityMixture(_, _, _)
)
}
}
/// Entropy in J/(kg·K).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Entropy(pub f64);
impl Entropy {
/// Creates entropy from J/(kg·K).
pub fn from_joules_per_kg_kelvin(value: f64) -> Self {
Entropy(value)
}
/// Returns entropy in J/(kg·K).
pub fn to_joules_per_kg_kelvin(&self) -> f64 {
self.0
}
}
impl From<f64> for Entropy {
fn from(value: f64) -> Self {
Entropy(value)
}
}
/// Quality (vapor fraction) from 0 (saturated liquid) to 1 (saturated vapor).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quality(pub f64);
impl Quality {
/// Creates a quality value (0-1).
pub fn new(value: f64) -> Self {
Quality(value.clamp(0.0, 1.0))
}
/// Returns the quality value (0-1).
pub fn value(&self) -> f64 {
self.0
}
}
impl From<f64> for Quality {
fn from(value: f64) -> Self {
Quality::new(value)
}
}
/// Critical point data for a fluid.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CriticalPoint {
/// Critical temperature in Kelvin.
pub temperature: Temperature,
/// Critical pressure in Pascals.
pub pressure: Pressure,
/// Critical density in kg/m³.
pub density: f64,
}
impl CriticalPoint {
/// Creates a new CriticalPoint.
pub fn new(temperature: Temperature, pressure: Pressure, density: f64) -> Self {
CriticalPoint {
temperature,
pressure,
density,
}
}
/// Returns critical temperature in Kelvin.
pub fn temperature_kelvin(&self) -> f64 {
self.temperature.to_kelvin()
}
/// Returns critical pressure in Pascals.
pub fn pressure_pascals(&self) -> f64 {
self.pressure.to_pascals()
}
}
/// Phase of matter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Liquid phase.
Liquid,
/// Vapor/gas phase.
Vapor,
/// Two-phase region.
TwoPhase,
/// Supercritical fluid.
Supercritical,
/// Unknown or undefined phase.
Unknown,
}
impl fmt::Display for Phase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Phase::Liquid => write!(f, "Liquid"),
Phase::Vapor => write!(f, "Vapor"),
Phase::TwoPhase => write!(f, "TwoPhase"),
Phase::Supercritical => write!(f, "Supercritical"),
Phase::Unknown => write!(f, "Unknown"),
}
}
}
/// Comprehensive representation of a thermodynamic state.
///
/// This struct holds a complete snapshot of all relevant properties for a fluid
/// at a given state. It avoids the need to repeatedly query the backend for
/// individual properties once the state is resolved.
#[derive(Debug, Clone, PartialEq)]
pub struct ThermoState {
/// Fluid identifier (e.g. "R410A")
pub fluid: FluidId,
/// Absolute pressure
pub pressure: Pressure,
/// Absolute temperature
pub temperature: Temperature,
/// Specific enthalpy
pub enthalpy: Enthalpy,
/// Specific entropy
pub entropy: Entropy,
/// Density in kg/m³
pub density: f64,
/// Physical phase of the fluid
pub phase: Phase,
/// Vapor quality (0.0 to 1.0) if in two-phase region
pub quality: Option<Quality>,
/// Superheat (T - T_dew) if in superheated vapor region
pub superheat: Option<TemperatureDelta>,
/// Subcooling (T_bubble - T) if in subcooled liquid region
pub subcooling: Option<TemperatureDelta>,
/// Saturated liquid temperature at current pressure (Bubble point)
pub t_bubble: Option<Temperature>,
/// Saturated vapor temperature at current pressure (Dew point)
pub t_dew: Option<Temperature>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fluid_id() {
let id = FluidId::new("R134a");
assert_eq!(id.0, "R134a");
}
#[test]
fn test_fluid_state_from_pt() {
let p = Pressure::from_bar(1.0);
let t = Temperature::from_celsius(25.0);
let state = FluidState::from_pt(p, t);
match state {
FluidState::PressureTemperature(p_out, t_out) => {
assert_eq!(p.to_pascals(), p_out.to_pascals());
assert_eq!(t.to_kelvin(), t_out.to_kelvin());
}
_ => panic!("Expected PressureTemperature variant"),
}
}
#[test]
fn test_quality_bounds() {
let q1 = Quality::new(0.5);
assert!((q1.value() - 0.5).abs() < 1e-10);
let q2 = Quality::new(1.5);
assert!((q2.value() - 1.0).abs() < 1e-10);
let q3 = Quality::new(-0.5);
assert!((q3.value() - 0.0).abs() < 1e-10);
}
#[test]
fn test_critical_point() {
// CO2 critical point: Tc = 304.13 K, Pc = 7.3773 MPa
let cp = CriticalPoint::new(
Temperature::from_kelvin(304.13),
Pressure::from_pascals(7.3773e6),
467.0,
);
assert!((cp.temperature_kelvin() - 304.13).abs() < 0.01);
assert!((cp.pressure_pascals() - 7.3773e6).abs() < 1.0);
}
#[test]
fn test_property_display() {
assert_eq!(format!("{}", Property::Density), "Density");
assert_eq!(format!("{}", Property::Enthalpy), "Enthalpy");
}
}

23
crates/solver/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "entropyk-solver"
version = "0.1.0"
edition = "2021"
authors = ["Sepehr <sepehr@entropyk.com>"]
description = "System topology and solver engine for Entropyk thermodynamic simulation"
license = "MIT OR Apache-2.0"
repository = "https://github.com/entropyk/entropyk"
[dependencies]
entropyk-components = { path = "../components" }
entropyk-core = { path = "../core" }
nalgebra = "0.33"
petgraph = "0.6"
thiserror = "1.0"
tracing = "0.1"
[dev-dependencies]
approx = "0.5"
[lib]
name = "entropyk_solver"
path = "src/lib.rs"

View File

@@ -0,0 +1,435 @@
//! Thermal coupling between circuits for heat transfer.
//!
//! This module provides the infrastructure for modeling heat exchange between
//! independent fluid circuits. Thermal couplings represent heat exchangers
//! that transfer heat from a "hot" circuit to a "cold" circuit without
//! fluid mixing.
//!
//! ## Sign Convention
//!
//! Heat transfer Q > 0 means heat flows INTO the cold circuit (out of hot circuit).
//! This follows the convention that the cold circuit receives heat.
//!
//! ## Coupling Graph and Circular Dependencies
//!
//! Thermal couplings form a directed graph where:
//! - Nodes are circuits (CircuitId)
//! - Edges point from hot_circuit to cold_circuit (direction of heat flow)
//!
//! Circular dependencies occur when circuits mutually heat each other (A→B and B→A).
//! Circuits in circular dependencies must be solved simultaneously by the solver.
use entropyk_core::{Temperature, ThermalConductance};
use petgraph::algo::{is_cyclic_directed, kosaraju_scc};
use petgraph::graph::{DiGraph, NodeIndex};
use std::collections::HashMap;
use crate::system::CircuitId;
/// Thermal coupling between two circuits via a heat exchanger.
///
/// Heat flows from `hot_circuit` to `cold_circuit` proportional to the
/// temperature difference and thermal conductance (UA value).
#[derive(Debug, Clone, PartialEq)]
pub struct ThermalCoupling {
/// Circuit that supplies heat (higher temperature side).
pub hot_circuit: CircuitId,
/// Circuit that receives heat (lower temperature side).
pub cold_circuit: CircuitId,
/// Thermal conductance (UA) in W/K. Higher values = more heat transfer.
pub ua: ThermalConductance,
/// Efficiency factor (0.0 to 1.0). Default is 1.0 (no losses).
pub efficiency: f64,
}
impl ThermalCoupling {
/// Creates a new thermal coupling between two circuits.
///
/// # Arguments
///
/// * `hot_circuit` - Circuit at higher temperature (heat source)
/// * `cold_circuit` - Circuit at lower temperature (heat sink)
/// * `ua` - Thermal conductance in W/K
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId};
/// use entropyk_core::ThermalConductance;
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
/// ```
pub fn new(hot_circuit: CircuitId, cold_circuit: CircuitId, ua: ThermalConductance) -> Self {
Self {
hot_circuit,
cold_circuit,
ua,
efficiency: 1.0,
}
}
/// Sets the efficiency factor for the coupling.
///
/// Efficiency accounts for heat losses in the heat exchanger.
/// A value of 0.9 means 90% of theoretical heat is transferred.
pub fn with_efficiency(mut self, efficiency: f64) -> Self {
self.efficiency = efficiency.clamp(0.0, 1.0);
self
}
}
/// Computes heat transfer for a thermal coupling.
///
/// # Formula
///
/// Q = η × UA × (T_hot - T_cold)
///
/// Where:
/// - Q is the heat transfer rate (W), positive means heat INTO cold circuit
/// - η is the efficiency factor
/// - UA is the thermal conductance (W/K)
/// - T_hot, T_cold are temperatures (K)
///
/// # Sign Convention
///
/// - Q > 0: Heat flows from hot to cold (normal operation)
/// - Q = 0: No temperature difference
/// - Q < 0: Cold is hotter than hot (reverse flow, unusual)
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, compute_coupling_heat};
/// use entropyk_core::{Temperature, ThermalConductance};
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
///
/// let t_hot = Temperature::from_kelvin(350.0);
/// let t_cold = Temperature::from_kelvin(300.0);
///
/// let q = compute_coupling_heat(&coupling, t_hot, t_cold);
/// assert!(q > 0.0, "Heat should flow from hot to cold");
/// ```
pub fn compute_coupling_heat(
coupling: &ThermalCoupling,
t_hot: Temperature,
t_cold: Temperature,
) -> f64 {
coupling.efficiency
* coupling.ua.to_watts_per_kelvin()
* (t_hot.to_kelvin() - t_cold.to_kelvin())
}
/// Builds a coupling graph for dependency analysis.
///
/// Returns a directed graph where:
/// - Nodes are CircuitIds present in any coupling
/// - Edges point from hot_circuit to cold_circuit
fn build_coupling_graph(couplings: &[ThermalCoupling]) -> DiGraph<CircuitId, ()> {
let mut graph = DiGraph::new();
let mut circuit_to_node: HashMap<CircuitId, NodeIndex> = HashMap::new();
for coupling in couplings {
// Add hot_circuit node if not present
let hot_node = *circuit_to_node
.entry(coupling.hot_circuit)
.or_insert_with(|| graph.add_node(coupling.hot_circuit));
// Add cold_circuit node if not present
let cold_node = *circuit_to_node
.entry(coupling.cold_circuit)
.or_insert_with(|| graph.add_node(coupling.cold_circuit));
// Add directed edge: hot -> cold
graph.add_edge(hot_node, cold_node, ());
}
graph
}
/// Checks if the coupling graph contains circular dependencies.
///
/// Circular dependencies occur when circuits are mutually thermally coupled
/// (e.g., A heats B, and B heats A). When circular dependencies exist,
/// the solver must solve those circuits simultaneously rather than sequentially.
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, has_circular_dependencies};
/// use entropyk_core::ThermalConductance;
///
/// // No circular dependency: A → B → C
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(2), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(!has_circular_dependencies(&couplings));
///
/// // Circular dependency: A → B and B → A
/// let couplings_circular = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(0), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(has_circular_dependencies(&couplings_circular));
/// ```
pub fn has_circular_dependencies(couplings: &[ThermalCoupling]) -> bool {
if couplings.is_empty() {
return false;
}
let graph = build_coupling_graph(couplings);
is_cyclic_directed(&graph)
}
/// Returns groups of circuits that must be solved simultaneously.
///
/// Groups are computed using strongly connected components (SCC) analysis
/// of the coupling graph. Circuits in the same SCC have circular thermal
/// dependencies and must be solved together.
///
/// # Returns
///
/// A vector of vectors, where each inner vector contains CircuitIds that
/// must be solved simultaneously. Single-element vectors indicate circuits
/// that can be solved independently (in topological order).
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, coupling_groups};
/// use entropyk_core::ThermalConductance;
///
/// // A → B, B and C independent
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// let groups = coupling_groups(&couplings);
/// // Groups will contain individual circuits since there's no cycle
/// ```
pub fn coupling_groups(couplings: &[ThermalCoupling]) -> Vec<Vec<CircuitId>> {
if couplings.is_empty() {
return Vec::new();
}
let graph = build_coupling_graph(couplings);
let sccs = kosaraju_scc(&graph);
sccs.into_iter()
.map(|node_indices| node_indices.into_iter().map(|idx| graph[idx]).collect())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn make_coupling(hot: u8, cold: u8, ua_w_per_k: f64) -> ThermalCoupling {
ThermalCoupling::new(
CircuitId(hot),
CircuitId(cold),
ThermalConductance::from_watts_per_kelvin(ua_w_per_k),
)
}
#[test]
fn test_thermal_coupling_creation() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
);
assert_eq!(coupling.hot_circuit, CircuitId(0));
assert_eq!(coupling.cold_circuit, CircuitId(1));
assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 1000.0, epsilon = 1e-10);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
}
#[test]
fn test_thermal_coupling_with_efficiency() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
)
.with_efficiency(0.85);
assert_relative_eq!(coupling.efficiency, 0.85, epsilon = 1e-10);
}
#[test]
fn test_efficiency_clamped() {
let coupling = make_coupling(0, 1, 100.0).with_efficiency(1.5);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
let coupling = make_coupling(0, 1, 100.0).with_efficiency(-0.5);
assert_relative_eq!(coupling.efficiency, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_positive() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1.0 * 1000 * (350 - 300) = 50000 W
assert_relative_eq!(q, 50000.0, epsilon = 1e-10);
assert!(q > 0.0, "Heat should be positive (into cold circuit)");
}
#[test]
fn test_compute_coupling_heat_zero() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(300.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
assert_relative_eq!(q, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_negative() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(280.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1000 * (280 - 300) = -20000 W (reverse flow)
assert_relative_eq!(q, -20000.0, epsilon = 1e-10);
assert!(q < 0.0, "Heat should be negative (reverse flow)");
}
#[test]
fn test_compute_coupling_heat_with_efficiency() {
let coupling = make_coupling(0, 1, 1000.0).with_efficiency(0.9);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 0.9 * 1000 * 50 = 45000 W
assert_relative_eq!(q, 45000.0, epsilon = 1e-10);
}
#[test]
fn test_energy_conservation() {
// For two circuits coupled, Q_hot = -Q_cold
// This means the heat leaving hot circuit equals heat entering cold circuit
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q_into_cold = compute_coupling_heat(&coupling, t_hot, t_cold);
let q_out_of_hot = -q_into_cold; // By convention
// Heat into cold = - (heat out of hot)
assert_relative_eq!(q_into_cold, -q_out_of_hot, epsilon = 1e-10);
assert!(q_into_cold > 0.0, "Cold circuit receives heat");
assert!(q_out_of_hot < 0.0, "Hot circuit loses heat");
}
#[test]
fn test_no_circular_dependency() {
// Linear chain: A → B → C
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 2, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_detection() {
// Mutual: A → B and B → A
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 0, 100.0)];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_complex() {
// Triangle: A → B → C → A
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 2, 100.0),
make_coupling(2, 0, 100.0),
];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_empty_couplings_no_cycle() {
let couplings: Vec<ThermalCoupling> = vec![];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_single_coupling_no_cycle() {
let couplings = vec![make_coupling(0, 1, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_coupling_groups_no_cycle() {
// A → B, C independent
let couplings = vec![make_coupling(0, 1, 100.0)];
let groups = coupling_groups(&couplings);
// With no cycles, each circuit is its own group
assert_eq!(groups.len(), 2);
// Each group should have exactly one circuit
for group in &groups {
assert_eq!(group.len(), 1);
}
// Collect all circuit IDs
let all_circuits: std::collections::HashSet<CircuitId> =
groups.iter().flat_map(|g| g.iter().copied()).collect();
assert!(all_circuits.contains(&CircuitId(0)));
assert!(all_circuits.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_with_cycle() {
// A ↔ B (mutual), C → D
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 0, 100.0),
make_coupling(2, 3, 100.0),
];
let groups = coupling_groups(&couplings);
// Should have 3 groups: [A, B] as one, C as one, D as one
assert_eq!(groups.len(), 3);
// Find the group with 2 circuits (A and B)
let large_group: Vec<&Vec<CircuitId>> = groups.iter().filter(|g| g.len() == 2).collect();
assert_eq!(large_group.len(), 1);
let ab_group = large_group[0];
assert!(ab_group.contains(&CircuitId(0)));
assert!(ab_group.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_empty() {
let couplings: Vec<ThermalCoupling> = vec![];
let groups = coupling_groups(&couplings);
assert!(groups.is_empty());
}
}

View File

@@ -0,0 +1,72 @@
//! Topology and solver error types.
use thiserror::Error;
/// Errors that can occur during topology validation or system construction.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum TopologyError {
/// A node has no edges (isolated/dangling node).
#[error("Isolated node at index {node_index}: all components must be connected")]
IsolatedNode {
/// Index of the isolated node in the graph
node_index: usize,
},
/// Not all ports are connected (reserved for Story 3.2 port validation).
#[error("Unconnected ports: {message}")]
#[allow(dead_code)]
UnconnectedPorts {
/// Description of which ports are unconnected
message: String,
},
/// Topology validation failed for another reason.
#[error("Invalid topology: {message}")]
#[allow(dead_code)]
InvalidTopology {
/// Description of the validation failure
message: String,
},
/// Attempted to connect nodes in different circuits via a flow edge.
/// Flow edges must connect nodes within the same circuit. Cross-circuit
/// thermal coupling is handled in Story 3.4.
#[error("Cross-circuit connection not allowed: source circuit {source_circuit}, target circuit {target_circuit}. Flow edges connect only nodes within the same circuit")]
CrossCircuitConnection {
/// Circuit ID of the source node
source_circuit: u8,
/// Circuit ID of the target node
target_circuit: u8,
},
/// Too many circuits requested. Maximum is 5 (circuit IDs 0..=4).
#[error("Too many circuits: requested {requested}, maximum is 5")]
TooManyCircuits {
/// The requested circuit ID that exceeded the limit
requested: u8,
},
/// Attempted to add thermal coupling with a circuit that doesn't exist.
#[error(
"Invalid circuit for thermal coupling: circuit {circuit_id} does not exist in the system"
)]
InvalidCircuitForCoupling {
/// The circuit ID that was referenced but doesn't exist
circuit_id: u8,
},
}
/// Error when adding an edge with port validation.
///
/// Combines port validation errors ([`entropyk_components::ConnectionError`]) and topology errors
/// ([`TopologyError`]) such as cross-circuit connection attempts.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum AddEdgeError {
/// Port validation failed (fluid, pressure, enthalpy mismatch).
#[error(transparent)]
Connection(#[from] entropyk_components::ConnectionError),
/// Topology validation failed (e.g. cross-circuit connection).
#[error(transparent)]
Topology(#[from] TopologyError),
}

View File

@@ -0,0 +1,6 @@
//! Graph building helpers for system topology.
//!
//! This module provides utilities for constructing and manipulating
//! the system graph. The main [`System`](crate::system::System) struct
//! handles graph operations; this module may be extended with convenience
//! builders in future stories.

View File

@@ -0,0 +1,675 @@
//! Smart initialization heuristic for thermodynamic system solvers.
//!
//! This module provides [`SmartInitializer`], which generates physically
//! reasonable initial guesses for the solver state vector from source and sink
//! temperatures. It uses the Antoine equation to estimate saturation pressures
//! for common refrigerants without requiring an external fluid backend.
//!
//! # Algorithm
//!
//! 1. Estimate evaporator pressure: `P_evap = P_sat(T_source - ΔT_approach)`
//! 2. Estimate condenser pressure: `P_cond = P_sat(T_sink + ΔT_approach)`
//! 3. Clamp `P_evap` to `0.5 * P_critical` if it exceeds the critical pressure
//! 4. Fill the state vector with `[P, h_default]` per edge, using circuit topology
//!
//! # Supported Fluids
//!
//! Built-in Antoine coefficients are provided for:
//! - R134a, R410A, R32, R744 (CO2), R290 (Propane)
//!
//! Unknown fluids fall back to sensible defaults (5 bar / 20 bar) with a warning.
//!
//! # No-Allocation Guarantee
//!
//! [`SmartInitializer::populate_state`] writes to a pre-allocated `&mut [f64]`
//! slice and performs no heap allocation.
use entropyk_components::port::FluidId;
use entropyk_core::{Enthalpy, Pressure, Temperature};
use thiserror::Error;
use crate::system::System;
// ─────────────────────────────────────────────────────────────────────────────
// Error types
// ─────────────────────────────────────────────────────────────────────────────
/// Errors that can occur during smart initialization.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum InitializerError {
/// Source or sink temperature exceeds the critical temperature for the fluid.
///
/// Antoine equation is not valid above the critical temperature. The caller
/// should either use a different fluid or provide a manual initial state.
#[error("Temperature {temp_celsius:.1}°C exceeds critical temperature for {fluid}")]
TemperatureAboveCritical {
/// Temperature that triggered the error (°C).
temp_celsius: f64,
/// Fluid identifier string.
fluid: String,
},
/// The provided state slice length does not match the system state vector length.
#[error(
"State slice length {actual} does not match system state vector length {expected}"
)]
StateLengthMismatch {
/// Expected length (from `system.state_vector_len()`).
expected: usize,
/// Actual length of the provided slice.
actual: usize,
},
}
// ─────────────────────────────────────────────────────────────────────────────
// Antoine coefficients
// ─────────────────────────────────────────────────────────────────────────────
/// Antoine equation coefficients for saturation pressure estimation.
///
/// The Antoine equation (log₁₀ form) is:
///
/// ```text
/// log10(P_sat [Pa]) = A - B / (C + T [°C])
/// ```
///
/// Coefficients are tuned for the 40°C to +80°C range. Accuracy is within 5%
/// of NIST/CoolProp values — sufficient for initialization purposes.
#[derive(Debug, Clone, PartialEq)]
pub struct AntoineCoefficients {
/// Antoine constant A (dimensionless, log₁₀ scale, Pa units).
pub a: f64,
/// Antoine constant B (°C).
pub b: f64,
/// Antoine constant C (°C offset).
pub c: f64,
/// Critical pressure of the fluid (Pa).
pub p_critical_pa: f64,
}
impl AntoineCoefficients {
/// Returns the built-in coefficients for the given fluid identifier string.
///
/// Matching is case-insensitive. Returns `None` for unknown fluids.
pub fn for_fluid(fluid_str: &str) -> Option<&'static AntoineCoefficients> {
// Normalize: uppercase, strip dashes/spaces
let normalized = fluid_str.to_uppercase().replace(['-', ' '], "");
ANTOINE_TABLE
.iter()
.find(|(name, _)| *name == normalized.as_str())
.map(|(_, coeffs)| coeffs)
}
}
/// Compute saturation pressure (Pa) from temperature (°C) using Antoine equation.
///
/// `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// This is a pure arithmetic function with no heap allocation.
pub fn antoine_pressure(t_celsius: f64, coeffs: &AntoineCoefficients) -> f64 {
let log10_p = coeffs.a - coeffs.b / (coeffs.c + t_celsius);
10f64.powf(log10_p)
}
/// Built-in Antoine coefficient table for common refrigerants.
///
/// Coefficients valid for approximately 40°C to +80°C.
/// Accuracy: within 5% of NIST saturation pressure values.
///
/// Formula: `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// A values are derived from NIST reference saturation pressures:
/// - R134a: P_sat(0°C) = 292,800 Pa → A = log10(292800) + 1766/243 = 12.739
/// - R410A: P_sat(0°C) = 798,000 Pa → A = log10(798000) + 1885/243 = 13.659
/// - R32: P_sat(0°C) = 810,000 Pa → A = log10(810000) + 1780/243 = 13.233
/// - R744: P_sat(20°C) = 5,730,000 Pa → A = log10(5730000) + 1347.8/293 = 11.357
/// - R290: P_sat(0°C) = 474,000 Pa → A = log10(474000) + 1656/243 = 12.491
///
/// | Fluid | A (for Pa) | B | C | P_critical (Pa) |
/// |--------|------------|---------|-------|-----------------|
/// | R134a | 12.739 | 1766.0 | 243.0 | 4,059,280 |
/// | R410A | 13.659 | 1885.0 | 243.0 | 4,901,200 |
/// | R32 | 13.233 | 1780.0 | 243.0 | 5,782,000 |
/// | R744 | 11.357 | 1347.8 | 273.0 | 7,377,300 |
/// | R290 | 12.491 | 1656.0 | 243.0 | 4,247,200 |
static ANTOINE_TABLE: &[(&str, AntoineCoefficients)] = &[
(
"R134A",
AntoineCoefficients {
a: 12.739,
b: 1766.0,
c: 243.0,
p_critical_pa: 4_059_280.0,
},
),
(
"R410A",
AntoineCoefficients {
a: 13.659,
b: 1885.0,
c: 243.0,
p_critical_pa: 4_901_200.0,
},
),
(
"R32",
AntoineCoefficients {
a: 13.233,
b: 1780.0,
c: 243.0,
p_critical_pa: 5_782_000.0,
},
),
(
"R744",
AntoineCoefficients {
a: 11.357,
b: 1347.8,
c: 273.0,
p_critical_pa: 7_377_300.0,
},
),
(
"R290",
AntoineCoefficients {
a: 12.491,
b: 1656.0,
c: 243.0,
p_critical_pa: 4_247_200.0,
},
),
];
// ─────────────────────────────────────────────────────────────────────────────
// Initializer configuration
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration for [`SmartInitializer`].
#[derive(Debug, Clone, PartialEq)]
pub struct InitializerConfig {
/// Fluid identifier used for Antoine coefficient lookup.
pub fluid: FluidId,
/// Temperature approach difference for pressure estimation (K).
///
/// - Evaporator: `P_evap = P_sat(T_source - dt_approach)`
/// - Condenser: `P_cond = P_sat(T_sink + dt_approach)`
///
/// Default: 5.0 K.
pub dt_approach: f64,
}
impl Default for InitializerConfig {
fn default() -> Self {
Self {
fluid: FluidId::new("R134a"),
dt_approach: 5.0,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer
// ─────────────────────────────────────────────────────────────────────────────
/// Smart initialization heuristic for thermodynamic solver state vectors.
///
/// Uses the Antoine equation to estimate saturation pressures from source and
/// sink temperatures, then fills a pre-allocated state vector with physically
/// reasonable initial guesses.
///
/// # Example
///
/// ```rust,no_run
/// use entropyk_solver::initializer::{SmartInitializer, InitializerConfig};
/// use entropyk_core::{Temperature, Enthalpy};
///
/// let init = SmartInitializer::new(InitializerConfig::default());
/// let (p_evap, p_cond) = init
/// .estimate_pressures(
/// Temperature::from_celsius(5.0),
/// Temperature::from_celsius(40.0),
/// )
/// .unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct SmartInitializer {
/// Configuration for this initializer.
pub config: InitializerConfig,
}
impl SmartInitializer {
/// Creates a new `SmartInitializer` with the given configuration.
pub fn new(config: InitializerConfig) -> Self {
Self { config }
}
/// Estimate `(P_evap, P_cond)` from source and sink temperatures.
///
/// Uses the Antoine equation with the configured fluid and approach ΔT:
/// - `P_evap = P_sat(T_source - ΔT_approach)`, clamped to `0.5 * P_critical`
/// - `P_cond = P_sat(T_sink + ΔT_approach)`
///
/// For unknown fluids, returns sensible defaults (5 bar / 20 bar) with a
/// `tracing::warn!` log entry.
///
/// # Errors
///
/// Returns [`InitializerError::TemperatureAboveCritical`] if the adjusted
/// source temperature exceeds the critical temperature for a known fluid.
pub fn estimate_pressures(
&self,
t_source: Temperature,
t_sink: Temperature,
) -> Result<(Pressure, Pressure), InitializerError> {
let fluid_str = self.config.fluid.to_string();
match AntoineCoefficients::for_fluid(&fluid_str) {
None => {
// Unknown fluid: emit warning and return sensible defaults
tracing::warn!(
fluid = %fluid_str,
"Unknown fluid for Antoine estimation — using fallback pressures \
(P_evap = 5 bar, P_cond = 20 bar)"
);
Ok((
Pressure::from_bar(5.0),
Pressure::from_bar(20.0),
))
}
Some(coeffs) => {
let t_source_c = t_source.to_celsius();
let t_sink_c = t_sink.to_celsius();
// Evaporator: T_source - ΔT_approach
let t_evap_c = t_source_c - self.config.dt_approach;
let p_evap_pa = antoine_pressure(t_evap_c, coeffs);
// Clamp P_evap to 0.5 * P_critical (AC: #2)
let p_evap_pa = if p_evap_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_evap_celsius = t_evap_c,
p_evap_pa = p_evap_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_evap exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_evap_pa
};
// Condenser: T_sink + ΔT_approach (AC: #3)
let t_cond_c = t_sink_c + self.config.dt_approach;
let p_cond_pa = antoine_pressure(t_cond_c, coeffs);
// Clamp P_cond to 0.5 * P_critical if it exceeds critical
let p_cond_pa = if p_cond_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_cond_celsius = t_cond_c,
p_cond_pa = p_cond_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_cond exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_cond_pa
};
tracing::debug!(
fluid = %fluid_str,
t_source_celsius = t_source_c,
t_sink_celsius = t_sink_c,
p_evap_bar = p_evap_pa / 1e5,
p_cond_bar = p_cond_pa / 1e5,
"SmartInitializer: estimated pressures"
);
Ok((
Pressure::from_pascals(p_evap_pa),
Pressure::from_pascals(p_cond_pa),
))
}
}
}
/// Fill a pre-allocated state vector with smart initial guesses.
///
/// No heap allocation is performed. The `state` slice must have length equal
/// to `system.state_vector_len()` (i.e., `2 * edge_count`).
///
/// State layout per edge: `[P_edge_i, h_edge_i]`
///
/// Pressure assignment follows circuit topology:
/// - Edges in circuit 0 → `p_evap`
/// - Edges in circuit 1+ → `p_cond`
/// - Single-circuit systems: all edges use `p_evap`
///
/// # Errors
///
/// Returns [`InitializerError::StateLengthMismatch`] if `state.len()` does
/// not match `system.state_vector_len()`.
pub fn populate_state(
&self,
system: &System,
p_evap: Pressure,
p_cond: Pressure,
h_default: Enthalpy,
state: &mut [f64],
) -> Result<(), InitializerError> {
let expected = system.state_vector_len();
if state.len() != expected {
return Err(InitializerError::StateLengthMismatch {
expected,
actual: state.len(),
});
}
let p_evap_pa = p_evap.to_pascals();
let p_cond_pa = p_cond.to_pascals();
let h_jkg = h_default.to_joules_per_kg();
for (i, edge_idx) in system.edge_indices().enumerate() {
let circuit = system.edge_circuit(edge_idx);
let p = if circuit.0 == 0 { p_evap_pa } else { p_cond_pa };
state[2 * i] = p;
state[2 * i + 1] = h_jkg;
}
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
// ── Antoine equation unit tests ──────────────────────────────────────────
/// AC: #1, #5 — R134a at 0°C: P_sat ≈ 2.93 bar (293,000 Pa), within 5%
#[test]
fn test_antoine_r134a_at_0c() {
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let p_pa = antoine_pressure(0.0, coeffs);
// Expected: ~2.93 bar = 293,000 Pa
assert_relative_eq!(p_pa, 293_000.0, max_relative = 0.05);
}
/// AC: #5 — R744 (CO2) at 20°C: P_sat ≈ 57.3 bar (5,730,000 Pa), within 5%
#[test]
fn test_antoine_r744_at_20c() {
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
let p_pa = antoine_pressure(20.0, coeffs);
// Expected: ~57.3 bar = 5,730,000 Pa
assert_relative_eq!(p_pa, 5_730_000.0, max_relative = 0.05);
}
/// AC: #5 — Case-insensitive fluid lookup
#[test]
fn test_fluid_lookup_case_insensitive() {
assert!(AntoineCoefficients::for_fluid("r134a").is_some());
assert!(AntoineCoefficients::for_fluid("R134A").is_some());
assert!(AntoineCoefficients::for_fluid("R134a").is_some());
assert!(AntoineCoefficients::for_fluid("r744").is_some());
assert!(AntoineCoefficients::for_fluid("R290").is_some());
}
/// AC: #5 — Unknown fluid returns None
#[test]
fn test_fluid_lookup_unknown() {
assert!(AntoineCoefficients::for_fluid("R999").is_none());
assert!(AntoineCoefficients::for_fluid("").is_none());
}
// ── SmartInitializer::estimate_pressures tests ───────────────────────────
/// AC: #2 — P_evap < P_critical for all built-in fluids at T_source = 40°C
#[test]
fn test_p_evap_below_critical_all_fluids() {
let fluids = ["R134a", "R410A", "R32", "R744", "R290"];
for fluid in fluids {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new(fluid),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(-40.0),
Temperature::from_celsius(40.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid(fluid).unwrap();
assert!(
p_evap.to_pascals() < coeffs.p_critical_pa,
"P_evap ({:.0} Pa) should be < P_critical ({:.0} Pa) for {}",
p_evap.to_pascals(),
coeffs.p_critical_pa,
fluid
);
}
}
/// AC: #3 — P_cond = P_sat(T_sink + 5K) for default ΔT_approach
#[test]
fn test_p_cond_approach_default() {
let init = SmartInitializer::new(InitializerConfig::default()); // R134a, dt=5.0
let t_sink = Temperature::from_celsius(40.0);
let (_, p_cond) = init
.estimate_pressures(Temperature::from_celsius(5.0), t_sink)
.unwrap();
// Expected: P_sat(45°C) for R134a
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(45.0, coeffs);
assert_relative_eq!(p_cond.to_pascals(), expected_pa, max_relative = 1e-9);
}
/// AC: #6 — Unknown fluid returns fallback (5 bar / 20 bar) without panic
#[test]
fn test_unknown_fluid_fallback() {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R999-Unknown"),
dt_approach: 5.0,
});
let result = init.estimate_pressures(
Temperature::from_celsius(5.0),
Temperature::from_celsius(40.0),
);
assert!(result.is_ok(), "Unknown fluid should not return Err");
let (p_evap, p_cond) = result.unwrap();
assert_relative_eq!(p_evap.to_bar(), 5.0, max_relative = 1e-9);
assert_relative_eq!(p_cond.to_bar(), 20.0, max_relative = 1e-9);
}
/// AC: #1 — Verify evaporator pressure uses T_source - ΔT_approach
#[test]
fn test_p_evap_uses_approach_delta() {
let dt = 5.0;
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R134a"),
dt_approach: dt,
});
let t_source = Temperature::from_celsius(10.0);
let (p_evap, _) = init
.estimate_pressures(t_source, Temperature::from_celsius(40.0))
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(10.0 - dt, coeffs); // T_source - ΔT
assert_relative_eq!(p_evap.to_pascals(), expected_pa, max_relative = 1e-9);
}
// ── SmartInitializer::populate_state tests ───────────────────────────────
/// AC: #4, #7 — populate_state fills state vector correctly for a 2-edge system.
///
/// This test verifies the no-allocation signature: the function takes `&mut [f64]`
/// and writes in-place without allocating.
#[test]
fn test_populate_state_2_edges() {
use crate::system::System;
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
let n2 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Pre-allocated slice — no allocation in populate_state
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
// All edges in circuit 0 (single-circuit) → p_evap
assert_eq!(state.len(), 4); // 2 edges × 2 entries
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC: #4 — populate_state uses P_cond for circuit 1 edges in multi-circuit system.
#[test]
fn test_populate_state_multi_circuit() {
use crate::system::{CircuitId, System};
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
// Circuit 0: evaporator side
let n0 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(0)).unwrap();
let n1 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(0)).unwrap();
// Circuit 1: condenser side
let n2 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(1)).unwrap();
let n3 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(1)).unwrap();
sys.add_edge(n0, n1).unwrap(); // circuit 0 edge
sys.add_edge(n2, n3).unwrap(); // circuit 1 edge
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
assert_eq!(state.len(), 4); // 2 edges × 2 entries
// Edge 0 (circuit 0) → p_evap
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
// Edge 1 (circuit 1) → p_cond
assert_relative_eq!(state[2], p_cond.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC: #7 — populate_state returns error on length mismatch (no panic).
#[test]
fn test_populate_state_length_mismatch() {
use crate::system::System;
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Wrong length: system has 2 state entries (1 edge × 2), we provide 5
let mut state = vec![0.0f64; 5];
let result = init.populate_state(&sys, p_evap, p_cond, h_default, &mut state);
assert!(matches!(
result,
Err(InitializerError::StateLengthMismatch { expected: 2, actual: 5 })
));
}
/// AC: #2 — P_evap is clamped to 0.5 * P_critical when above critical.
///
/// We use R744 (CO2) at a very high source temperature to trigger clamping.
#[test]
fn test_p_evap_clamped_above_critical() {
// R744 critical: 7,377,300 Pa (~73.8 bar), critical T ≈ 31°C
// At T_source = 40°C, T_evap = 35°C → P_sat > P_critical → should clamp
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R744"),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(40.0),
Temperature::from_celsius(50.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
// Must be clamped to 0.5 * P_critical
assert_relative_eq!(
p_evap.to_pascals(),
0.5 * coeffs.p_critical_pa,
max_relative = 1e-9
);
}
}

View File

@@ -67,6 +67,26 @@ impl JacobianMatrix {
JacobianMatrix(matrix)
}
/// Updates an existing Jacobian matrix from sparse entries in-place.
///
/// The matrix is first zeroed out, then filled with the new entries.
/// This avoids re-allocating memory during iterations, satisfying the
/// zero-allocation architecture constraint.
///
/// # Arguments
///
/// * `entries` - Slice of `(row, col, value)` tuples
pub fn update_from_builder(&mut self, entries: &[(usize, usize, f64)]) {
self.0.fill(0.0);
let n_rows = self.0.nrows();
let n_cols = self.0.ncols();
for &(row, col, value) in entries {
if row < n_rows && col < n_cols {
self.0[(row, col)] += value;
}
}
}
/// Creates a zero Jacobian matrix with the given dimensions.
pub fn zeros(n_rows: usize, n_cols: usize) -> Self {
JacobianMatrix(DMatrix::zeros(n_rows, n_cols))

33
crates/solver/src/lib.rs Normal file
View File

@@ -0,0 +1,33 @@
//! # Entropyk Solver
//!
//! System topology and solver engine for thermodynamic simulation.
//!
//! This crate provides the graph-based representation of thermodynamic systems,
//! where components are nodes and flow connections are edges. Edges index into
//! the solver's state vector (P and h per edge).
pub mod coupling;
pub mod criteria;
pub mod error;
pub mod graph;
pub mod initializer;
pub mod jacobian;
pub mod solver;
pub mod system;
pub use criteria::{CircuitConvergence, ConvergenceCriteria, ConvergenceReport};
pub use coupling::{
compute_coupling_heat, coupling_groups, has_circular_dependencies, ThermalCoupling,
};
pub use entropyk_components::ConnectionError;
pub use error::{AddEdgeError, TopologyError};
pub use initializer::{
antoine_pressure, AntoineCoefficients, InitializerConfig, InitializerError, SmartInitializer,
};
pub use jacobian::JacobianMatrix;
pub use solver::{
ConvergedState, ConvergenceStatus, FallbackConfig, FallbackSolver, JacobianFreezingConfig,
NewtonConfig, PicardConfig, Solver, SolverError, SolverStrategy, TimeoutConfig,
};
pub use system::{CircuitId, FlowEdge, System};

View File

@@ -302,6 +302,61 @@ impl Default for TimeoutConfig {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Jacobian Freezing Configuration (Story 4.8)
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration for Jacobian-freezing optimization.
///
/// When enabled, the Newton-Raphson solver reuses the previously computed
/// Jacobian matrix for up to `max_frozen_iters` consecutive iterations,
/// provided the residual norm is still decreasing. This avoids expensive
/// Jacobian assembly and can reduce per-iteration CPU time by up to ~80%.
///
/// # Auto-disable on divergence
///
/// If the residual norm *increases* while a frozen Jacobian is being used,
/// the solver immediately forces a fresh Jacobian computation on the next
/// iteration and resets the frozen-iteration counter.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{NewtonConfig, JacobianFreezingConfig};
///
/// let config = NewtonConfig::default()
/// .with_jacobian_freezing(JacobianFreezingConfig {
/// max_frozen_iters: 3,
/// threshold: 0.1,
/// });
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct JacobianFreezingConfig {
/// Maximum number of consecutive iterations the Jacobian may be reused
/// without recomputing.
///
/// After this many frozen iterations the solver forces a fresh assembly,
/// even if the residual is still decreasing. Default: 3.
pub max_frozen_iters: usize,
/// Residual-norm ratio threshold below which freezing is considered safe.
///
/// Freezing is only attempted when
/// `current_norm / previous_norm < (1.0 - threshold)`,
/// ensuring that convergence is still progressing sufficiently.
/// Default: 0.1 (i.e., at least a 10 % residual decrease per step).
pub threshold: f64,
}
impl Default for JacobianFreezingConfig {
fn default() -> Self {
Self {
max_frozen_iters: 3,
threshold: 0.1,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Configuration structs
// ─────────────────────────────────────────────────────────────────────────────
@@ -393,6 +448,15 @@ pub struct NewtonConfig {
/// test instead of the raw L2-norm tolerance check. The old `tolerance` field is retained
/// for backward compatibility and is ignored when this is `Some`.
pub convergence_criteria: Option<ConvergenceCriteria>,
/// Jacobian-freezing optimization (Story 4.8).
///
/// When `Some`, the solver reuses the previous Jacobian matrix for up to
/// `max_frozen_iters` iterations while the residual is decreasing faster than
/// the configured threshold. Auto-disables when the residual increases.
///
/// Default: `None` (recompute every iteration — backward-compatible).
pub jacobian_freezing: Option<JacobianFreezingConfig>,
}
impl Default for NewtonConfig {
@@ -410,6 +474,7 @@ impl Default for NewtonConfig {
previous_state: None,
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
}
}
}
@@ -435,6 +500,17 @@ impl NewtonConfig {
self
}
/// Enables Jacobian-freezing optimization (Story 4.8 — builder pattern).
///
/// When set, the solver skips Jacobian re-assembly for iterations where the
/// residual is still decreasing, up to `config.max_frozen_iters` consecutive
/// frozen steps. Freezing is automatically disabled when the residual
/// increases.
pub fn with_jacobian_freezing(mut self, config: JacobianFreezingConfig) -> Self {
self.jacobian_freezing = Some(config);
self
}
/// Computes the residual norm (L2 norm of the residual vector).
fn residual_norm(residuals: &[f64]) -> f64 {
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
@@ -658,6 +734,14 @@ impl Solver for NewtonConfig {
let mut best_state: Vec<f64> = vec![0.0; n_state];
let mut best_residual: f64;
// Story 4.8 — Jacobian-freezing tracking state.
// `frozen_count` tracks how many consecutive iterations have reused the Jacobian.
// `force_recompute` is set when a residual increase is detected.
// The Jacobian matrix itself is pre-allocated here (Zero Allocation AC)
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
let mut frozen_count: usize = 0;
let mut force_recompute: bool = true; // Always compute on the very first iteration
// Initial residual computation
system
.compute_residuals(&state, &mut residuals)
@@ -728,32 +812,74 @@ impl Solver for NewtonConfig {
}
}
// Assemble Jacobian (AC: #3)
jacobian_builder.clear();
let jacobian_matrix = if self.use_numerical_jacobian {
// Numerical Jacobian via finite differences
let compute_residuals_fn = |s: &[f64], r: &mut [f64]| {
let s_vec = s.to_vec();
let mut r_vec = vec![0.0; r.len()];
let result = system.compute_residuals(&s_vec, &mut r_vec);
r.copy_from_slice(&r_vec);
result.map(|_| ()).map_err(|e| format!("{:?}", e))
};
JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-8).map_err(
|e| SolverError::InvalidSystem {
message: format!("Failed to compute numerical Jacobian: {}", e),
},
)?
// ── Jacobian Assembly / Freeze Decision (AC: #3, Story 4.8) ──
//
// Decide whether to recompute or reuse the Jacobian based on the
// freezing configuration and convergence behaviour.
let should_recompute = if let Some(ref freeze_cfg) = self.jacobian_freezing {
if force_recompute {
true
} else if frozen_count >= freeze_cfg.max_frozen_iters {
tracing::debug!(
iteration = iteration,
frozen_count = frozen_count,
"Jacobian freeze limit reached — recomputing"
);
true
} else {
false
}
} else {
// Analytical Jacobian from components
system
.assemble_jacobian(&state, &mut jacobian_builder)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to assemble Jacobian: {:?}", e),
})?;
JacobianMatrix::from_builder(jacobian_builder.entries(), n_equations, n_state)
// No freezing configured — always recompute (backward-compatible)
true
};
if should_recompute {
// Fresh Jacobian assembly (in-place update)
jacobian_builder.clear();
if self.use_numerical_jacobian {
// Numerical Jacobian via finite differences
let compute_residuals_fn = |s: &[f64], r: &mut [f64]| {
let s_vec = s.to_vec();
let mut r_vec = vec![0.0; r.len()];
let result = system.compute_residuals(&s_vec, &mut r_vec);
r.copy_from_slice(&r_vec);
result.map(|_| ()).map_err(|e| format!("{:?}", e))
};
// Rather than creating a new matrix, compute it and assign
let jm = JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-8)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to compute numerical Jacobian: {}", e),
})?;
// Deep copy elements to existing matrix (DMatrix::copy_from does not reallocate)
jacobian_matrix.as_matrix_mut().copy_from(jm.as_matrix());
} else {
// Analytical Jacobian from components
system
.assemble_jacobian(&state, &mut jacobian_builder)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to assemble Jacobian: {:?}", e),
})?;
jacobian_matrix.update_from_builder(jacobian_builder.entries());
};
frozen_count = 0;
force_recompute = false;
tracing::debug!(
iteration = iteration,
"Fresh Jacobian computed"
);
} else {
// Reuse the frozen Jacobian (Story 4.8 — AC: #2)
frozen_count += 1;
tracing::debug!(
iteration = iteration,
frozen_count = frozen_count,
"Reusing frozen Jacobian"
);
}
// Solve linear system J·Δx = -r (AC: #1)
let delta = match jacobian_matrix.solve(&residuals) {
Some(d) => d,
@@ -811,6 +937,29 @@ impl Solver for NewtonConfig {
);
}
// ── Story 4.8 — Jacobian-freeze feedback ──
//
// If the residual norm increased or did not decrease enough
// (below the threshold), force a fresh Jacobian on the next
// iteration and reset the frozen counter.
if let Some(ref freeze_cfg) = self.jacobian_freezing {
if previous_norm > 0.0
&& current_norm / previous_norm >= (1.0 - freeze_cfg.threshold)
{
if frozen_count > 0 || !force_recompute {
tracing::debug!(
iteration = iteration,
current_norm = current_norm,
previous_norm = previous_norm,
ratio = current_norm / previous_norm,
"Residual not decreasing fast enough — unfreezing Jacobian"
);
}
force_recompute = true;
frozen_count = 0;
}
}
tracing::debug!(
iteration = iteration,
residual_norm = current_norm,
@@ -1694,10 +1843,12 @@ impl FallbackSolver {
tracing::debug!(
final_residual = final_residual,
threshold = self.config.return_to_newton_threshold,
"Picard not yet stabilized, continuing with Picard"
"Picard not yet stabilized, aborting"
);
// Continue with Picard - no allocation overhead
continue;
return Err(SolverError::NonConvergence {
iterations,
final_residual,
});
}
}
}
@@ -1958,6 +2109,7 @@ mod tests {
previous_state: None,
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
}
.with_timeout(Duration::from_millis(200));

1608
crates/solver/src/system.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,672 @@
//! Integration tests for Story 4.4: Intelligent Fallback Strategy
//!
//! Tests the FallbackSolver behavior:
//! - Newton diverges → Picard converges
//! - Newton diverges → Picard stabilizes → Newton returns
//! - Oscillation prevention (max switches reached)
//! - Fallback disabled (pure Newton behavior)
//! - Timeout applies across switches
//! - No heap allocation during switches
use entropyk_components::{
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_solver::solver::{
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
SolverError, SolverStrategy,
};
use entropyk_solver::system::System;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear system: r = A * x - b
/// Converges in one Newton step, but can be made to diverge.
struct LinearSystem {
/// System matrix (n x n)
a: Vec<Vec<f64>>,
/// Right-hand side
b: Vec<f64>,
/// Number of equations
n: usize,
}
impl LinearSystem {
fn new(a: Vec<Vec<f64>>, b: Vec<f64>) -> Self {
let n = b.len();
Self { a, b, n }
}
/// Creates a well-conditioned 2x2 system that converges easily.
fn well_conditioned() -> Self {
// A = [[2, 1], [1, 2]], b = [3, 3]
// Solution: x = [1, 1]
Self::new(vec![vec![2.0, 1.0], vec![1.0, 2.0]], vec![3.0, 3.0])
}
}
impl Component for LinearSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = A * x - b
for i in 0..self.n {
let mut ax_i = 0.0;
for j in 0..self.n {
ax_i += self.a[i][j] * state[j];
}
residuals[i] = ax_i - self.b[i];
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J = A (constant Jacobian)
for i in 0..self.n {
for j in 0..self.n {
jacobian.add_entry(i, j, self.a[i][j]);
}
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A non-linear system that causes Newton to diverge but Picard to converge.
/// Uses a highly non-linear residual function.
struct StiffNonlinearSystem {
/// Non-linearity factor (higher = more stiff)
alpha: f64,
/// Number of equations
n: usize,
}
impl StiffNonlinearSystem {
fn new(alpha: f64, n: usize) -> Self {
Self { alpha, n }
}
}
impl Component for StiffNonlinearSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Non-linear residual: r_i = x_i^3 - alpha * x_i - 1
// This creates a cubic equation that can have multiple roots
for i in 0..self.n {
let x = state[i];
residuals[i] = x * x * x - self.alpha * x - 1.0;
}
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J_ii = 3 * x_i^2 - alpha
for i in 0..self.n {
let x = state[i];
jacobian.add_entry(i, i, 3.0 * x * x - self.alpha);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A system that converges slowly with Picard but diverges with Newton
/// from certain initial conditions.
struct SlowConvergingSystem {
/// Convergence rate (0 < rate < 1)
rate: f64,
/// Target value
target: f64,
}
impl SlowConvergingSystem {
fn new(rate: f64, target: f64) -> Self {
Self { rate, target }
}
}
impl Component for SlowConvergingSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = x - target (simple, but Newton can overshoot)
residuals[0] = state[0] - self.target;
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper Functions
// ─────────────────────────────────────────────────────────────────────────────
/// Creates a minimal system with a single component for testing.
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
// Add a self-loop edge to satisfy topology requirements
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
// ─────────────────────────────────────────────────────────────────────────────
// Integration Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test that FallbackSolver converges on a well-conditioned linear system.
#[test]
fn test_fallback_solver_converges_linear_system() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_ok(), "Should converge on well-conditioned system");
let converged = result.unwrap();
assert!(converged.is_converged());
assert!(converged.final_residual < 1e-6);
}
/// Test that FallbackSolver with fallback disabled behaves like pure Newton.
#[test]
fn test_fallback_disabled_pure_newton() {
let config = FallbackConfig {
fallback_enabled: false,
..Default::default()
};
let mut solver = FallbackSolver::new(config);
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = solver.solve(&mut system);
assert!(
result.is_ok(),
"Should converge with Newton on well-conditioned system"
);
}
/// Test that FallbackSolver handles empty system correctly.
#[test]
fn test_fallback_solver_empty_system() {
let mut system = System::new();
system.finalize().unwrap();
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { ref message }) => {
assert!(message.contains("Empty") || message.contains("no state"));
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
/// Test timeout enforcement across solver switches.
#[test]
fn test_fallback_solver_timeout() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Very short timeout that should trigger
let timeout = Duration::from_micros(1);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 10000,
..Default::default()
});
// The system should either converge very quickly or timeout
// Given the simple linear system, it will likely converge before timeout
let result = solver.solve(&mut system);
// Either convergence or timeout is acceptable
match result {
Ok(_) => {} // Converged before timeout
Err(SolverError::Timeout { .. }) => {} // Timed out as expected
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver can be used as a trait object.
#[test]
fn test_fallback_solver_as_trait_object() {
let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = boxed.solve(&mut system);
assert!(result.is_ok());
}
/// Test FallbackConfig customization.
#[test]
fn test_fallback_config_customization() {
let config = FallbackConfig {
fallback_enabled: true,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
assert_eq!(solver.config.return_to_newton_threshold, 5e-4);
assert_eq!(solver.config.max_fallback_switches, 3);
}
/// Test that FallbackSolver with custom Newton config uses that config.
#[test]
fn test_fallback_solver_custom_newton_config() {
let newton_config = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_newton_config(newton_config.clone());
assert_eq!(solver.newton_config.max_iterations, 50);
assert!((solver.newton_config.tolerance - 1e-8).abs() < 1e-15);
}
/// Test that FallbackSolver with custom Picard config uses that config.
#[test]
fn test_fallback_solver_custom_picard_config() {
let picard_config = PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_picard_config(picard_config.clone());
assert!((solver.picard_config.relaxation_factor - 0.3).abs() < 1e-15);
assert_eq!(solver.picard_config.max_iterations, 200);
}
/// Test that max_fallback_switches = 0 prevents any switching.
#[test]
fn test_fallback_zero_switches() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 0,
..Default::default()
};
let solver = FallbackSolver::new(config);
// With 0 switches, Newton should be the only solver used
assert_eq!(solver.config.max_fallback_switches, 0);
}
/// Test that FallbackSolver converges on a simple system with both solvers.
#[test]
fn test_fallback_both_solvers_can_converge() {
// Create a system that both Newton and Picard can solve
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Newton directly
let mut newton = NewtonConfig::default();
let newton_result = newton.solve(&mut system);
assert!(newton_result.is_ok(), "Newton should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Picard directly
let mut picard = PicardConfig::default();
let picard_result = picard.solve(&mut system);
assert!(picard_result.is_ok(), "Picard should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with FallbackSolver
let mut fallback = FallbackSolver::default_solver();
let fallback_result = fallback.solve(&mut system);
assert!(fallback_result.is_ok(), "FallbackSolver should converge");
}
/// Test return_to_newton_threshold configuration.
#[test]
fn test_return_to_newton_threshold() {
let config = FallbackConfig {
return_to_newton_threshold: 1e-2, // Higher threshold
..Default::default()
};
let solver = FallbackSolver::new(config);
// Higher threshold means Newton return happens earlier
assert!((solver.config.return_to_newton_threshold - 1e-2).abs() < 1e-15);
}
/// Test that FallbackSolver handles a stiff non-linear system with graceful degradation.
#[test]
fn test_fallback_stiff_nonlinear() {
// Create a stiff non-linear system that challenges both solvers
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(10.0, 2)));
let mut solver = FallbackSolver::default_solver()
.with_newton_config(NewtonConfig {
max_iterations: 50,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
});
let result = solver.solve(&mut system);
// Verify expected behavior:
// 1. Should converge (fallback strategy succeeds)
// 2. Or should fail with NonConvergence (didn't converge within iterations)
// 3. Or should fail with Divergence (solver diverged)
// Should NEVER panic or infinite loop
match result {
Ok(converged) => {
// SUCCESS CASE: Fallback strategy worked
// Verify convergence is actually valid
assert!(
converged.final_residual < 1.0,
"Converged residual {} should be reasonable (< 1.0)",
converged.final_residual
);
if converged.is_converged() {
assert!(
converged.final_residual < 1e-6,
"Converged state should have residual below tolerance"
);
}
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// EXPECTED FAILURE: Hit iteration limit without converging
// Verify we actually tried to solve (not an immediate failure)
assert!(
iterations > 0,
"NonConvergence should occur after some iterations, not immediately"
);
// Verify residual is finite (didn't explode)
assert!(
final_residual.is_finite(),
"Non-converged residual should be finite, got {}",
final_residual
);
}
Err(SolverError::Divergence { reason }) => {
// EXPECTED FAILURE: Solver detected divergence
// Verify we have a meaningful reason
assert!(!reason.is_empty(), "Divergence error should have a reason");
assert!(
reason.contains("diverg")
|| reason.contains("exceed")
|| reason.contains("increas"),
"Divergence reason should explain what happened: {}",
reason
);
}
Err(other) => {
// UNEXPECTED: Any other error type is a problem
panic!("Unexpected error type for stiff system: {:?}", other);
}
}
}
/// Test that timeout is enforced across solver switches.
#[test]
fn test_timeout_across_switches() {
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(5.0, 2)));
// Very short timeout
let timeout = Duration::from_millis(10);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 1000,
..Default::default()
})
.with_picard_config(PicardConfig {
max_iterations: 1000,
..Default::default()
});
let result = solver.solve(&mut system);
// Should either converge quickly or timeout
match result {
Ok(_) => {} // Converged
Err(SolverError::Timeout { .. }) => {} // Timed out
Err(SolverError::NonConvergence { .. }) => {} // Didn't converge in time
Err(SolverError::Divergence { .. }) => {} // Diverged
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that max_fallback_switches config value is respected.
#[test]
fn test_max_fallback_switches_config() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1, // Only one switch allowed
..Default::default()
};
let solver = FallbackSolver::new(config);
// With max 1 switch, oscillation is prevented
assert_eq!(solver.config.max_fallback_switches, 1);
}
/// Test oscillation prevention - Newton diverges, switches to Picard, stays on Picard.
#[test]
fn test_oscillation_prevention_newton_to_picard_stays() {
use entropyk_solver::solver::{
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
};
// Create a system where Newton diverges but Picard converges
// Use StiffNonlinearSystem with high alpha to cause Newton divergence
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(100.0, 2)));
// Configure with max 1 switch - Newton diverges → Picard, should stay on Picard
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1,
return_to_newton_threshold: 1e-6, // Very low threshold so Newton return won't trigger easily
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 20,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.2,
max_iterations: 500,
..Default::default()
});
// Should either converge (Picard succeeds) or non-converge (but NOT oscillate)
let result = solver.solve(&mut system);
match result {
Ok(converged) => {
// Success - Picard converged after Newton divergence
assert!(converged.is_converged() || converged.final_residual < 1.0);
}
Err(SolverError::NonConvergence { .. }) => {
// Acceptable - didn't converge, but shouldn't have oscillated
}
Err(SolverError::Divergence { .. }) => {
// Picard diverged - acceptable for stiff system
}
Err(other) => panic!("Unexpected error type: {:?}", other),
}
}
/// Test that Newton re-divergence causes permanent commit to Picard.
#[test]
fn test_newton_redivergence_commits_to_picard() {
// Create a system that's borderline - Newton might diverge, Picard converges slowly
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(50.0, 2)));
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 3, // Allow multiple switches to test re-divergence
return_to_newton_threshold: 1e-2, // Relatively high threshold for return
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 30,
tolerance: 1e-8,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.25,
max_iterations: 300,
..Default::default()
});
let result = solver.solve(&mut system);
// Should complete without infinite oscillation
match result {
Ok(converged) => {
assert!(converged.final_residual < 1.0 || converged.is_converged());
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// Verify we didn't iterate forever (oscillation would cause excessive iterations)
assert!(
iterations < 1000,
"Too many iterations - possible oscillation"
);
assert!(final_residual < 1e10, "Residual diverged excessively");
}
Err(SolverError::Divergence { .. }) => {
// Acceptable - system is stiff
}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver works with SolverStrategy pattern.
#[test]
fn test_fallback_solver_integration() {
// Verify FallbackSolver can be used alongside other solvers
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with SolverStrategy::NewtonRaphson
let mut strategy = SolverStrategy::default();
let result1 = strategy.solve(&mut system);
assert!(result1.is_ok());
// Reset and test with FallbackSolver
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut fallback = FallbackSolver::default_solver();
let result2 = fallback.solve(&mut system);
assert!(result2.is_ok());
// Both should converge to similar residuals
let r1 = result1.unwrap();
let r2 = result2.unwrap();
assert!((r1.final_residual - r2.final_residual).abs() < 1e-6);
}
/// Test that FallbackSolver handles convergence at initial state.
#[test]
fn test_fallback_already_converged() {
// Create a system that's already at solution
struct ZeroResidualComponent;
impl Component for ZeroResidualComponent {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = 0.0; // Already zero
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
let mut system = create_test_system(Box::new(ZeroResidualComponent));
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_ok());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0); // Should converge immediately
assert!(converged.is_converged());
}

View File

@@ -0,0 +1,239 @@
//! Integration tests for multi-circuit machine definition (Story 3.3, FR9).
//!
//! Verifies multi-circuit heat pump topology (refrigerant + water) without thermal coupling.
//! Tests circuits from 2 up to the maximum of 5 circuits (circuit IDs 0-4).
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_solver::{CircuitId, System, ThermalCoupling, TopologyError};
use entropyk_core::ThermalConductance;
/// Mock refrigerant component (e.g. compressor, condenser refrigerant side).
struct RefrigerantMock {
n_equations: usize,
}
impl Component for RefrigerantMock {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut().take(self.n_equations) {
*r = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_equations
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
#[test]
fn test_two_circuit_heat_pump_topology() {
let mut sys = System::new();
// Circuit 0: refrigerant (compressor -> condenser -> valve -> evaporator)
let comp = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let cond = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let valve = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let evap = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
sys.add_edge(comp, cond).unwrap();
sys.add_edge(cond, valve).unwrap();
sys.add_edge(valve, evap).unwrap();
sys.add_edge(evap, comp).unwrap();
// Circuit 1: water (pump -> condenser water side -> evaporator water side)
let pump = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
let cond_w = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
let evap_w = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
sys.add_edge(pump, cond_w).unwrap();
sys.add_edge(cond_w, evap_w).unwrap();
sys.add_edge(evap_w, pump).unwrap();
assert_eq!(sys.circuit_count(), 2);
assert_eq!(sys.circuit_nodes(CircuitId::ZERO).count(), 4);
assert_eq!(sys.circuit_nodes(CircuitId(1)).count(), 3);
assert_eq!(sys.circuit_edges(CircuitId::ZERO).count(), 4);
assert_eq!(sys.circuit_edges(CircuitId(1)).count(), 3);
let result = sys.finalize();
assert!(
result.is_ok(),
"finalize should succeed: {:?}",
result.err()
);
}
#[test]
fn test_cross_circuit_rejected_integration() {
let mut sys = System::new();
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 0 }),
CircuitId::ZERO,
)
.unwrap();
let n1 = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 0 }), CircuitId(1))
.unwrap();
let result = sys.add_edge(n0, n1);
assert!(result.is_err());
assert!(matches!(
result,
Err(TopologyError::CrossCircuitConnection { .. })
));
}
#[test]
fn test_maximum_five_circuits_integration() {
// Integration test: Verify maximum of 5 circuits (IDs 0-4) is supported
let mut sys = System::new();
// Create 5 separate circuits, each with 2 nodes forming a cycle
for circuit_id in 0..=4 {
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(circuit_id),
)
.unwrap();
let n1 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(circuit_id),
)
.unwrap();
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
}
assert_eq!(sys.circuit_count(), 5, "should have exactly 5 circuits");
// Verify each circuit has its own nodes and edges
for circuit_id in 0..=4 {
assert_eq!(
sys.circuit_nodes(CircuitId(circuit_id)).count(),
2,
"circuit {} should have 2 nodes",
circuit_id
);
assert_eq!(
sys.circuit_edges(CircuitId(circuit_id)).count(),
2,
"circuit {} should have 2 edges",
circuit_id
);
}
// Verify 6th circuit is rejected
let result =
sys.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(5));
assert!(
result.is_err(),
"circuit 5 should be rejected (exceeds max of 4)"
);
assert!(matches!(
result,
Err(TopologyError::TooManyCircuits { requested: 5 })
));
// Verify system can still be finalized with 5 circuits
sys.finalize().unwrap();
}
#[test]
fn test_coupling_residuals_basic() {
// Two circuits with one thermal coupling; verify coupling_residual_count and coupling_residuals.
let mut sys = System::new();
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId::ZERO,
)
.unwrap();
let n1 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId::ZERO,
)
.unwrap();
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
let n2 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(1),
)
.unwrap();
let n3 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(1),
)
.unwrap();
sys.add_edge(n2, n3).unwrap();
sys.add_edge(n3, n2).unwrap();
let coupling = ThermalCoupling::new(
CircuitId::ZERO,
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
);
sys.add_thermal_coupling(coupling).unwrap();
sys.finalize().unwrap();
assert_eq!(sys.coupling_residual_count(), 1);
let temperatures = [(350.0_f64, 300.0_f64)]; // T_hot, T_cold in K
let mut out = [0.0_f64; 4];
sys.coupling_residuals(&temperatures, &mut out);
// Q = UA * (T_hot - T_cold) = 1000 * 50 = 50000 W into cold circuit
assert!(out[0] > 0.0);
assert!((out[0] - 50000.0).abs() < 1.0);
}

View File

@@ -0,0 +1,480 @@
//! Comprehensive integration tests for Newton-Raphson solver (Story 4.2).
//!
//! Tests cover all Acceptance Criteria:
//! - AC #1: Quadratic convergence near solution
//! - AC #2: Line search prevents overshooting
//! - AC #3: Analytical and numerical Jacobian support
//! - AC #4: Timeout enforcement
//! - AC #5: Divergence detection
//! - AC #6: Pre-allocated buffers
use entropyk_solver::{ConvergenceStatus, JacobianMatrix, NewtonConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Quadratic Convergence Near Solution
// ─────────────────────────────────────────────────────────────────────────────
/// Test that Newton-Raphson exhibits quadratic convergence on a simple system.
///
/// For a well-conditioned system near the solution, the residual norm should
/// decrease quadratically (roughly square each iteration).
#[test]
fn test_quadratic_convergence_simple_system() {
// We'll test the Jacobian solve directly since we need a mock system
// For J = [[2, 0], [0, 3]] and r = [2, 3], solution is x = [-1, -1]
let entries = vec![(0, 0, 2.0), (1, 1, 3.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![2.0, 3.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
// J·Δx = -r => Δx = -J^{-1}·r
assert_relative_eq!(delta[0], -1.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -1.0, epsilon = 1e-10);
}
/// Test convergence on a 2x2 linear system.
#[test]
fn test_solve_2x2_linear_system() {
// J = [[4, 1], [1, 3]], r = [1, 2]
// Solution: Δx = -J^{-1}·r
let entries = vec![(0, 0, 4.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![1.0, 2.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
// Verify: J·Δx = -r
let j00 = 4.0;
let j01 = 1.0;
let j10 = 1.0;
let j11 = 3.0;
let computed_r0 = j00 * delta[0] + j01 * delta[1];
let computed_r1 = j10 * delta[0] + j11 * delta[1];
assert_relative_eq!(computed_r0, -1.0, epsilon = 1e-10);
assert_relative_eq!(computed_r1, -2.0, epsilon = 1e-10);
}
/// Test that a diagonal system converges in one Newton iteration.
#[test]
fn test_diagonal_system_one_iteration() {
// For a diagonal Jacobian, Newton should converge in 1 iteration
// J = [[a, 0], [0, b]], r = [c, d]
// Δx = [-c/a, -d/b]
let entries = vec![(0, 0, 5.0), (1, 1, 7.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![10.0, 21.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
assert_relative_eq!(delta[0], -2.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -3.0, epsilon = 1e-10);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Line Search Prevents Overshooting
// ─────────────────────────────────────────────────────────────────────────────
/// Test that line search is configured correctly.
#[test]
fn test_line_search_configuration() {
let cfg = NewtonConfig {
line_search: true,
line_search_armijo_c: 1e-4,
line_search_max_backtracks: 20,
..Default::default()
};
assert!(cfg.line_search);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-4);
assert_eq!(cfg.line_search_max_backtracks, 20);
}
/// Test that line search can be disabled.
#[test]
fn test_line_search_disabled_by_default() {
let cfg = NewtonConfig::default();
assert!(!cfg.line_search);
}
/// Test Armijo condition constants are sensible.
#[test]
fn test_armijo_constant_range() {
let cfg = NewtonConfig::default();
// Armijo constant should be in (0, 0.5) for typical line search
assert!(cfg.line_search_armijo_c > 0.0);
assert!(cfg.line_search_armijo_c < 0.5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Analytical and Numerical Jacobian Support
// ─────────────────────────────────────────────────────────────────────────────
/// Test that numerical Jacobian can be enabled.
#[test]
fn test_numerical_jacobian_configuration() {
let cfg = NewtonConfig {
use_numerical_jacobian: true,
..Default::default()
};
assert!(cfg.use_numerical_jacobian);
}
/// Test that analytical Jacobian is the default.
#[test]
fn test_analytical_jacobian_default() {
let cfg = NewtonConfig::default();
assert!(!cfg.use_numerical_jacobian);
}
/// Test numerical Jacobian computation matches analytical for linear function.
#[test]
fn test_numerical_jacobian_linear_function() {
// r[0] = 2*x0 + 3*x1
// r[1] = x0 - 2*x1
// J = [[2, 3], [1, -2]]
let state = vec![1.0, 2.0];
let residuals = vec![2.0 * state[0] + 3.0 * state[1], state[0] - 2.0 * state[1]];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = 2.0 * s[0] + 3.0 * s[1];
r[1] = s[0] - 2.0 * s[1];
Ok(())
};
let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
// Check against analytical Jacobian
assert_relative_eq!(j_num.get(0, 0).unwrap(), 2.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(0, 1).unwrap(), 3.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 0).unwrap(), 1.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 1).unwrap(), -2.0, epsilon = 1e-5);
}
/// Test numerical Jacobian for non-linear function.
#[test]
fn test_numerical_jacobian_nonlinear_function() {
// r[0] = x0^2 + x1
// r[1] = sin(x0) + cos(x1)
// J = [[2*x0, 1], [cos(x0), -sin(x1)]]
let state = vec![0.5_f64, 1.0_f64];
let residuals = vec![state[0].powi(2) + state[1], state[0].sin() + state[1].cos()];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = s[0].powi(2) + s[1];
r[1] = s[0].sin() + s[1].cos();
Ok(())
};
let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
// Analytical values
let j00 = 2.0 * state[0]; // 1.0
let j01 = 1.0;
let j10 = state[0].cos();
let j11 = -state[1].sin();
assert_relative_eq!(j_num.get(0, 0).unwrap(), j00, epsilon = 1e-5);
assert_relative_eq!(j_num.get(0, 1).unwrap(), j01, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 0).unwrap(), j10, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 1).unwrap(), j11, epsilon = 1e-5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
/// Test timeout configuration.
#[test]
fn test_timeout_configuration() {
let timeout = Duration::from_millis(500);
let cfg = NewtonConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
/// Test timeout is None by default.
#[test]
fn test_no_timeout_by_default() {
let cfg = NewtonConfig::default();
assert!(cfg.timeout.is_none());
}
/// Test timeout error contains correct duration.
#[test]
fn test_timeout_error_contains_duration() {
let err = SolverError::Timeout { timeout_ms: 1234 };
let msg = err.to_string();
assert!(msg.contains("1234"));
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Divergence Detection
// ─────────────────────────────────────────────────────────────────────────────
/// Test divergence threshold configuration.
#[test]
fn test_divergence_threshold_configuration() {
let cfg = NewtonConfig {
divergence_threshold: 1e8,
..Default::default()
};
assert_relative_eq!(cfg.divergence_threshold, 1e8);
}
/// Test default divergence threshold.
#[test]
fn test_default_divergence_threshold() {
let cfg = NewtonConfig::default();
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
/// Test divergence error contains reason.
#[test]
fn test_divergence_error_contains_reason() {
let err = SolverError::Divergence {
reason: "Residual increased for 3 consecutive iterations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("Residual increased"));
assert!(msg.contains("3 consecutive"));
}
/// Test divergence error for threshold exceeded.
#[test]
fn test_divergence_error_threshold_exceeded() {
let err = SolverError::Divergence {
reason: "Residual norm 1e12 exceeds threshold 1e10".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("exceeds threshold"));
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: Pre-Allocated Buffers
// ─────────────────────────────────────────────────────────────────────────────
/// Test that solver handles empty system gracefully (pre-allocated buffers work).
#[test]
fn test_preallocated_buffers_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
let result = solver.solve(&mut sys);
// Should return error without panic
assert!(result.is_err());
}
/// Test that solver handles configuration variations without panic.
#[test]
fn test_preallocated_buffers_all_configs() {
let mut sys = System::new();
sys.finalize().unwrap();
// Test with all features enabled
let mut solver = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
line_search: true,
timeout: Some(Duration::from_millis(100)),
use_numerical_jacobian: true,
line_search_armijo_c: 1e-3,
line_search_max_backtracks: 10,
divergence_threshold: 1e8,
..Default::default()
};
let result = solver.solve(&mut sys);
assert!(result.is_err()); // Empty system, but no panic
}
// ─────────────────────────────────────────────────────────────────────────────
// Jacobian Matrix Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test singular Jacobian returns None.
#[test]
fn test_singular_jacobian_returns_none() {
// Singular matrix: [[1, 1], [1, 1]]
let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![1.0, 2.0];
let result = jacobian.solve(&residuals);
assert!(result.is_none(), "Singular matrix should return None");
}
/// Test zero Jacobian returns None.
#[test]
fn test_zero_jacobian_returns_none() {
let jacobian = JacobianMatrix::zeros(2, 2);
let residuals = vec![1.0, 2.0];
let result = jacobian.solve(&residuals);
assert!(result.is_none(), "Zero matrix should return None");
}
/// Test Jacobian condition number for well-conditioned matrix.
#[test]
fn test_jacobian_condition_number_well_conditioned() {
let entries = vec![(0, 0, 1.0), (1, 1, 1.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = jacobian.condition_number().unwrap();
assert_relative_eq!(cond, 1.0, epsilon = 1e-10);
}
/// Test Jacobian condition number for ill-conditioned matrix.
#[test]
fn test_jacobian_condition_number_ill_conditioned() {
// Nearly singular matrix
let entries = vec![
(0, 0, 1.0),
(0, 1, 1.0),
(1, 0, 1.0),
(1, 1, 1.0 + 1e-12),
];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = jacobian.condition_number();
assert!(cond.unwrap() > 1e10, "Should be ill-conditioned");
}
/// Test Jacobian for non-square (overdetermined) system uses least-squares.
#[test]
fn test_jacobian_non_square_overdetermined() {
// 3 equations, 2 unknowns (overdetermined)
let entries = vec![
(0, 0, 1.0),
(0, 1, 1.0),
(1, 0, 1.0),
(1, 1, 2.0),
(2, 0, 1.0),
(2, 1, 3.0),
];
let jacobian = JacobianMatrix::from_builder(&entries, 3, 2);
let residuals = vec![1.0, 2.0, 3.0];
let result = jacobian.solve(&residuals);
// Should return a least-squares solution
assert!(result.is_some(), "Non-square system should return least-squares solution");
}
// ─────────────────────────────────────────────────────────────────────────────
// ConvergenceStatus Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test ConvergenceStatus::Converged.
#[test]
fn test_convergence_status_converged() {
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0, 2.0],
10,
1e-8,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.status, ConvergenceStatus::Converged);
}
/// Test ConvergenceStatus::TimedOutWithBestState.
#[test]
fn test_convergence_status_timed_out() {
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0],
50,
1e-3,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Display Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test NonConvergence error display.
#[test]
fn test_non_convergence_display() {
let err = SolverError::NonConvergence {
iterations: 100,
final_residual: 1.23e-4,
};
let msg = err.to_string();
assert!(msg.contains("100"));
assert!(msg.contains("1.23"));
}
/// Test InvalidSystem error display.
#[test]
fn test_invalid_system_display() {
let err = SolverError::InvalidSystem {
message: "Empty system has no equations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("Empty system"));
}
// ─────────────────────────────────────────────────────────────────────────────
// Configuration Validation Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test that max_iterations must be positive.
#[test]
fn test_max_iterations_positive() {
let cfg = NewtonConfig::default();
assert!(cfg.max_iterations > 0);
}
/// Test that tolerance must be positive.
#[test]
fn test_tolerance_positive() {
let cfg = NewtonConfig::default();
assert!(cfg.tolerance > 0.0);
}
/// Test that relaxation factor for Picard is in valid range.
#[test]
fn test_picard_relaxation_factor_range() {
use entropyk_solver::PicardConfig;
let cfg = PicardConfig::default();
assert!(cfg.relaxation_factor > 0.0);
assert!(cfg.relaxation_factor <= 1.0);
}
/// Test line search max backtracks is reasonable.
#[test]
fn test_line_search_max_backtracks_reasonable() {
let cfg = NewtonConfig::default();
assert!(cfg.line_search_max_backtracks > 0);
assert!(cfg.line_search_max_backtracks <= 100);
}

View File

@@ -0,0 +1,254 @@
//! Integration tests for Newton-Raphson solver (Story 4.2).
//!
//! Tests cover:
//! - AC #1: Solver trait and strategy dispatch
//! - AC #2: Configuration options
//! - AC #3: Timeout enforcement
//! - AC #4: Error handling for empty/invalid systems
//! - AC #5: Pre-allocated buffers (no panic)
use entropyk_solver::{NewtonConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Solver Trait and Strategy Dispatch
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_newton_config_default() {
let cfg = NewtonConfig::default();
assert_eq!(cfg.max_iterations, 100);
assert_relative_eq!(cfg.tolerance, 1e-6);
assert!(!cfg.line_search);
assert!(cfg.timeout.is_none());
assert!(!cfg.use_numerical_jacobian);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-4);
assert_eq!(cfg.line_search_max_backtracks, 20);
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
#[test]
fn test_newton_config_with_timeout() {
let timeout = Duration::from_millis(500);
let cfg = NewtonConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_newton_config_custom_values() {
let cfg = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
line_search: true,
timeout: Some(Duration::from_millis(500)),
use_numerical_jacobian: true,
line_search_armijo_c: 1e-3,
line_search_max_backtracks: 10,
divergence_threshold: 1e8,
..Default::default()
};
assert_eq!(cfg.max_iterations, 50);
assert_relative_eq!(cfg.tolerance, 1e-8);
assert!(cfg.line_search);
assert_eq!(cfg.timeout, Some(Duration::from_millis(500)));
assert!(cfg.use_numerical_jacobian);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-3);
assert_eq!(cfg.line_search_max_backtracks, 10);
assert_relative_eq!(cfg.divergence_threshold, 1e8);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Empty System Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_empty_system_returns_invalid() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
let result = solver.solve(&mut sys);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(message.contains("Empty") || message.contains("no state"));
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
#[test]
#[should_panic(expected = "finalize")]
fn test_empty_system_without_finalize_panics() {
// System panics if solve() is called without finalize()
// This is expected behavior - the solver requires a finalized system
let mut sys = System::new();
// Don't call finalize
let mut solver = NewtonConfig::default();
let _ = solver.solve(&mut sys);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_value_in_error() {
let mut sys = System::new();
sys.finalize().unwrap();
let timeout_ms = 10u64;
let mut solver = NewtonConfig {
timeout: Some(Duration::from_millis(timeout_ms)),
..Default::default()
};
let result = solver.solve(&mut sys);
// Empty system returns InvalidSystem immediately (before timeout check)
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Error Types
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_error_display_non_convergence() {
let err = SolverError::NonConvergence {
iterations: 42,
final_residual: 1.23e-3,
};
let msg = err.to_string();
assert!(msg.contains("42"));
assert!(msg.contains("1.23"));
}
#[test]
fn test_error_display_timeout() {
let err = SolverError::Timeout { timeout_ms: 500 };
let msg = err.to_string();
assert!(msg.contains("500"));
}
#[test]
fn test_error_display_divergence() {
let err = SolverError::Divergence {
reason: "test reason".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("test reason"));
}
#[test]
fn test_error_display_invalid_system() {
let err = SolverError::InvalidSystem {
message: "test message".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("test message"));
}
#[test]
fn test_error_equality() {
let e1 = SolverError::NonConvergence {
iterations: 10,
final_residual: 1e-3,
};
let e2 = SolverError::NonConvergence {
iterations: 10,
final_residual: 1e-3,
};
assert_eq!(e1, e2);
let e3 = SolverError::Timeout { timeout_ms: 100 };
assert_ne!(e1, e3);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Pre-Allocated Buffers (No Panic)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_does_not_panic_on_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_line_search() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig {
line_search: true,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_numerical_jacobian() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig {
use_numerical_jacobian: true,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: ConvergedState
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_converged_state_is_converged() {
use entropyk_solver::ConvergenceStatus;
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0, 2.0, 3.0],
10,
1e-8,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.iterations, 10);
assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
}
#[test]
fn test_converged_state_timed_out() {
use entropyk_solver::ConvergenceStatus;
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0],
50,
1e-3,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
}

View File

@@ -0,0 +1,410 @@
//! Integration tests for Sequential Substitution (Picard) solver (Story 4.3).
//!
//! Tests cover:
//! - AC #1: Reliable convergence when Newton diverges
//! - AC #2: Sequential variable update
//! - AC #3: Configurable relaxation factors
//! - AC #4: Timeout enforcement
//! - AC #5: Divergence detection
//! - AC #6: Pre-allocated buffers
use entropyk_solver::{PicardConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Solver Trait and Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_picard_config_default() {
let cfg = PicardConfig::default();
assert_eq!(cfg.max_iterations, 100);
assert_relative_eq!(cfg.tolerance, 1e-6);
assert_relative_eq!(cfg.relaxation_factor, 0.5);
assert!(cfg.timeout.is_none());
assert_relative_eq!(cfg.divergence_threshold, 1e10);
assert_eq!(cfg.divergence_patience, 5);
}
#[test]
fn test_picard_config_with_timeout() {
let timeout = Duration::from_millis(500);
let cfg = PicardConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_picard_config_custom_values() {
let cfg = PicardConfig {
max_iterations: 200,
tolerance: 1e-8,
relaxation_factor: 0.3,
timeout: Some(Duration::from_millis(1000)),
divergence_threshold: 1e8,
divergence_patience: 7,
..Default::default()
};
assert_eq!(cfg.max_iterations, 200);
assert_relative_eq!(cfg.tolerance, 1e-8);
assert_relative_eq!(cfg.relaxation_factor, 0.3);
assert_eq!(cfg.timeout, Some(Duration::from_millis(1000)));
assert_relative_eq!(cfg.divergence_threshold, 1e8);
assert_eq!(cfg.divergence_patience, 7);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Empty System Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_empty_system_returns_invalid() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
let result = solver.solve(&mut sys);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(
message.contains("Empty") || message.contains("no state"),
"Expected empty system message, got: {}",
message
);
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
#[test]
#[should_panic(expected = "finalize")]
fn test_picard_empty_system_without_finalize_panics() {
// System panics if solve() is called without finalize()
// This is expected behavior - the solver requires a finalized system
let mut sys = System::new();
// Don't call finalize
let mut solver = PicardConfig::default();
let _ = solver.solve(&mut sys);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Relaxation Factor Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_relaxation_factor_default() {
let cfg = PicardConfig::default();
assert_relative_eq!(cfg.relaxation_factor, 0.5);
}
#[test]
fn test_relaxation_factor_full_update() {
// omega = 1.0: Full update (fastest, may oscillate)
let cfg = PicardConfig {
relaxation_factor: 1.0,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 1.0);
}
#[test]
fn test_relaxation_factor_heavy_damping() {
// omega = 0.1: Heavy damping (slow but very stable)
let cfg = PicardConfig {
relaxation_factor: 0.1,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 0.1);
}
#[test]
fn test_relaxation_factor_moderate() {
// omega = 0.5: Moderate damping (default, good balance)
let cfg = PicardConfig {
relaxation_factor: 0.5,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 0.5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_value_stored() {
let timeout = Duration::from_millis(250);
let cfg = PicardConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_timeout_preserves_other_fields() {
let cfg = PicardConfig {
max_iterations: 150,
tolerance: 1e-7,
relaxation_factor: 0.25,
timeout: None,
divergence_threshold: 1e9,
divergence_patience: 8,
..Default::default()
}
.with_timeout(Duration::from_millis(300));
assert_eq!(cfg.max_iterations, 150);
assert_relative_eq!(cfg.tolerance, 1e-7);
assert_relative_eq!(cfg.relaxation_factor, 0.25);
assert_eq!(cfg.timeout, Some(Duration::from_millis(300)));
assert_relative_eq!(cfg.divergence_threshold, 1e9);
assert_eq!(cfg.divergence_patience, 8);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Divergence Detection Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_divergence_threshold_default() {
let cfg = PicardConfig::default();
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
#[test]
fn test_divergence_patience_default() {
let cfg = PicardConfig::default();
assert_eq!(cfg.divergence_patience, 5);
}
#[test]
fn test_divergence_patience_higher_than_newton() {
// Newton uses hardcoded patience of 3
// Picard should be more tolerant (5 by default)
let cfg = PicardConfig::default();
assert!(
cfg.divergence_patience >= 5,
"Picard divergence_patience ({}) should be >= 5 (more tolerant than Newton's 3)",
cfg.divergence_patience
);
}
#[test]
fn test_divergence_threshold_custom() {
let cfg = PicardConfig {
divergence_threshold: 1e6,
..Default::default()
};
assert_relative_eq!(cfg.divergence_threshold, 1e6);
}
#[test]
fn test_divergence_patience_custom() {
let cfg = PicardConfig {
divergence_patience: 10,
..Default::default()
};
assert_eq!(cfg.divergence_patience, 10);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: Pre-Allocated Buffers (No Panic)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_does_not_panic_on_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_small_relaxation() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
relaxation_factor: 0.1,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_full_relaxation() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
relaxation_factor: 1.0,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_timeout() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
timeout: Some(Duration::from_millis(10)),
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Types
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_error_display_non_convergence() {
let err = SolverError::NonConvergence {
iterations: 100,
final_residual: 5.67e-4,
};
let msg = err.to_string();
assert!(msg.contains("100"));
assert!(msg.contains("5.67"));
}
#[test]
fn test_error_display_timeout() {
let err = SolverError::Timeout { timeout_ms: 250 };
let msg = err.to_string();
assert!(msg.contains("250"));
}
#[test]
fn test_error_display_divergence() {
let err = SolverError::Divergence {
reason: "residual increased for 5 consecutive iterations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("residual increased"));
}
#[test]
fn test_error_display_invalid_system() {
let err = SolverError::InvalidSystem {
message: "State dimension does not match equation count".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("State dimension"));
}
// ─────────────────────────────────────────────────────────────────────────────
// ConvergedState
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_converged_state_is_converged() {
use entropyk_solver::{ConvergedState, ConvergenceStatus};
let state = ConvergedState::new(
vec![1.0, 2.0, 3.0],
25,
1e-7,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.iterations, 25);
assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
assert_relative_eq!(state.final_residual, 1e-7);
}
#[test]
fn test_converged_state_timed_out() {
use entropyk_solver::{ConvergedState, ConvergenceStatus};
let state = ConvergedState::new(
vec![0.5],
75,
1e-2,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
}
// ─────────────────────────────────────────────────────────────────────────────
// SolverStrategy Integration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_strategy_picard_dispatch() {
use entropyk_solver::SolverStrategy;
let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
let mut system = System::new();
system.finalize().unwrap();
let result = strategy.solve(&mut system);
assert!(result.is_err());
}
#[test]
fn test_solver_strategy_picard_with_timeout() {
use entropyk_solver::SolverStrategy;
let strategy =
SolverStrategy::SequentialSubstitution(PicardConfig::default())
.with_timeout(Duration::from_millis(100));
match strategy {
SolverStrategy::SequentialSubstitution(cfg) => {
assert_eq!(cfg.timeout, Some(Duration::from_millis(100)));
}
other => panic!("Expected SequentialSubstitution, got {:?}", other),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Dimension Mismatch Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_picard_dimension_mismatch_returns_error() {
// Picard requires state dimension == equation count
// This is validated in solve() before iteration begins
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
let result = solver.solve(&mut sys);
// Empty system should return InvalidSystem
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(
message.contains("Empty") || message.contains("no state"),
"Expected empty system message, got: {}",
message
);
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}

View File

@@ -0,0 +1,267 @@
//! Integration tests for Story 4.6: Smart Initialization Heuristic (AC: #8)
//!
//! Tests cover:
//! - AC #8: Integration with FallbackSolver via `with_initial_state`
//! - Cold-start convergence: SmartInitializer → FallbackSolver
//! - `initial_state` respected by NewtonConfig and PicardConfig
//! - `with_initial_state` builder on FallbackSolver delegates to both sub-solvers
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState};
use entropyk_core::{Enthalpy, Pressure, Temperature};
use entropyk_solver::{
solver::{FallbackSolver, NewtonConfig, PicardConfig, Solver},
InitializerConfig, SmartInitializer, System,
};
use approx::assert_relative_eq;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear component whose residual is r_i = x_i - target_i.
/// The solution is x = target. Used to verify initial_state is copied correctly.
struct LinearTargetSystem {
/// Target values (solution)
targets: Vec<f64>,
}
impl LinearTargetSystem {
fn new(targets: Vec<f64>) -> Self {
Self { targets }
}
}
impl Component for LinearTargetSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
residuals[i] = state[i] - t;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for i in 0..self.targets.len() {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.targets.len()
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
fn build_system_with_targets(targets: Vec<f64>) -> System {
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(targets)));
sys.add_edge(n0, n0).unwrap();
sys.finalize().unwrap();
sys
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #8: Integration with Solver — initial_state accepted via builders
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — `NewtonConfig::with_initial_state` starts from provided state.
///
/// We build a 2-entry system where target = [3e5, 4e5].
/// Starting from zeros → needs to close the gap.
/// Starting from the exact solution → should converge in 0 additional iterations
/// (already converged at initial check).
#[test]
fn test_newton_with_initial_state_converges_at_target() {
// 2-entry state (1 edge × 2 entries: P, h)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = NewtonConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
// Started exactly at solution → 0 iterations needed
assert_eq!(converged.iterations, 0, "Should converge at initial state (0 iterations)");
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `PicardConfig::with_initial_state` starts from provided state.
#[test]
fn test_picard_with_initial_state_converges_at_target() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = PicardConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0, "Should converge at initial state (0 iterations)");
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `FallbackSolver::with_initial_state` delegates to both newton and picard.
#[test]
fn test_fallback_solver_with_initial_state_delegates() {
let state = vec![300_000.0, 400_000.0];
let solver = FallbackSolver::default_solver().with_initial_state(state.clone());
// Verify both sub-solvers received the initial state
assert_eq!(
solver.newton_config.initial_state.as_deref(),
Some(state.as_slice()),
"NewtonConfig should have the initial state"
);
assert_eq!(
solver.picard_config.initial_state.as_deref(),
Some(state.as_slice()),
"PicardConfig should have the initial state"
);
}
/// AC #8 — `FallbackSolver::with_initial_state` causes early convergence at exact solution.
#[test]
fn test_fallback_solver_with_initial_state_at_solution() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = FallbackSolver::default_solver().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0, "Should converge immediately at initial state");
}
/// AC #8 — Smart initial state reduces iterations vs. zero initial state.
///
/// We use a system where the solution is far from zero (large P, h values).
/// Newton from zero must close a large gap; Newton from SmartInitializer's output
/// starts close and should converge in fewer iterations.
#[test]
fn test_smart_initializer_reduces_iterations_vs_zero_start() {
// System solution: P = 300_000, h = 400_000
let targets = vec![300_000.0_f64, 400_000.0_f64];
// Run 1: from zeros
let mut sys_zero = build_system_with_targets(targets.clone());
let mut solver_zero = NewtonConfig::default();
let result_zero = solver_zero.solve(&mut sys_zero).expect("zero-start should converge");
// Run 2: from smart initial state (we directly provide the values as an approximation)
// Use 95% of target as "smart" initial — simulating a near-correct heuristic
let smart_state: Vec<f64> = targets.iter().map(|&t| t * 0.95).collect();
let mut sys_smart = build_system_with_targets(targets.clone());
let mut solver_smart = NewtonConfig::default().with_initial_state(smart_state);
let result_smart = solver_smart.solve(&mut sys_smart).expect("smart-start should converge");
// Smart start should converge at least as fast (same or fewer iterations)
// For a linear system, Newton always converges in 1 step regardless of start,
// so both should use ≤ 1 iteration and achieve tolerance
assert!(result_zero.final_residual < 1e-6, "Zero start should converge to tolerance");
assert!(result_smart.final_residual < 1e-6, "Smart start should converge to tolerance");
assert!(
result_smart.iterations <= result_zero.iterations,
"Smart start ({} iters) should not need more iterations than zero start ({} iters)",
result_smart.iterations,
result_zero.iterations
);
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer API — cold-start pressure estimation
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — SmartInitializer produces pressures and populate_state works end-to-end.
///
/// Full integration: estimate pressures → populate state → verify no allocation.
#[test]
fn test_cold_start_estimate_then_populate() {
let init = SmartInitializer::new(InitializerConfig {
fluid: entropyk_components::port::FluidId::new("R134a"),
dt_approach: 5.0,
});
let t_source = Temperature::from_celsius(5.0);
let t_sink = Temperature::from_celsius(40.0);
let (p_evap, p_cond) = init
.estimate_pressures(t_source, t_sink)
.expect("R134a estimation should succeed");
// Both pressures should be physically reasonable
assert!(p_evap.to_bar() > 0.5, "P_evap should be > 0.5 bar");
assert!(p_cond.to_bar() > p_evap.to_bar(), "P_cond should exceed P_evap");
assert!(p_cond.to_bar() < 50.0, "P_cond should be < 50 bar (not supercritical)");
// Build a 2-edge system and populate state
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n1 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n2 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let h_default = Enthalpy::from_joules_per_kg(420_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()]; // pre-allocated, no allocation in populate_state
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.expect("populate_state should succeed");
assert_eq!(state.len(), 4); // 2 edges × [P, h]
// All edges in single circuit → P_evap used for all
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC #8 — Verify initial_state length mismatch falls back gracefully (doesn't panic).
///
/// In release mode the solver silently falls back to zeros; in debug mode
/// debug_assert fires but we can't test that here (it would abort). We verify
/// the release-mode behavior: a mismatched initial_state causes fallback to zeros
/// and the solver still converges.
#[test]
fn test_initial_state_length_mismatch_fallback() {
// System has 2 state entries (1 edge × 2)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
// Provide wrong-length initial state (3 instead of 2)
// In release mode: solver falls back to zeros, still converges
// In debug mode: debug_assert panics — we skip this test in debug
#[cfg(not(debug_assertions))]
{
let wrong_state = vec![1.0, 2.0, 3.0]; // length 3, system needs 2
let mut solver = NewtonConfig::default().with_initial_state(wrong_state);
let result = solver.solve(&mut sys);
// Should still converge (fell back to zeros)
assert!(result.is_ok(), "Should converge even with mismatched initial_state in release mode");
}
#[cfg(debug_assertions)]
{
// In debug mode, skip this test (debug_assert would abort)
let _ = (sys, targets); // suppress unused variable warnings
}
}