Some checks failed
CI / check (push) Has been cancelled
Replace isobaric 4-port closures with SimplifiedChannel (default) and Martin1996 DP models so z_dp and UI dp_correlation actually affect the Newton solve. Co-authored-by: Cursor <cursoragent@cursor.com>
1505 lines
56 KiB
Rust
1505 lines
56 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(rust_2018_idioms)]
|
||
// Pre-existing lint debt: the workspace had no CI for a long time and accumulated
|
||
// many clippy/style warnings. They are temporarily allowed here so the new CI
|
||
// skeleton can enforce warnings-as-errors on *new* code without a multi-thousand
|
||
// line refactor. See deferred-work.md for the cleanup plan.
|
||
#![allow(clippy::too_many_arguments)]
|
||
#![allow(clippy::question_mark)]
|
||
#![allow(clippy::needless_range_loop)]
|
||
#![allow(clippy::doc_overindented_list_items)]
|
||
#![allow(clippy::new_ret_no_self)]
|
||
#![allow(clippy::should_implement_trait)]
|
||
#![allow(clippy::manual_clamp)]
|
||
#![allow(clippy::approx_constant)]
|
||
#![allow(clippy::type_complexity)]
|
||
#![allow(clippy::legacy_numeric_constants)]
|
||
#![allow(clippy::field_reassign_with_default)]
|
||
#![allow(clippy::assertions_on_constants)]
|
||
#![allow(clippy::unnecessary_unwrap)]
|
||
#![allow(missing_docs)]
|
||
|
||
pub mod air_boundary;
|
||
pub mod anchor;
|
||
pub mod brine_boundary;
|
||
pub mod bypass_valve;
|
||
pub mod capillary_tube;
|
||
pub mod centrifugal_compressor;
|
||
pub mod compressor;
|
||
pub mod curves;
|
||
pub mod dof;
|
||
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 heat_source;
|
||
pub mod isenthalpic_expansion_valve;
|
||
pub mod isentropic_compressor;
|
||
pub mod jacobian_fd;
|
||
pub mod node;
|
||
pub mod params;
|
||
pub mod pipe;
|
||
pub mod polynomials;
|
||
pub mod port;
|
||
pub mod pump;
|
||
pub mod python_components;
|
||
pub mod refrigerant_boundary;
|
||
pub mod registry;
|
||
pub mod reversing_valve;
|
||
pub mod screw_economizer_compressor;
|
||
pub mod state_machine;
|
||
pub mod thermal_load;
|
||
pub mod valve_flow;
|
||
|
||
pub use air_boundary::{AirSink, AirSource};
|
||
pub use anchor::{Anchor, AnchorConstraint};
|
||
pub use brine_boundary::{BrineSink, BrineSource};
|
||
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
|
||
pub use capillary_tube::{CapillaryGeometry, CapillaryTube};
|
||
pub use centrifugal_compressor::{CentrifugalCompressor, CentrifugalMap, CentrifugalMapPoint};
|
||
pub use compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
|
||
pub use curves::{BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning};
|
||
pub use dof::{unspecified_roles, EquationRole};
|
||
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 flow_junction::{
|
||
CompressibleMerger, CompressibleSplitter, FlowMerger, FlowSplitter, FluidKind,
|
||
IncompressibleMerger, IncompressibleSplitter,
|
||
};
|
||
pub use free_cooling_exchanger::{
|
||
FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode,
|
||
};
|
||
pub use heat_exchanger::model::FluidState;
|
||
pub use heat_exchanger::{
|
||
AirCooledCondenser, BphxDpCorrelation, CoilGeometry, Condenser, CondenserCoil, CondenserRating,
|
||
Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType,
|
||
FanCoilUnit, FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator,
|
||
FloodedPoolBoilingConfig, FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder,
|
||
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
||
};
|
||
pub use heat_source::HeatSource;
|
||
pub use isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
||
pub use isentropic_compressor::IsentropicCompressor;
|
||
pub use isentropic_compressor::{VolumetricEfficiency, VsdSpeedMap};
|
||
pub use node::{Node, NodeMeasurements, NodePhase};
|
||
pub use params::ComponentParams;
|
||
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, PortKind,
|
||
};
|
||
pub use pump::{Pump, PumpCurves};
|
||
pub use python_components::{
|
||
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,
|
||
PyExpansionValveReal, PyFlowMergerReal, PyFlowSinkReal, PyFlowSourceReal, PyFlowSplitterReal,
|
||
PyHeatExchangerReal, PyPipeReal, PyRefrigerantSinkReal, PyRefrigerantSourceReal,
|
||
};
|
||
pub use refrigerant_boundary::{RefrigerantSink, RefrigerantSource};
|
||
pub use registry::{create_component, RegistryError};
|
||
pub use reversing_valve::{ReversingMode, ReversingValve};
|
||
pub use screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||
pub use state_machine::{
|
||
CircuitId, OperationalState, StateHistory, StateManageable, StateTransitionError,
|
||
StateTransitionRecord,
|
||
};
|
||
pub use thermal_load::ThermalLoad;
|
||
pub use valve_flow::{
|
||
valve_mass_flow, valve_mass_flow_dp_down, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel,
|
||
};
|
||
|
||
use entropyk_core::{MassFlow, Power};
|
||
use entropyk_fluids::FluidError;
|
||
use thiserror::Error;
|
||
|
||
/// A recoverable component domain violation (KINSOL `> 0` convention).
|
||
///
|
||
/// Re-exported from `entropyk-solver-core` so component authors name the type
|
||
/// via this facade, never the sub-crate directly.
|
||
pub use entropyk_solver_core::DomainViolation;
|
||
|
||
/// 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),
|
||
|
||
/// The evaluation left the component's physical domain (recoverable).
|
||
///
|
||
/// Raised when a property backend is queried outside its valid range
|
||
/// (e.g. a CoolProp out-of-range pressure/enthalpy probe). Globalization
|
||
/// strategies treat this as a step-reduction signal following the KINSOL
|
||
/// callback convention (`0` = ok, `> 0` = recoverable → shrink the step
|
||
/// and retry, `< 0` = fatal): the line search backtracks instead of
|
||
/// aborting, and when backtracks are exhausted the solve terminates with
|
||
/// `ConvergenceReason::DomainViolation`.
|
||
///
|
||
/// Use [`ComponentError::is_recoverable`] to distinguish this variant
|
||
/// from fatal errors, and [`ComponentError::from_fluid_error`] /
|
||
/// [`ComponentError::from_fluid_error_context`] to classify a
|
||
/// [`FluidError`] into this variant or [`ComponentError::CalculationFailed`].
|
||
#[error("{0}")]
|
||
DomainViolation(DomainViolation),
|
||
}
|
||
|
||
impl ComponentError {
|
||
/// Returns `true` when this error is a *recoverable* domain violation.
|
||
///
|
||
/// Follows the KINSOL callback convention: an evaluation returning `0` is
|
||
/// fine, `> 0` is recoverable (the solver may shrink the step and retry),
|
||
/// `< 0` is fatal. [`ComponentError::DomainViolation`] is the recoverable
|
||
/// (`> 0`) signal; every other variant is fatal for solver purposes.
|
||
pub fn is_recoverable(&self) -> bool {
|
||
matches!(self, Self::DomainViolation(_))
|
||
}
|
||
|
||
/// Classifies a fluid-backend failure as recoverable or fatal.
|
||
///
|
||
/// Domain errors — the probe left the backend's valid range — are
|
||
/// recoverable ([`ComponentError::DomainViolation`]):
|
||
/// - [`FluidError::InvalidState`] (e.g. CoolProp NaN detected post-hoc),
|
||
/// - [`FluidError::OutOfBounds`] (state outside the tabular data bounds),
|
||
/// - [`FluidError::CoolPropError`] (CoolProp rejected the probe).
|
||
///
|
||
/// All other variants (`UnknownFluid`, `UnsupportedProperty`,
|
||
/// `TableNotFound`, `NoCriticalPoint`, `MixtureNotSupported`,
|
||
/// `NumericalError`) are fatal configuration/usage errors and stay
|
||
/// [`ComponentError::CalculationFailed`].
|
||
pub fn from_fluid_error(error: FluidError) -> Self {
|
||
match error {
|
||
FluidError::InvalidState { reason } => Self::DomainViolation(DomainViolation {
|
||
component: None,
|
||
detail: reason,
|
||
}),
|
||
error @ FluidError::OutOfBounds { .. } => Self::DomainViolation(DomainViolation {
|
||
component: None,
|
||
detail: error.to_string(),
|
||
}),
|
||
FluidError::CoolPropError(message) => Self::DomainViolation(DomainViolation {
|
||
component: None,
|
||
detail: message,
|
||
}),
|
||
fatal => Self::CalculationFailed(fatal.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Same classification as [`ComponentError::from_fluid_error`], but the
|
||
/// `detail` / fatal message becomes `format!("{context}: {error}")` so
|
||
/// call sites keep their existing context prefixes (e.g. `"rho_in: "`,
|
||
/// `"Failed to compute suction state: "`).
|
||
pub fn from_fluid_error_context(context: &str, error: FluidError) -> Self {
|
||
match error {
|
||
error @ (FluidError::InvalidState { .. }
|
||
| FluidError::OutOfBounds { .. }
|
||
| FluidError::CoolPropError(_)) => Self::DomainViolation(DomainViolation {
|
||
component: None,
|
||
detail: format!("{context}: {error}"),
|
||
}),
|
||
fatal => Self::CalculationFailed(format!("{context}: {fatal}")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// Physical output that can be measured from a component's converged state.
|
||
///
|
||
/// Used by the inverse-control layer to evaluate constraint residuals from
|
||
/// *real* thermodynamics (via [`Component::measure_output`]) instead of
|
||
/// placeholder formulas. Each variant maps to a solver-side `ComponentOutput`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MeasuredOutput {
|
||
/// Refrigerant-side heat/duty [W] (evaporator/condenser capacity).
|
||
Capacity,
|
||
/// Heat-transfer rate [W] (alias of capacity for HX duty).
|
||
HeatTransferRate,
|
||
/// Suction/outlet superheat above saturation [K].
|
||
Superheat,
|
||
/// Liquid-line subcooling below saturation [K].
|
||
Subcooling,
|
||
/// Refrigerant mass flow rate [kg/s].
|
||
MassFlowRate,
|
||
/// Absolute pressure at the component [Pa].
|
||
Pressure,
|
||
/// Refrigerant temperature at the component [K].
|
||
Temperature,
|
||
/// Saturation temperature at the component's reference pressure [K]
|
||
/// (SST for an evaporator, SDT for a condenser).
|
||
SaturationTemperature,
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// Semantic roles for each residual equation contributed by this component.
|
||
///
|
||
/// Used by the system-wide DoF ledger (`entropyk_solver::dof`) to audit that
|
||
/// the algebraic system is square and that every fix has a corresponding free
|
||
/// unknown (or an intentional residual drop).
|
||
///
|
||
/// # Contract
|
||
///
|
||
/// - Prefer returning exactly [`n_equations`](Self::n_equations) roles.
|
||
/// - An empty default is allowed for legacy components; the solver then
|
||
/// labels rows as `Unspecified`.
|
||
/// - **Never** add a residual "to make it converge" without declaring its
|
||
/// role and the free unknown it closes.
|
||
///
|
||
/// # Fix / Free discipline
|
||
///
|
||
/// | Role | DoF effect |
|
||
/// |------|------------|
|
||
/// | `BoundaryDirichlet` | Fixes a boundary state (machine input) |
|
||
/// | `OutletClosure` | Consumes one DoF — pair with a free actuator or drop another residual |
|
||
/// | `EnergyBalance` / `Momentum…` | Closes a physics residual on live ports |
|
||
/// | `ActuatorClosure` | Closes a free actuator unknown |
|
||
fn equation_roles(&self) -> Vec<EquationRole> {
|
||
Vec::new()
|
||
}
|
||
|
||
/// 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 `(m_idx, p_idx, h_idx)` triples for
|
||
/// every edge incident to this component's node in the parent graph (incoming and
|
||
/// outgoing), in traversal order. The `m_idx` is the mass-flow state index added
|
||
/// by CM1.2; components use it to contribute mass-flow residuals (CM1.3).
|
||
/// `MacroComponent` uses all three indices to emit port-coupling residuals.
|
||
fn set_system_context(
|
||
&mut self,
|
||
_state_offset: usize,
|
||
_external_edge_state_indices: &[(usize, usize, usize)],
|
||
) {
|
||
// Default: no-op for all ordinary leaf components.
|
||
}
|
||
|
||
/// Injects the per-port edge state indices resolved by the solver.
|
||
///
|
||
/// `port_edges[i]` is `Some((m_idx, p_idx, h_idx))` when a flow edge is
|
||
/// connected at this component's local port index `i` (the same index used
|
||
/// by `System::add_edge_with_ports`), or `None` when the port is
|
||
/// unconnected. Unlike [`set_system_context`](Self::set_system_context),
|
||
/// whose incident-edge ordering depends on graph traversal, this mapping is
|
||
/// deterministic — multi-port components (Modelica-style 4-port heat
|
||
/// exchangers, economizer compressors, drums…) should prefer it.
|
||
///
|
||
/// Called by `System::finalize()` right after `set_system_context`.
|
||
fn set_port_context(&mut self, _port_edges: &[Option<(usize, usize, usize)>]) {
|
||
// Default: no-op — positional wiring via set_system_context stays valid.
|
||
}
|
||
|
||
/// Declares the internal series flow paths of a multi-port component as
|
||
/// `(inlet_port, outlet_port)` pairs.
|
||
///
|
||
/// The mass-flow topology presolve uses these to walk *through* the
|
||
/// component: an edge entering at `inlet_port` and the edge leaving at
|
||
/// `outlet_port` belong to the same series branch (they share one ṁ
|
||
/// unknown), exactly like Modelica's `port_a.m_flow + port_b.m_flow = 0`.
|
||
///
|
||
/// A Modelica-style 4-port heat exchanger returns `[(0, 1), (2, 3)]`
|
||
/// (refrigerant path + secondary path). Genuine junctions (splitters,
|
||
/// mergers, drums) keep the empty default so they stay branch boundaries.
|
||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||
Vec::new()
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Sets the fixed orifice opening fraction in `[0, 1]` for components that
|
||
/// meter flow through an adjustable opening (expansion valves).
|
||
///
|
||
/// Used by physical-parameter continuation: solve at a reduced opening,
|
||
/// then walk the opening up to its target warm-starting each step
|
||
/// (TLK-Thermo/Modelica 2019 style homotopy on the physical parameter).
|
||
/// Default: no-op for components without an adjustable opening.
|
||
fn set_opening_fraction(&mut self, _opening: f64) {}
|
||
|
||
/// Injects the state index of an externally-determined heat rate Q [W]
|
||
/// (an inter-circuit thermal-coupling unknown) into this component.
|
||
///
|
||
/// Called by the solver's `System::finalize()` for the cold-side receiver
|
||
/// of a physical thermal coupling (e.g. [`ThermalLoad`]), which then reads
|
||
/// `Q = state[idx]` in its energy-balance residual.
|
||
fn set_external_heat_index(&mut self, _idx: usize) {
|
||
// Default: no-op for components that don't consume external heat
|
||
}
|
||
|
||
/// Whether this component's [`energy_transfers`](Self::energy_transfers)
|
||
/// contribute to the refrigerant-cycle performance aggregation
|
||
/// (cooling/heating capacity, COP).
|
||
///
|
||
/// Secondary-side receivers such as [`ThermalLoad`] return `false`: the
|
||
/// heat they absorb is the *rejected* duty of the primary cycle and must
|
||
/// not be double-counted as cooling capacity. They still participate in
|
||
/// per-component First Law validation.
|
||
fn counts_in_cycle_performance(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Measures a physical output from the current state for inverse control.
|
||
///
|
||
/// Returns the *real* value of the requested [`MeasuredOutput`] using this
|
||
/// component's thermodynamics, or `None` when the component cannot provide
|
||
/// it. This replaces the placeholder constraint formulas: the inverse-control
|
||
/// solver calls this to form genuine constraint residuals `measure − target`.
|
||
///
|
||
/// The default implementation derives `Capacity`/`HeatTransferRate` from
|
||
/// [`energy_transfers`](Self::energy_transfers) (the refrigerant-side heat,
|
||
/// as an absolute duty in Watts). All other outputs return `None`; heat
|
||
/// exchangers and the compressor override this to add superheat, subcooling,
|
||
/// mass flow, pressure and temperature.
|
||
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||
match kind {
|
||
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => self
|
||
.energy_transfers(state)
|
||
.map(|(heat, _work)| heat.to_watts().abs()),
|
||
_ => 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);
|
||
}
|
||
|
||
// ── Story 1.3: recoverable DomainViolation (AC #1) ──────────────────────
|
||
|
||
#[test]
|
||
fn domain_violation_is_the_only_recoverable_variant() {
|
||
let recoverable = ComponentError::DomainViolation(DomainViolation {
|
||
component: Some("compressor".to_string()),
|
||
detail: "suction pressure below backend range".to_string(),
|
||
});
|
||
assert!(recoverable.is_recoverable());
|
||
|
||
let fatal_variants = [
|
||
ComponentError::InvalidStateDimensions {
|
||
expected: 3,
|
||
actual: 2,
|
||
},
|
||
ComponentError::InvalidResidualDimensions {
|
||
expected: 3,
|
||
actual: 2,
|
||
},
|
||
ComponentError::NumericalError("division by zero".to_string()),
|
||
ComponentError::InvalidState("disconnected port".to_string()),
|
||
ComponentError::InvalidStateTransition {
|
||
from: OperationalState::Off,
|
||
to: OperationalState::On,
|
||
reason: "not allowed".to_string(),
|
||
},
|
||
ComponentError::CalculationFailed("no fluid backend".to_string()),
|
||
];
|
||
for variant in &fatal_variants {
|
||
assert!(
|
||
!variant.is_recoverable(),
|
||
"{variant:?} must not be recoverable"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn domain_violation_display_delegates_to_inner_type() {
|
||
let attributed = ComponentError::DomainViolation(DomainViolation {
|
||
component: Some("condenser".to_string()),
|
||
detail: "pressure below backend range".to_string(),
|
||
});
|
||
let rendered = attributed.to_string();
|
||
assert!(
|
||
rendered.contains("condenser"),
|
||
"missing component: {rendered}"
|
||
);
|
||
assert!(
|
||
rendered.contains("pressure below backend range"),
|
||
"missing detail: {rendered}"
|
||
);
|
||
|
||
let unattributed = ComponentError::DomainViolation(DomainViolation {
|
||
component: None,
|
||
detail: "out of range".to_string(),
|
||
});
|
||
assert_eq!(unattributed.to_string(), "domain violation: out of range");
|
||
}
|
||
|
||
#[test]
|
||
fn from_fluid_error_classifies_recoverable_domain_errors() {
|
||
let recoverable_cases = [
|
||
FluidError::InvalidState {
|
||
reason: "pressure below triple point".to_string(),
|
||
},
|
||
FluidError::OutOfBounds {
|
||
fluid: "R134a".to_string(),
|
||
p: 1.0e5,
|
||
t: 300.0,
|
||
},
|
||
FluidError::CoolPropError("unable to solve 1phase PY flash".to_string()),
|
||
];
|
||
for error in recoverable_cases {
|
||
let classified = ComponentError::from_fluid_error(error);
|
||
assert!(
|
||
classified.is_recoverable(),
|
||
"{classified:?} must be recoverable"
|
||
);
|
||
match &classified {
|
||
ComponentError::DomainViolation(violation) => {
|
||
assert!(violation.component.is_none());
|
||
assert!(!violation.detail.is_empty());
|
||
}
|
||
other => panic!("expected DomainViolation, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn from_fluid_error_keeps_config_errors_fatal() {
|
||
let fatal_cases = [
|
||
FluidError::UnknownFluid {
|
||
fluid: "R999".to_string(),
|
||
},
|
||
FluidError::UnsupportedProperty {
|
||
property: "viscosity".to_string(),
|
||
},
|
||
FluidError::TableNotFound {
|
||
path: "missing.bin".to_string(),
|
||
},
|
||
FluidError::NoCriticalPoint {
|
||
fluid: "R134a".to_string(),
|
||
},
|
||
FluidError::MixtureNotSupported("R134a/R410A".to_string()),
|
||
FluidError::NumericalError("NaN in table lookup".to_string()),
|
||
];
|
||
for error in fatal_cases {
|
||
let classified = ComponentError::from_fluid_error(error);
|
||
assert!(
|
||
!classified.is_recoverable(),
|
||
"{classified:?} must stay fatal"
|
||
);
|
||
assert!(
|
||
matches!(classified, ComponentError::CalculationFailed(_)),
|
||
"expected CalculationFailed, got {classified:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn from_fluid_error_context_preserves_prefix() {
|
||
let recoverable = ComponentError::from_fluid_error_context(
|
||
"rho_in",
|
||
FluidError::InvalidState {
|
||
reason: "pressure below triple point".to_string(),
|
||
},
|
||
);
|
||
match recoverable {
|
||
ComponentError::DomainViolation(violation) => {
|
||
assert!(violation.detail.starts_with("rho_in: "));
|
||
assert!(violation.detail.contains("pressure below triple point"));
|
||
}
|
||
other => panic!("expected DomainViolation, got {other:?}"),
|
||
}
|
||
|
||
let fatal = ComponentError::from_fluid_error_context(
|
||
"suction_state",
|
||
FluidError::UnknownFluid {
|
||
fluid: "R999".to_string(),
|
||
},
|
||
);
|
||
match fatal {
|
||
ComponentError::CalculationFailed(message) => {
|
||
assert!(message.starts_with("suction_state: "));
|
||
assert!(message.contains("R999"));
|
||
}
|
||
other => panic!("expected CalculationFailed, got {other:?}"),
|
||
}
|
||
}
|
||
}
|