Files
Entropyk/crates/components/src/lib.rs

1095 lines
38 KiB
Rust

//! # Entropyk Components
//!
//! This crate provides the core component trait definitions for the Entropyk
//! thermodynamic simulation library. All thermodynamic components (compressors,
//! condensers, evaporators, etc.) implement the [`Component`] trait.
//!
//! ## Core Concept
//!
//! The [`Component`] trait defines the interface between thermodynamic components
//! and the solver engine. Each component is responsible for:
//!
//! - Computing residuals based on current system state
//! - Providing Jacobian entries for numerical solving
//! - Reporting the number of equations it contributes
//!
//! ## Object Safety
//!
//! The [`Component`] trait is designed to be **object-safe**, meaning it supports
//! dynamic dispatch via `Box<dyn Component>` or `&dyn Component`. This is essential
//! for the solver to work with collections of heterogeneous components.
//!
//! ## Example
//!
//! ```rust
//! use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
//!
//! struct MockComponent {
//! n_equations: usize,
//! }
//!
//! impl Component for MockComponent {
//! fn compute_residuals(&self, state: &StateSlice, residuals: &mut ResidualVector) -> Result<(), ComponentError> {
//! // Component-specific residual computation
//! Ok(())
//! }
//!
//! fn jacobian_entries(&self, state: &StateSlice, jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
//! // Component-specific Jacobian contributions
//! Ok(())
//! }
//!
//! fn n_equations(&self) -> usize {
//! self.n_equations
//! }
//!
//! fn get_ports(&self) -> &[ConnectedPort] {
//! &[]
//! }
//! }
//!
//! // Trait object usage
//! let component: Box<dyn Component> = Box::new(MockComponent { n_equations: 3 });
//! ```
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
pub mod air_boundary;
pub mod brine_boundary;
pub mod bypass_valve;
pub mod compressor;
pub mod curves;
pub mod drum;
pub mod expansion_valve;
pub mod external_model;
pub mod fan;
pub mod flow_junction;
pub mod free_cooling_exchanger;
pub mod heat_exchanger;
pub mod node;
pub mod params;
pub mod pipe;
pub mod polynomials;
pub mod port;
pub mod pump;
pub mod registry;
pub mod python_components;
pub mod refrigerant_boundary;
pub mod screw_economizer_compressor;
pub mod state_machine;
pub use air_boundary::{AirSink, AirSource};
pub use brine_boundary::{BrineSink, BrineSource};
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
pub use compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
pub use curves::{
BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning,
};
pub use drum::Drum;
pub use expansion_valve::{ExpansionValve, PhaseRegion};
pub use external_model::{
ExternalModel, ExternalModelConfig, ExternalModelError, ExternalModelMetadata,
ExternalModelType, MockExternalModel, ThreadSafeExternalModel,
};
pub use fan::{Fan, FanCurves};
pub use free_cooling_exchanger::{
FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode,
};
pub use flow_junction::{
CompressibleMerger, CompressibleSplitter, FlowMerger, FlowSplitter, FluidKind,
IncompressibleMerger, IncompressibleSplitter,
};
pub use heat_exchanger::model::FluidState;
pub use heat_exchanger::{
Condenser, CondenserCoil, Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, ExchangerType,
FloodedCondenser, FloodedEvaporator, FlowConfiguration, HeatExchanger, HeatExchangerBuilder,
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil,
};
pub use node::{Node, NodeMeasurements, NodePhase};
pub use params::ComponentParams;
pub use registry::{RegistryError, create_component};
pub use pipe::{friction_factor, roughness, Pipe, PipeGeometry};
pub use polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D, Polynomial2D};
pub use port::{
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
Port,
};
pub use pump::{Pump, PumpCurves};
pub use python_components::{
PyCompressorReal, PyExpansionValveReal, PyFlowMergerReal, PyFlowSinkReal, PyFlowSourceReal,
PyFlowSplitterReal, PyHeatExchangerReal, PyPipeReal,
PyRefrigerantSourceReal, PyRefrigerantSinkReal, PyBrineSourceReal, PyBrineSinkReal,
PyAirSourceReal, PyAirSinkReal,
};
pub use refrigerant_boundary::{RefrigerantSink, RefrigerantSource};
pub use screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
pub use state_machine::{
CircuitId, OperationalState, StateHistory, StateManageable, StateTransitionError,
StateTransitionRecord,
};
use entropyk_core::{MassFlow, Power};
use thiserror::Error;
/// Errors that can occur during component operations.
///
/// This enum represents all possible error conditions that components
/// may encounter during computation, providing detailed context for debugging.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ComponentError {
/// Invalid state vector dimensions.
///
/// The state vector provided does not have the expected dimensions
/// for this component's computation.
#[error("Invalid state vector dimensions: expected at least {expected}, got {actual}")]
InvalidStateDimensions {
/// Expected minimum dimension
expected: usize,
/// Actual dimension received
actual: usize,
},
/// Invalid residual vector dimensions.
///
/// The residual vector does not match the expected size for this component.
#[error("Invalid residual vector dimensions: expected {expected}, got {actual}")]
InvalidResidualDimensions {
/// Expected dimension (from n_equations)
expected: usize,
/// Actual dimension received
actual: usize,
},
/// Numerical computation error.
///
/// Occurs when a numerical operation fails (e.g., division by zero,
/// logarithm of non-positive number, NaN or infinite results).
#[error("Numerical error in component computation: {0}")]
NumericalError(String),
/// Invalid component state.
///
/// The component is in an invalid state for computation (e.g.,
/// disconnected ports, uninitialized parameters).
#[error("Invalid component state: {0}")]
InvalidState(String),
/// Invalid state transition.
///
/// The requested state transition is not allowed for this component.
#[error("Invalid state transition from {from:?} to {to:?}: {reason}")]
InvalidStateTransition {
/// State before attempted transition
from: OperationalState,
/// Attempted target state
to: OperationalState,
/// Reason for rejection
reason: String,
},
/// Calculation dynamically failed.
///
/// Occurs when an underlying model or backend fails to evaluate
/// properties at the requested state.
#[error("Calculation failed: {0}")]
CalculationFailed(String),
}
/// Represents the state of the entire thermodynamic system.
///
/// Re-exported from `entropyk_core` for convenience. Each edge in the system
/// graph has two state variables: pressure and enthalpy.
///
/// See [`entropyk_core::SystemState`] for full documentation.
pub use entropyk_core::SystemState;
/// Type alias for state slice used in component methods.
///
/// This allows both `&Vec<f64>` and `&SystemState` to be passed via deref coercion.
pub type StateSlice = [f64];
/// Vector of residual values for equation solving.
///
/// Residuals represent the difference between expected and actual values
/// in the system of equations. The solver aims to drive all residuals to zero.
pub type ResidualVector = Vec<f64>;
/// Builder for constructing Jacobian matrix entries.
///
/// The Jacobian matrix contains partial derivatives of residuals with respect
/// to state variables. This builder accumulates entries in coordinate format
/// (row, column, value) before assembly into a sparse matrix.
#[derive(Debug, Default)]
pub struct JacobianBuilder {
entries: Vec<(usize, usize, f64)>,
}
impl JacobianBuilder {
/// Creates a new empty Jacobian builder.
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let builder = JacobianBuilder::new();
/// ```
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Adds a single entry to the Jacobian.
///
/// # Arguments
///
/// * `row` - Row index in the Jacobian matrix (equation index)
/// * `col` - Column index in the Jacobian matrix (state variable index)
/// * `value` - Partial derivative value ∂residual/∂state
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let mut builder = JacobianBuilder::new();
/// builder.add_entry(0, 1, 2.5);
/// ```
pub fn add_entry(&mut self, row: usize, col: usize, value: f64) {
self.entries.push((row, col, value));
}
/// Adds multiple entries at once.
///
/// # Arguments
///
/// * `entries` - Iterator of (row, col, value) tuples
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let mut builder = JacobianBuilder::new();
/// builder.add_entries(vec![(0, 0, 1.0), (0, 1, 2.0)]);
/// ```
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = (usize, usize, f64)>) {
self.entries.extend(entries);
}
/// Returns all accumulated entries.
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let mut builder = JacobianBuilder::new();
/// builder.add_entry(0, 0, 1.0);
/// let entries = builder.entries();
/// assert_eq!(entries.len(), 1);
/// ```
pub fn entries(&self) -> &[(usize, usize, f64)] {
&self.entries
}
/// Clears all entries from the builder.
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let mut builder = JacobianBuilder::new();
/// builder.add_entry(0, 0, 1.0);
/// builder.clear();
/// assert!(builder.entries().is_empty());
/// ```
pub fn clear(&mut self) {
self.entries.clear();
}
/// Returns the number of accumulated entries.
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let mut builder = JacobianBuilder::new();
/// builder.add_entry(0, 0, 1.0);
/// assert_eq!(builder.len(), 1);
/// ```
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns true if no entries have been added.
///
/// # Examples
///
/// ```
/// use entropyk_components::JacobianBuilder;
///
/// let builder = JacobianBuilder::new();
/// assert!(builder.is_empty());
/// ```
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// Core trait for all thermodynamic components.
///
/// The `Component` trait defines the interface between thermodynamic components
/// (compressors, heat exchangers, valves, etc.) and the solver engine. All
/// components in the system must implement this trait.
///
/// # Object Safety
///
/// This trait is **object-safe**, meaning it can be used with dynamic dispatch:
///
/// ```
/// use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct SimpleComponent;
/// impl Component for SimpleComponent {
/// fn compute_residuals(&self, _state: &StateSlice, _residuals: &mut ResidualVector) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn jacobian_entries(&self, _state: &StateSlice, _jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn n_equations(&self) -> usize { 1 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
/// }
///
/// let component: Box<dyn Component> = Box::new(SimpleComponent);
/// ```
///
/// # Required Methods
///
/// Implementors must provide three methods:
///
/// - [`compute_residuals`](Self::compute_residuals): Compute residual values
/// representing the component's contribution to the system of equations.
///
/// - [`jacobian_entries`](Self::jacobian_entries): Provide partial derivatives
/// of residuals with respect to state variables.
///
/// - [`n_equations`](Self::n_equations): Report how many equations this
/// component contributes to the overall system.
///
/// # Error Handling
///
/// Both computation methods return [`Result`] to allow components to report
/// errors such as invalid state dimensions, numerical issues, or invalid
/// component configuration.
///
/// # Type Parameters
///
/// Currently, this trait uses simple slice types for state and residuals.
/// Future iterations may introduce generic type parameters for enhanced
/// type safety and performance.
pub trait Component {
/// Computes residual values for this component.
///
/// Residuals represent the difference between the component's expected
/// behavior and its actual state. The solver attempts to drive all
/// residuals to zero.
///
/// # Arguments
///
/// * `state` - Current state vector of the entire system as a slice
/// * `residuals` - Mutable slice to store computed residual values
///
/// # Returns
///
/// Returns `Ok(())` on success, or a [`ComponentError`] if computation fails.
///
/// # Implementation Notes
///
/// The `residuals` slice has length equal to [`n_equations`](Self::n_equations).
/// Each index corresponds to a specific equation for this component.
///
/// # Example
///
/// ```
/// use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct MassBalanceComponent;
/// impl Component for MassBalanceComponent {
/// fn compute_residuals(&self, state: &StateSlice, residuals: &mut ResidualVector) -> Result<(), ComponentError> {
/// // Validate dimensions
/// if state.len() < 2 {
/// return Err(ComponentError::InvalidStateDimensions { expected: 2, actual: state.len() });
/// }
/// // Mass balance: inlet - outlet = 0
/// residuals[0] = state[0] - state[1];
/// Ok(())
/// }
///
/// fn jacobian_entries(&self, _state: &StateSlice, _jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn n_equations(&self) -> usize { 1 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
/// }
/// ```
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError>;
/// Provides Jacobian matrix entries for this component.
///
/// The Jacobian contains partial derivatives of residuals with respect
/// to state variables: J[i,j] = ∂residual[i]/∂state[j]
///
/// # Arguments
///
/// * `state` - Current state vector of the entire system as a slice
/// * `jacobian` - Builder for accumulating Jacobian entries
///
/// # Returns
///
/// Returns `Ok(())` on success, or a [`ComponentError`] if computation fails.
///
/// # Implementation Notes
///
/// Use [`JacobianBuilder::add_entry`] to add individual partial derivatives.
/// Only add non-zero entries to optimize sparse matrix storage.
///
/// # Example
///
/// ```
/// use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct LinearComponent;
/// impl Component for LinearComponent {
/// fn compute_residuals(&self, _state: &StateSlice, _residuals: &mut ResidualVector) -> Result<(), ComponentError> {
/// Ok(())
/// }
///
/// fn jacobian_entries(&self, _state: &StateSlice, jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
/// // ∂r₀/∂s₀ = 2.0
/// jacobian.add_entry(0, 0, 2.0);
/// // ∂r₀/∂s₁ = -1.0
/// jacobian.add_entry(0, 1, -1.0);
/// Ok(())
/// }
///
/// fn n_equations(&self) -> usize { 1 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
/// }
/// ```
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError>;
/// Returns the number of equations contributed by this component.
///
/// This determines the size of the residual vector passed to
/// [`compute_residuals`](Self::compute_residuals).
///
/// # Examples
///
/// ```
/// use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct ThreeEquationComponent;
/// impl Component for ThreeEquationComponent {
/// fn compute_residuals(&self, _state: &StateSlice, _residuals: &mut ResidualVector) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn jacobian_entries(&self, _state: &StateSlice, _jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn n_equations(&self) -> usize { 3 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
/// }
///
/// let component = ThreeEquationComponent;
/// assert_eq!(component.n_equations(), 3);
/// ```
fn n_equations(&self) -> usize;
/// Returns the connected ports of this component.
///
/// This method provides access to the component's ports for topology
/// validation and graph construction. Components without ports should
/// return an empty slice.
///
/// # Examples
///
/// ```
/// use entropyk_components::{Component, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct PortlessComponent;
/// impl Component for PortlessComponent {
/// fn compute_residuals(&self, _state: &StateSlice, _residuals: &mut ResidualVector) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn jacobian_entries(&self, _state: &StateSlice, _jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
/// Ok(())
/// }
/// fn n_equations(&self) -> usize { 0 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
/// }
///
/// let component = PortlessComponent;
/// assert!(component.get_ports().is_empty());
/// ```
fn get_ports(&self) -> &[ConnectedPort];
/// Returns the names of this component's ports in index order.
///
/// The default implementation returns an empty vector. Components with
/// named ports should override this to return human-readable names
/// (e.g., `["suction", "discharge"]` for a compressor).
///
/// Port names are used by [`SystemBuilder::edge_with_ports`] to create
/// validated connections using string identifiers instead of integer indices.
fn port_names(&self) -> Vec<String> {
Vec::new()
}
/// Resolves a port name string to a port index for this component.
///
/// First checks the explicit [`port_names`](Self::port_names) override. If the
/// component defines named ports and `name` matches one, returns the matching index.
///
/// If no explicit port names are defined, falls back to a convention-based lookup
/// using standard thermodynamic port naming:
///
/// | Name pattern | Index |
/// |-------------------------------------------|-------|
/// | `inlet`, `in`, `suction`, `cold_in` | 0 |
/// | `outlet`, `out`, `discharge`, `cold_out` | 1 |
/// | `hot_in`, `hot_inlet`, `refrigerant_in` | 2 |
/// | `hot_out`, `hot_outlet`, `refrigerant_out` | 3 |
/// | `economizer`, `eco`, `economiser` | 2 |
///
/// # Errors
///
/// Returns a string describing why the port name could not be resolved.
fn resolve_port_name(&self, name: &str) -> Result<usize, String> {
let names = self.port_names();
if !names.is_empty() {
for (i, n) in names.iter().enumerate() {
if n.eq_ignore_ascii_case(name) {
return Ok(i);
}
}
return Err(format!(
"Port '{}' not found on component (valid ports: {})",
name,
names
.iter()
.enumerate()
.map(|(i, n)| format!("{i}: {n}"))
.collect::<Vec<_>>()
.join(", ")
));
}
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"inlet" | "in" | "suction" | "cold_in" => Ok(0),
"outlet" | "out" | "discharge" | "cold_out" => Ok(1),
"hot_in" | "hot_inlet" | "refrigerant_in" | "feed_inlet" | "evaporator_return" => {
Ok(2 % self.n_equations().max(2))
}
"hot_out" | "hot_outlet" | "refrigerant_out" | "liquid_outlet" | "vapor_outlet" => {
Ok(3 % self.n_equations().max(2))
}
"economizer" | "eco" | "economiser" | "flash_in" => Ok(2),
other => Err(format!("Unknown port name '{other}' for component")),
}
}
/// Injects system-level context into a component during topology finalization.
///
/// Called by [`System::finalize()`] after all edge state indices are computed.
/// The default implementation is a no-op; override this in components that need
/// to know their position in the global state vector (e.g. `MacroComponent`).
///
/// # Arguments
///
/// * `state_offset` — The index in the global state vector where this component's
/// *internal* state block begins. For ordinary leaf components this is never
/// needed; for `MacroComponent` it replaces the manual `set_global_state_offset`
/// call.
/// * `external_edge_state_indices` — A slice of `(p_idx, h_idx)` pairs for every
/// edge incident to this component's node in the parent graph (incoming and
/// outgoing), in traversal order. `MacroComponent` uses these to emit
/// port-coupling residuals.
fn set_system_context(
&mut self,
_state_offset: usize,
_external_edge_state_indices: &[(usize, usize)],
) {
// Default: no-op for all ordinary leaf components.
}
/// Returns the number of internal state variables this component maintains.
///
/// The default implementation returns 0, which is correct for all ordinary
/// leaf components. Hierarchical components (like `MacroComponent`) should
/// override this to return the size of their internal state block.
fn internal_state_len(&self) -> usize {
0
}
/// Returns the mass flow vector associated with the component's ports.
///
/// The returned vector matches the order of ports returned by `get_ports()`.
/// Positive values indicate flow *into* the component, negative values flow *out*.
///
/// # Arguments
///
/// * `state` - The global system state vector as a slice
///
/// # Returns
///
/// * `Ok(Vec<MassFlow>)` containing the mass flows if calculation is supported
/// * `Err(ComponentError::NotImplemented)` by default
fn port_mass_flows(&self, _state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Err(ComponentError::CalculationFailed(
"Mass flow calculation not implemented for this component".to_string(),
))
}
/// Returns the specified enthalpy vector associated with the component's ports.
///
/// The returned vector matches the order of ports returned by `get_ports()`.
///
/// # Arguments
///
/// * `state` - The global system state vector as a slice
///
/// # Returns
///
/// * `Ok(Vec<Enthalpy>)` containing the enthalpies if calculation is supported
/// * `Err(ComponentError::NotImplemented)` by default
fn port_enthalpies(
&self,
_state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
Err(ComponentError::CalculationFailed(
"Enthalpy calculation not implemented for this component".to_string(),
))
}
/// Injects control variable indices for calibration parameters into a component.
///
/// Called by the solver (e.g. `System::finalize()`) after matching `BoundedVariable`s
/// to components, so the component can read calibration factors dynamically from
/// the system state vector.
fn set_calib_indices(&mut self, _indices: entropyk_core::CalibIndices) {
// Default: no-op for components that don't support inverse calibration
}
/// Updates a single calibration factor on this component.
///
/// Returns `true` if the factor was recognized and updated. The default
/// implementation returns `false` (component does not support calibration).
/// Components that override this should also apply side effects (e.g.
/// updating internal model parameters).
fn update_calib_factor(&mut self, _factor: &str, _value: f64) -> bool {
false
}
/// Injects a fluid backend into this component for thermodynamic property queries.
///
/// Called by [`SystemBuilder::build()`] when a default or per-circuit backend is configured.
/// Components that already have a backend (set via their own builder) should ignore the call
/// to preserve the pre-assigned backend.
///
/// The default implementation is a no-op — components that don't use fluid backends
/// silently ignore this.
fn set_fluid_backend_from_builder(&mut self, _backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
// Default: no-op for components that don't use fluid backends
}
/// Evaluates the energy interactions of the component with its environment.
///
/// Returns a tuple of `(HeatTransfer, WorkTransfer)` in Watts (converted to `Power`).
/// - `HeatTransfer` > 0 means heat added TO the component from the environment.
/// - `WorkTransfer` > 0 means work done BY the component on the environment.
///
/// The default implementation returns `None`, indicating that the component does
/// not support or has not implemented energy transfer reporting. Components that
/// are strictly adiabatic and passive (like Pipes) should return `Some((Power(0.0), Power(0.0)))`.
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
None
}
/// Generates a string signature of the component's configuration (parameters, fluid, etc.).
/// Used for simulation traceability (input hashing).
/// Default implementation is provided, but components should override this to include
/// their specific parameters (e.g., fluid type, geometry).
fn signature(&self) -> String {
"Component".to_string()
}
/// Extracts component parameters for serialization.
///
/// Returns a `ComponentParams` struct containing all information needed to
/// reconstruct this component later (component type, configuration parameters).
///
/// The default implementation returns a generic "Component" type with no parameters.
/// Component implementations should override this to provide their specific parameters.
///
/// # Examples
///
/// ```
/// use entropyk_components::{Component, ComponentParams, ComponentError, StateSlice, ResidualVector, JacobianBuilder, ConnectedPort};
///
/// struct MyComponent;
/// impl Component for MyComponent {
/// fn compute_residuals(&self, _s: &StateSlice, _r: &mut ResidualVector) -> Result<(), ComponentError> { Ok(()) }
/// fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
/// fn n_equations(&self) -> usize { 2 }
/// fn get_ports(&self) -> &[ConnectedPort] { &[] }
///
/// fn to_params(&self) -> ComponentParams {
/// ComponentParams::new("MyComponent")
/// .with_param("value1", 42.0)
/// .with_param("value2", "test")
/// }
/// }
/// ```
fn to_params(&self) -> ComponentParams {
ComponentParams::new(self.signature())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Mock component for testing trait object safety
struct MockComponent {
n_equations: usize,
}
impl Component for MockComponent {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Validate dimensions
if residuals.len() != self.n_equations {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations,
actual: residuals.len(),
});
}
for (i, residual) in residuals.iter_mut().enumerate() {
*residual = state.get(i).copied().unwrap_or(0.0);
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Add identity-like entries for testing
for i in 0..self.n_equations {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n_equations
}
fn get_ports(&self) -> &[super::ConnectedPort] {
&[]
}
}
#[test]
fn test_component_trait_object_compiles() {
// This test verifies that Component trait is object-safe
let component: Box<dyn Component> = Box::new(MockComponent { n_equations: 3 });
assert_eq!(component.n_equations(), 3);
}
#[test]
fn test_component_reference_trait_object() {
// Test with reference trait object
let mock = MockComponent { n_equations: 2 };
let component: &dyn Component = &mock;
assert_eq!(component.n_equations(), 2);
}
#[test]
fn test_compute_residuals() {
let component = MockComponent { n_equations: 3 };
let state = vec![1.0, 2.0, 3.0];
let mut residuals = vec![0.0; 3];
let result = component.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert_eq!(residuals, vec![1.0, 2.0, 3.0]);
}
#[test]
fn test_compute_residuals_with_wrong_residual_size() {
let component = MockComponent { n_equations: 3 };
let state = vec![1.0, 2.0, 3.0];
let mut residuals = vec![0.0; 2]; // Wrong size
let result = component.compute_residuals(&state, &mut residuals);
assert!(result.is_err());
match result {
Err(ComponentError::InvalidResidualDimensions { expected, actual }) => {
assert_eq!(expected, 3);
assert_eq!(actual, 2);
}
_ => panic!("Expected InvalidResidualDimensions error"),
}
}
#[test]
fn test_compute_residuals_with_empty_state() {
let component = MockComponent { n_equations: 3 };
let state: Vec<f64> = vec![];
let mut residuals = vec![0.0; 3];
// Should still work, using unwrap_or(0.0) for missing state values
let result = component.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert_eq!(residuals, vec![0.0, 0.0, 0.0]); // Defaults to 0.0
}
#[test]
fn test_compute_residuals_with_zero_equations() {
let component = MockComponent { n_equations: 0 };
let state = vec![1.0, 2.0, 3.0];
let mut residuals: Vec<f64> = vec![];
let result = component.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(residuals.is_empty());
}
#[test]
fn test_jacobian_entries() {
let component = MockComponent { n_equations: 2 };
let state = vec![0.0; 2];
let mut jacobian = JacobianBuilder::new();
let result = component.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
let entries = jacobian.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], (0, 0, 1.0));
assert_eq!(entries[1], (1, 1, 1.0));
}
#[test]
fn test_jacobian_entries_with_zero_equations() {
let component = MockComponent { n_equations: 0 };
let state = vec![1.0, 2.0];
let mut jacobian = JacobianBuilder::new();
let result = component.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
assert!(jacobian.is_empty());
}
#[test]
fn test_jacobian_builder() {
let mut builder = JacobianBuilder::new();
assert!(builder.is_empty());
builder.add_entry(0, 1, 2.5);
assert_eq!(builder.len(), 1);
assert!(!builder.is_empty());
let entries = builder.entries();
assert_eq!(entries[0], (0, 1, 2.5));
}
#[test]
fn test_jacobian_builder_add_entries() {
let mut builder = JacobianBuilder::new();
let entries = vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 3.0)];
builder.add_entries(entries);
assert_eq!(builder.len(), 3);
assert_eq!(builder.entries()[0], (0, 0, 1.0));
assert_eq!(builder.entries()[1], (0, 1, 2.0));
assert_eq!(builder.entries()[2], (1, 0, 3.0));
}
#[test]
fn test_jacobian_builder_clear() {
let mut builder = JacobianBuilder::new();
builder.add_entry(0, 0, 1.0);
builder.add_entry(1, 1, 2.0);
assert_eq!(builder.len(), 2);
builder.clear();
assert!(builder.is_empty());
assert_eq!(builder.len(), 0);
}
#[test]
fn test_n_equations() {
let component = MockComponent { n_equations: 5 };
assert_eq!(component.n_equations(), 5);
}
#[test]
fn test_n_equations_zero() {
let component = MockComponent { n_equations: 0 };
assert_eq!(component.n_equations(), 0);
}
#[test]
fn test_vector_of_components() {
// Verify we can create a vector of trait objects
let components: Vec<Box<dyn Component>> = vec![
Box::new(MockComponent { n_equations: 1 }),
Box::new(MockComponent { n_equations: 2 }),
Box::new(MockComponent { n_equations: 3 }),
];
let total_equations: usize = components.iter().map(|c| c.n_equations()).sum();
assert_eq!(total_equations, 6);
}
#[test]
fn test_vector_with_zero_equation_component() {
let components: Vec<Box<dyn Component>> = vec![
Box::new(MockComponent { n_equations: 0 }),
Box::new(MockComponent { n_equations: 1 }),
Box::new(MockComponent { n_equations: 0 }),
];
let total_equations: usize = components.iter().map(|c| c.n_equations()).sum();
assert_eq!(total_equations, 1);
}
#[test]
fn test_component_error_display() {
let err = ComponentError::InvalidStateDimensions {
expected: 5,
actual: 3,
};
let msg = format!("{}", err);
assert!(msg.contains("Invalid state vector dimensions"));
assert!(msg.contains("expected at least 5"));
assert!(msg.contains("got 3"));
}
#[test]
fn test_component_error_numerical() {
let err = ComponentError::NumericalError("Division by zero".to_string());
let msg = format!("{}", err);
assert!(msg.contains("Numerical error"));
assert!(msg.contains("Division by zero"));
}
#[test]
fn test_component_error_invalid_state() {
let err = ComponentError::InvalidState("Port not connected".to_string());
let msg = format!("{}", err);
assert!(msg.contains("Invalid component state"));
assert!(msg.contains("Port not connected"));
}
#[test]
fn test_error_clonable() {
let err = ComponentError::InvalidStateDimensions {
expected: 2,
actual: 1,
};
let cloned = err.clone();
assert_eq!(err, cloned);
}
#[test]
fn test_component_with_ports_integration() {
use crate::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
struct ComponentWithPorts {
ports: Vec<ConnectedPort>,
}
impl ComponentWithPorts {
fn new() -> Self {
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),
);
let (connected1, connected2) =
port1.connect(port2).expect("connection should succeed");
Self {
ports: vec![connected1, connected2],
}
}
}
impl Component for ComponentWithPorts {
fn compute_residuals(
&self,
_state: &StateSlice,
_residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
0
}
fn get_ports(&self) -> &[ConnectedPort] {
&self.ports
}
}
let component = ComponentWithPorts::new();
assert_eq!(component.get_ports().len(), 2);
assert_eq!(component.get_ports()[0].fluid_id().as_str(), "R134a");
let boxed: Box<dyn Component> = Box::new(component);
assert_eq!(boxed.get_ports().len(), 2);
}
}