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