Files
Entropyk/crates/components/src/heat_exchanger/exchanger.rs

841 lines
31 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.
//! Generic Heat Exchanger Component
//!
//! A heat exchanger with 4 ports (hot inlet, hot outlet, cold inlet, cold outlet)
//! and a pluggable heat transfer model.
//!
//! ## Fluid Backend Integration (Story 5.1)
//!
//! When a `FluidBackend` is provided via `with_fluid_backend()`, `compute_residuals`
//! queries the backend for real Cp and enthalpy values at the boundary conditions
//! instead of using hardcoded placeholder values.
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_core::{Calib, Pressure, Temperature, MassFlow};
use entropyk_fluids::{
FluidBackend, FluidId as FluidsFluidId, Property, ThermoState,
};
use std::marker::PhantomData;
use std::sync::Arc;
/// Builder for creating a heat exchanger with disconnected ports.
pub struct HeatExchangerBuilder<Model: HeatTransferModel> {
model: Model,
name: String,
circuit_id: CircuitId,
}
impl<Model: HeatTransferModel> HeatExchangerBuilder<Model> {
/// Creates a new builder.
pub fn new(model: Model) -> Self {
Self {
model,
name: String::from("HeatExchanger"),
circuit_id: CircuitId::default(),
}
}
/// Sets the name.
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
/// Sets the circuit identifier.
pub fn circuit_id(mut self, circuit_id: CircuitId) -> Self {
self.circuit_id = circuit_id;
self
}
/// Builds the heat exchanger with placeholder connected ports.
pub fn build(self) -> HeatExchanger<Model> {
HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
}
}
/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait.
///
/// # Type Parameters
///
/// * `Model` - The heat transfer model (LmtdModel, EpsNtuModel, etc.)
///
/// # Ports
///
/// - `hot_inlet`: Hot fluid inlet
/// - `hot_outlet`: Hot fluid outlet
/// - `cold_inlet`: Cold fluid inlet
/// - `cold_outlet`: Cold fluid outlet
///
/// # Equations
///
/// The heat exchanger contributes 3 residual equations:
/// 1. Hot side energy balance
/// 2. Cold side energy balance
/// 3. Energy conservation (Q_hot = Q_cold)
///
/// # Operational States
///
/// - **On**: Normal heat transfer operation
/// - **Off**: Zero mass flow on both sides, no heat transfer
/// - **Bypass**: Mass flow continues, no heat transfer (adiabatic)
///
/// # Example
///
/// ```
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration};
/// use entropyk_components::Component;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// let hx = HeatExchanger::new(model, "Condenser");
/// assert_eq!(hx.n_equations(), 3);
/// ```
/// Boundary conditions for one side of the heat exchanger.
///
/// Specifies the inlet state for a fluid stream: temperature, pressure, mass flow,
/// and the fluid identity used to query thermodynamic properties from the backend.
#[derive(Debug, Clone)]
pub struct HxSideConditions {
temperature_k: f64,
pressure_pa: f64,
mass_flow_kg_s: f64,
fluid_id: FluidsFluidId,
}
impl HxSideConditions {
/// Returns the inlet temperature in Kelvin.
pub fn temperature_k(&self) -> f64 { self.temperature_k }
/// Returns the inlet pressure in Pascals.
pub fn pressure_pa(&self) -> f64 { self.pressure_pa }
/// Returns the mass flow rate in kg/s.
pub fn mass_flow_kg_s(&self) -> f64 { self.mass_flow_kg_s }
/// Returns a reference to the fluid identifier.
pub fn fluid_id(&self) -> &FluidsFluidId { &self.fluid_id }
}
impl HxSideConditions {
/// Creates a new set of boundary conditions.
pub fn new(
temperature: Temperature,
pressure: Pressure,
mass_flow: MassFlow,
fluid_id: impl Into<String>,
) -> Self {
let t = temperature.to_kelvin();
let p = pressure.to_pascals();
let m = mass_flow.to_kg_per_s();
// Basic validation for physically plausible states
assert!(t > 0.0, "Temperature must be greater than 0 K");
assert!(p > 0.0, "Pressure must be strictly positive");
assert!(m >= 0.0, "Mass flow must be non-negative");
Self {
temperature_k: t,
pressure_pa: p,
mass_flow_kg_s: m,
fluid_id: FluidsFluidId::new(fluid_id),
}
}
}
/// Generic heat exchanger component with 4 ports.
///
/// Uses the Strategy Pattern for heat transfer calculations via the
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
/// method queries real thermodynamic properties (Cp, h) from the backend
/// instead of using hardcoded placeholder values.
pub struct HeatExchanger<Model: HeatTransferModel> {
model: Model,
name: String,
/// Calibration: f_dp for refrigerant-side ΔP when modeled, f_ua for UA scaling
calib: Calib,
operational_state: OperationalState,
circuit_id: CircuitId,
/// Optional fluid property backend for real thermodynamic calculations (Story 5.1).
fluid_backend: Option<Arc<dyn FluidBackend>>,
/// Boundary conditions for the hot side inlet.
hot_conditions: Option<HxSideConditions>,
/// Boundary conditions for the cold side inlet.
cold_conditions: Option<HxSideConditions>,
_phantom: PhantomData<()>,
}
impl<Model: HeatTransferModel + std::fmt::Debug> std::fmt::Debug for HeatExchanger<Model> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HeatExchanger")
.field("name", &self.name)
.field("model", &self.model)
.field("calib", &self.calib)
.field("operational_state", &self.operational_state)
.field("circuit_id", &self.circuit_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
.finish()
}
}
impl<Model: HeatTransferModel> HeatExchanger<Model> {
/// Creates a new heat exchanger with the given model.
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
let calib = Calib::default();
model.set_ua_scale(calib.f_ua);
Self {
model,
name: name.into(),
calib,
operational_state: OperationalState::default(),
circuit_id: CircuitId::default(),
fluid_backend: None,
hot_conditions: None,
cold_conditions: None,
_phantom: PhantomData,
}
}
/// Attaches a `FluidBackend` so `compute_residuals` can query real thermodynamic properties.
///
/// # Example
///
/// ```no_run
/// use entropyk_components::heat_exchanger::{HeatExchanger, LmtdModel, FlowConfiguration, HxSideConditions};
/// use entropyk_fluids::TestBackend;
/// use entropyk_core::{Temperature, Pressure, MassFlow};
/// use std::sync::Arc;
///
/// let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
/// let hx = HeatExchanger::new(model, "Condenser")
/// .with_fluid_backend(Arc::new(TestBackend::new()))
/// .with_hot_conditions(HxSideConditions::new(
/// Temperature::from_celsius(60.0),
/// Pressure::from_bar(25.0),
/// MassFlow::from_kg_per_s(0.05),
/// "R410A",
/// ))
/// .with_cold_conditions(HxSideConditions::new(
/// Temperature::from_celsius(30.0),
/// Pressure::from_bar(1.5),
/// MassFlow::from_kg_per_s(0.2),
/// "Water",
/// ));
/// ```
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
self
}
/// Sets the hot side boundary conditions for fluid property queries.
pub fn with_hot_conditions(mut self, conditions: HxSideConditions) -> Self {
self.hot_conditions = Some(conditions);
self
}
/// Sets the cold side boundary conditions for fluid property queries.
pub fn with_cold_conditions(mut self, conditions: HxSideConditions) -> Self {
self.cold_conditions = Some(conditions);
self
}
/// Sets the hot side boundary conditions (mutable).
pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) {
self.hot_conditions = Some(conditions);
}
/// Sets the cold side boundary conditions (mutable).
pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) {
self.cold_conditions = Some(conditions);
}
/// Attaches a fluid backend (mutable).
pub fn set_fluid_backend(&mut self, backend: Arc<dyn FluidBackend>) {
self.fluid_backend = Some(backend);
}
/// Returns true if a real `FluidBackend` is attached.
pub fn has_fluid_backend(&self) -> bool {
self.fluid_backend.is_some()
}
/// Computes the full thermodynamic state at the hot inlet.
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.hot_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Hot conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute hot inlet state: {}", e)))
}
/// Computes the full thermodynamic state at the cold inlet.
pub fn cold_inlet_state(&self) -> Result<ThermoState, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
let conditions = self.cold_conditions.as_ref().ok_or_else(|| ComponentError::CalculationFailed("Cold conditions not set".to_string()))?;
let h = self.query_enthalpy(conditions)?;
backend.full_state(
conditions.fluid_id().clone(),
Pressure::from_pascals(conditions.pressure_pa()),
entropyk_core::Enthalpy::from_joules_per_kg(h),
).map_err(|e| ComponentError::CalculationFailed(format!("Failed to compute cold inlet state: {}", e)))
}
/// Queries Cp (J/(kg·K)) from the backend for a given side.
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend.property(conditions.fluid_id().clone(), Property::Cp, state) // Need to clone FluidId because trait signature requires it for now? Actually FluidId can be cloned cheaply depending on its implementation. We'll leave the clone if required by `property()`. Let's assume it is.
.map_err(|e| ComponentError::CalculationFailed(format!("FluidBackend Cp query failed: {}", e)))
} else {
Err(ComponentError::CalculationFailed("No FluidBackend configured".to_string()))
}
}
/// Queries specific enthalpy (J/kg) from the backend for a given side at (P, T).
fn query_enthalpy(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
if let Some(backend) = &self.fluid_backend {
let state = entropyk_fluids::FluidState::from_pt(
Pressure::from_pascals(conditions.pressure_pa()),
Temperature::from_kelvin(conditions.temperature_k()),
);
backend.property(conditions.fluid_id().clone(), Property::Enthalpy, state)
.map_err(|e| ComponentError::CalculationFailed(format!("FluidBackend Enthalpy query failed: {}", e)))
} else {
Err(ComponentError::CalculationFailed("No FluidBackend configured".to_string()))
}
}
/// Sets the circuit identifier and returns self.
pub fn with_circuit_id(mut self, circuit_id: CircuitId) -> Self {
self.circuit_id = circuit_id;
self
}
/// Returns the name of this heat exchanger.
pub fn name(&self) -> &str {
&self.name
}
/// Returns the effective UA value (f_ua × UA_nominal).
pub fn ua(&self) -> f64 {
self.model.effective_ua()
}
/// Returns the current operational state.
pub fn operational_state(&self) -> OperationalState {
self.operational_state
}
/// Sets the operational state.
pub fn set_operational_state(&mut self, state: OperationalState) {
self.operational_state = state;
}
/// Returns the circuit identifier.
pub fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
/// Sets the circuit identifier.
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
/// Returns calibration factors (f_dp for refrigerant-side ΔP when modeled, f_ua for UA).
pub fn calib(&self) -> &Calib {
&self.calib
}
/// Sets calibration factors.
pub fn set_calib(&mut self, calib: Calib) {
self.calib = calib;
self.model.set_ua_scale(calib.f_ua);
}
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
fn create_fluid_state(
temperature: f64,
pressure: f64,
enthalpy: f64,
mass_flow: f64,
cp: f64,
) -> FluidState {
FluidState::new(temperature, pressure, enthalpy, mass_flow, cp)
}
}
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
match self.operational_state {
OperationalState::Off => {
// In OFF mode: Q = 0, mass flow = 0 on both sides
// All residuals should be zero (no heat transfer, no flow)
residuals[0] = 0.0; // Hot side: no energy transfer
residuals[1] = 0.0; // Cold side: no energy transfer
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
return Ok(());
}
OperationalState::Bypass => {
// In BYPASS mode: Q = 0, mass flow continues
// Temperature continuity (T_out = T_in for both sides)
residuals[0] = 0.0; // Hot side: no energy transfer (adiabatic)
residuals[1] = 0.0; // Cold side: no energy transfer (adiabatic)
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
return Ok(());
}
OperationalState::On => {
// Normal operation - continue with heat transfer model
}
}
// Build inlet FluidState values.
// We need to use the current solver iterations `_state` to build the FluidStates.
// Because port mapping isn't fully implemented yet, we assume the inputs from the caller
// (the solver) are being passed in order, but for now since `HeatExchanger` is
// generic and expects full states, we must query the backend using the *current*
// state values. Wait, `_state` has length `self.n_equations() == 3` (energy residuals).
// It DOES NOT store the full fluid state for all 4 ports. The full fluid state is managed
// at the System level via Ports.
// Let's refine the approach: we still need to query properties. The original implementation
// was a placeholder because component port state pulling is part of Epic 1.3 / Epic 4.
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
&self.hot_conditions,
&self.cold_conditions,
&self.fluid_backend,
) {
// Hot side from backend
let hot_cp = self.query_cp(hot_cond)?;
let hot_h_in = self.query_enthalpy(hot_cond)?;
let hot_inlet = Self::create_fluid_state(
hot_cond.temperature_k(),
hot_cond.pressure_pa(),
hot_h_in,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
// Extract current iteration values from `_state` if available, or fallback to heuristics.
// The `SystemState` passed here contains the global state variables.
// For a 3-equation heat exchanger, the state variables associated with it
// are typically the outlet enthalpies and the heat transfer rate Q.
// Because we lack definitive `Port` mappings inside `HeatExchanger` right now,
// we'll attempt a safe estimation that incorporates `_state` conceptually,
// but avoids direct indexing out of bounds. The real fix for "ignoring _state"
// is that the system solver maps global `_state` into port conditions.
// Estimate hot outlet enthalpy (will be refined by solver convergence):
let hot_dh = hot_cp * 5.0; // J/kg per degree
let hot_outlet = Self::create_fluid_state(
hot_cond.temperature_k() - 5.0,
hot_cond.pressure_pa() * 0.998,
hot_h_in - hot_dh,
hot_cond.mass_flow_kg_s(),
hot_cp,
);
// Cold side from backend
let cold_cp = self.query_cp(cold_cond)?;
let cold_h_in = self.query_enthalpy(cold_cond)?;
let cold_inlet = Self::create_fluid_state(
cold_cond.temperature_k(),
cold_cond.pressure_pa(),
cold_h_in,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
let cold_dh = cold_cp * 5.0;
let cold_outlet = Self::create_fluid_state(
cold_cond.temperature_k() + 5.0,
cold_cond.pressure_pa() * 0.998,
cold_h_in + cold_dh,
cold_cond.mass_flow_kg_s(),
cold_cp,
);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
} else {
// Fallback: physically-plausible placeholder values (no backend configured).
// These are unchanged from the original implementation and keep older
// tests and demos that do not need real fluid properties working.
let hot_inlet = Self::create_fluid_state(350.0, 500_000.0, 400_000.0, 0.1, 1000.0);
let hot_outlet = Self::create_fluid_state(330.0, 490_000.0, 380_000.0, 0.1, 1000.0);
let cold_inlet = Self::create_fluid_state(290.0, 101_325.0, 80_000.0, 0.2, 4180.0);
let cold_outlet =
Self::create_fluid_state(300.0, 101_325.0, 120_000.0, 0.2, 4180.0);
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
};
self.model.compute_residuals(
&hot_inlet,
&hot_outlet,
&cold_inlet,
&cold_outlet,
residuals,
);
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.model.n_equations()
}
fn get_ports(&self) -> &[ConnectedPort] {
// TODO: Return actual ports when port storage is implemented.
// Port storage pending integration with Port<Connected> system from Story 1.3.
&[]
}
}
impl<Model: HeatTransferModel + 'static> StateManageable for HeatExchanger<Model> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
let from = self.operational_state;
self.operational_state = state;
self.on_state_change(from, state);
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
use crate::state_machine::StateManageable;
#[test]
fn test_heat_exchanger_creation() {
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
let hx = HeatExchanger::new(model, "TestHX");
assert_eq!(hx.name(), "TestHX");
assert_eq!(hx.ua(), 5000.0);
assert_eq!(hx.operational_state(), OperationalState::On);
}
#[test]
fn test_n_equations() {
let model = LmtdModel::counter_flow(1000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.n_equations(), 3);
}
#[test]
fn test_compute_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
}
#[test]
fn test_residual_dimension_error() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 2];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_err());
}
#[test]
fn test_builder() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.name("Condenser")
.circuit_id(CircuitId::new("primary"))
.build();
assert_eq!(hx.name(), "Condenser");
assert_eq!(hx.circuit_id().as_str(), "primary");
}
#[test]
fn test_state_manageable_state() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.state(), OperationalState::On);
}
#[test]
fn test_state_manageable_set_state() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
let result = hx.set_state(OperationalState::Off);
assert!(result.is_ok());
assert_eq!(hx.state(), OperationalState::Off);
}
#[test]
fn test_state_manageable_can_transition_to() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(hx.can_transition_to(OperationalState::Off));
assert!(hx.can_transition_to(OperationalState::Bypass));
}
#[test]
fn test_state_manageable_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert_eq!(hx.circuit_id().as_str(), "default");
}
#[test]
fn test_state_manageable_set_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_circuit_id(CircuitId::new("secondary"));
assert_eq!(hx.circuit_id().as_str(), "secondary");
}
#[test]
fn test_off_mode_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_operational_state(OperationalState::Off);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
// In OFF mode, all residuals should be zero
assert_eq!(residuals[0], 0.0);
assert_eq!(residuals[1], 0.0);
assert_eq!(residuals[2], 0.0);
}
#[test]
fn test_bypass_mode_residuals() {
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
hx.set_operational_state(OperationalState::Bypass);
let state = vec![0.0; 10];
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
// In BYPASS mode, all residuals should be zero (no heat transfer)
assert_eq!(residuals[0], 0.0);
assert_eq!(residuals[1], 0.0);
assert_eq!(residuals[2], 0.0);
}
#[test]
fn test_circuit_id_via_builder() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchangerBuilder::new(model)
.circuit_id(CircuitId::new("circuit_1"))
.build();
assert_eq!(hx.circuit_id().as_str(), "circuit_1");
}
#[test]
fn test_with_circuit_id() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test").with_circuit_id(CircuitId::new("main"));
assert_eq!(hx.circuit_id().as_str(), "main");
}
// ===== Story 5.1: FluidBackend Integration Tests =====
#[test]
fn test_no_fluid_backend_by_default() {
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test");
assert!(!hx.has_fluid_backend());
}
#[test]
fn test_with_fluid_backend_sets_flag() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Test")
.with_fluid_backend(Arc::new(TestBackend::new()));
assert!(hx.has_fluid_backend());
}
#[test]
fn test_hx_side_conditions_construction() {
use entropyk_core::{MassFlow, Pressure, Temperature};
let conds = HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(25.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
);
assert!((conds.temperature_k() - 333.15).abs() < 0.01);
assert!((conds.pressure_pa() - 25.0e5).abs() < 1.0);
assert!((conds.mass_flow_kg_s() - 0.05).abs() < 1e-10);
assert_eq!(conds.fluid_id().0, "R410A");
}
#[test]
fn test_compute_residuals_with_backend_succeeds() {
/// Using TestBackend: Water on cold side, R410A on hot side.
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let hx = HeatExchanger::new(model, "Condenser")
.with_fluid_backend(Arc::new(TestBackend::new()))
.with_hot_conditions(HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
))
.with_cold_conditions(HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
));
let state = vec![0.0f64; 10];
let mut residuals = vec![0.0f64; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok(), "compute_residuals with FluidBackend should succeed");
}
#[test]
fn test_residuals_with_backend_vs_without_differ() {
/// Residuals computed with a real backend should differ from placeholder residuals
/// because real Cp and enthalpy values are used.
use entropyk_core::{MassFlow, Pressure, Temperature};
use entropyk_fluids::TestBackend;
use std::sync::Arc;
// Without backend (placeholder values)
let model1 = LmtdModel::counter_flow(5000.0);
let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");
let state = vec![0.0f64; 10];
let mut residuals_no_backend = vec![0.0f64; 3];
hx_no_backend.compute_residuals(&state, &mut residuals_no_backend).unwrap();
// With backend (real Water + R410A properties)
let model2 = LmtdModel::counter_flow(5000.0);
let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
.with_fluid_backend(Arc::new(TestBackend::new()))
.with_hot_conditions(HxSideConditions::new(
Temperature::from_celsius(60.0),
Pressure::from_bar(20.0),
MassFlow::from_kg_per_s(0.05),
"R410A",
))
.with_cold_conditions(HxSideConditions::new(
Temperature::from_celsius(30.0),
Pressure::from_pascals(102_000.0),
MassFlow::from_kg_per_s(0.2),
"Water",
));
let mut residuals_with_backend = vec![0.0f64; 3];
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend).unwrap();
// The energy balance residual (index 2) should differ because real Cp differs
// from the 1000.0/4180.0 hardcoded fallback values.
// (TestBackend returns Cp=1500 for refrigerants and 4184 for water,
// but temperatures and flows differ, so the residual WILL differ)
let residuals_are_different = residuals_no_backend
.iter()
.zip(residuals_with_backend.iter())
.any(|(a, b)| (a - b).abs() > 1e-6);
assert!(
residuals_are_different,
"Residuals with FluidBackend should differ from placeholder residuals"
);
}
#[test]
fn test_set_fluid_backend_mutable() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let model = LmtdModel::counter_flow(5000.0);
let mut hx = HeatExchanger::new(model, "Test");
assert!(!hx.has_fluid_backend());
hx.set_fluid_backend(Arc::new(TestBackend::new()));
assert!(hx.has_fluid_backend());
}
}