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

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