feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
@@ -7,9 +7,17 @@ description = "Core component trait definitions for Entropyk thermodynamic simul
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/entropyk/entropyk"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ffi = []
|
||||
http = []
|
||||
|
||||
[dependencies]
|
||||
# Core types will be added when core crate is created
|
||||
# entropyk-core = { path = "../core" }
|
||||
# Core types from Story 1.2
|
||||
entropyk-core = { path = "../core" }
|
||||
|
||||
# Fluid properties backend (Story 5.1 - FluidBackend integration)
|
||||
entropyk-fluids = { path = "../fluids" }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
@@ -18,8 +26,8 @@ thiserror = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing utilities
|
||||
# tokio-test = "0.4"
|
||||
# Floating-point assertions
|
||||
approx = "0.5"
|
||||
|
||||
[lib]
|
||||
name = "entropyk_components"
|
||||
|
||||
2018
crates/components/src/compressor.rs
Normal file
2018
crates/components/src/compressor.rs
Normal file
File diff suppressed because it is too large
Load Diff
1434
crates/components/src/expansion_valve.rs
Normal file
1434
crates/components/src/expansion_valve.rs
Normal file
File diff suppressed because it is too large
Load Diff
628
crates/components/src/external_model.rs
Normal file
628
crates/components/src/external_model.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
636
crates/components/src/fan.rs
Normal file
636
crates/components/src/fan.rs
Normal 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 + ½ρv²
|
||||
///
|
||||
/// # 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 = ½ρ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);
|
||||
}
|
||||
}
|
||||
249
crates/components/src/heat_exchanger/condenser.rs
Normal file
249
crates/components/src/heat_exchanger/condenser.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
195
crates/components/src/heat_exchanger/condenser_coil.rs
Normal file
195
crates/components/src/heat_exchanger/condenser_coil.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
251
crates/components/src/heat_exchanger/economizer.rs
Normal file
251
crates/components/src/heat_exchanger/economizer.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
344
crates/components/src/heat_exchanger/eps_ntu.rs
Normal file
344
crates/components/src/heat_exchanger/eps_ntu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
292
crates/components/src/heat_exchanger/evaporator.rs
Normal file
292
crates/components/src/heat_exchanger/evaporator.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
208
crates/components/src/heat_exchanger/evaporator_coil.rs
Normal file
208
crates/components/src/heat_exchanger/evaporator_coil.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
398
crates/components/src/heat_exchanger/lmtd.rs
Normal file
398
crates/components/src/heat_exchanger/lmtd.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
51
crates/components/src/heat_exchanger/mod.rs
Normal file
51
crates/components/src/heat_exchanger/mod.rs
Normal 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;
|
||||
204
crates/components/src/heat_exchanger/model.rs
Normal file
204
crates/components/src/heat_exchanger/model.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1011
crates/components/src/pipe.rs
Normal file
1011
crates/components/src/pipe.rs
Normal file
File diff suppressed because it is too large
Load Diff
702
crates/components/src/polynomials.rs
Normal file
702
crates/components/src/polynomials.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
753
crates/components/src/port.rs
Normal file
753
crates/components/src/port.rs
Normal 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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
780
crates/components/src/pump.rs
Normal file
780
crates/components/src/pump.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
940
crates/components/src/state_machine.rs
Normal file
940
crates/components/src/state_machine.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user