Files
Entropyk/crates/components/src/lib.rs
sepehr 44f793a583
Some checks failed
CI / check (push) Has been cancelled
Wire BPHX channel pressure drop on both sides with selectable correlations.
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>
2026-07-19 16:52:25 +02:00

1505 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # 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:?}"),
}
}
}