Sepehr fdd124eefd fix: resolve CLI solver state dimension mismatch
Removed mathematical singularity in HeatExchanger models (q_hot - q_cold = 0 was redundant) causing them to incorrectly request 3 equations without internal variables. Fixed ScrewEconomizerCompressor internal_state_len to perfectly align with the solver dimensions.
2026-02-28 22:45:51 +01:00

729 lines
24 KiB
Rust

//! Drum - Recirculation Drum for Flooded Evaporators
//!
//! This module provides a recirculation drum component that separates a two-phase
//! mixture into saturated liquid and saturated vapor. Used in recirculation
//! evaporator systems to improve heat transfer.
//!
//! ## Physical Description
//!
//! A recirculation drum receives:
//! 1. Feed from economizer (typically subcooled or two-phase)
//! 2. Return from evaporator (enriched two-phase)
//!
//! And separates into:
//! - Saturated liquid (x=0) to the recirculation pump
//! - Saturated vapor (x=1) to the compressor
//!
//! ## Equations (8 total)
//!
//! | # | Equation | Description |
//! |---|----------|-------------|
//! | 1 | `m_liq + m_vap = m_feed + m_return` | Mass balance |
//! | 2 | `m_liq * h_liq + m_vap * h_vap = m_total * h_mixed` | Energy balance |
//! | 3 | `P_liq - P_feed = 0` | Pressure equality (liquid) |
//! | 4 | `P_vap - P_feed = 0` | Pressure equality (vapor) |
//! | 5 | `h_liq - h_sat(P, x=0) = 0` | Saturated liquid |
//! | 6 | `h_vap - h_sat(P, x=1) = 0` | Saturated vapor |
//! | 7-8 | Fluid continuity | Implicit via FluidId |
//!
//! ## Example
//!
//! ```ignore
//! use entropyk_components::Drum;
//! use entropyk_components::port::{Port, FluidId};
//! use entropyk_core::{Pressure, Enthalpy};
//! use entropyk_fluids::CoolPropBackend;
//! use std::sync::Arc;
//!
//! let backend = Arc::new(CoolPropBackend::new());
//!
//! let feed_inlet = Port::new(FluidId::new("R410A"), Pressure::from_bar(10.0), Enthalpy::from_kj_per_kg(250.0));
//! let evaporator_return = Port::new(FluidId::new("R410A"), Pressure::from_bar(10.0), Enthalpy::from_kj_per_kg(350.0));
//! let liquid_outlet = Port::new(FluidId::new("R410A"), Pressure::from_bar(10.0), Enthalpy::from_kj_per_kg(200.0));
//! let vapor_outlet = Port::new(FluidId::new("R410A"), Pressure::from_bar(10.0), Enthalpy::from_kj_per_kg(420.0));
//!
//! let drum = Drum::new("R410A", feed_inlet, evaporator_return, liquid_outlet, vapor_outlet, backend)?;
//! ```
use crate::port::{ConnectedPort, FluidId};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice};
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
use entropyk_fluids::{FluidBackend, FluidState, Property, Quality};
use std::sync::Arc;
/// Drum - Recirculation drum for flooded evaporator systems.
///
/// Separates a two-phase mixture (2 inlets) into:
/// - Saturated liquid (x=0) to the recirculation pump
/// - Saturated vapor (x=1) to the compressor
///
/// The drum requires a [`FluidBackend`] to calculate saturation properties.
pub struct Drum {
/// Fluid identifier (must be pure or pseudo-pure for saturation calculations)
fluid_id: String,
/// Feed inlet (from economizer)
feed_inlet: ConnectedPort,
/// Evaporator return (two-phase enriched)
evaporator_return: ConnectedPort,
/// Liquid outlet (saturated, x=0) to pump
liquid_outlet: ConnectedPort,
/// Vapor outlet (saturated, x=1) to compressor
vapor_outlet: ConnectedPort,
/// Fluid backend for saturation calculations
fluid_backend: Arc<dyn FluidBackend>,
/// Circuit identifier
circuit_id: CircuitId,
/// Operational state
operational_state: OperationalState,
}
impl std::fmt::Debug for Drum {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Drum")
.field("fluid_id", &self.fluid_id)
.field("circuit_id", &self.circuit_id)
.field("operational_state", &self.operational_state)
.finish()
}
}
impl Drum {
/// Creates a new recirculation drum.
///
/// # Arguments
///
/// * `fluid` - Fluid identifier (e.g., "R410A", "R134a")
/// * `feed_inlet` - Feed inlet port (from economizer)
/// * `evaporator_return` - Evaporator return port (two-phase)
/// * `liquid_outlet` - Liquid outlet port (to pump)
/// * `vapor_outlet` - Vapor outlet port (to compressor)
/// * `backend` - Fluid backend for saturation calculations
///
/// # Errors
///
/// Returns an error if ports have incompatible fluids.
pub fn new(
fluid: impl Into<String>,
feed_inlet: ConnectedPort,
evaporator_return: ConnectedPort,
liquid_outlet: ConnectedPort,
vapor_outlet: ConnectedPort,
backend: Arc<dyn FluidBackend>,
) -> Result<Self, ComponentError> {
let fluid_id = fluid.into();
Self::validate_fluids(
&fluid_id,
&feed_inlet,
&evaporator_return,
&liquid_outlet,
&vapor_outlet,
)?;
Ok(Self {
fluid_id,
feed_inlet,
evaporator_return,
liquid_outlet,
vapor_outlet,
fluid_backend: backend,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
})
}
fn validate_fluids(
expected: &str,
feed: &ConnectedPort,
ret: &ConnectedPort,
liq: &ConnectedPort,
vap: &ConnectedPort,
) -> Result<(), ComponentError> {
let expected_fluid = FluidId::new(expected);
if feed.fluid_id() != &expected_fluid {
return Err(ComponentError::InvalidState(format!(
"Drum feed_inlet fluid mismatch: expected {}, got {}",
expected,
feed.fluid_id().as_str()
)));
}
if ret.fluid_id() != &expected_fluid {
return Err(ComponentError::InvalidState(format!(
"Drum evaporator_return fluid mismatch: expected {}, got {}",
expected,
ret.fluid_id().as_str()
)));
}
if liq.fluid_id() != &expected_fluid {
return Err(ComponentError::InvalidState(format!(
"Drum liquid_outlet fluid mismatch: expected {}, got {}",
expected,
liq.fluid_id().as_str()
)));
}
if vap.fluid_id() != &expected_fluid {
return Err(ComponentError::InvalidState(format!(
"Drum vapor_outlet fluid mismatch: expected {}, got {}",
expected,
vap.fluid_id().as_str()
)));
}
Ok(())
}
/// Returns the fluid identifier.
pub fn fluid_id(&self) -> &str {
&self.fluid_id
}
/// Returns the feed inlet port.
pub fn feed_inlet(&self) -> &ConnectedPort {
&self.feed_inlet
}
/// Returns the evaporator return port.
pub fn evaporator_return(&self) -> &ConnectedPort {
&self.evaporator_return
}
/// Returns the liquid outlet port.
pub fn liquid_outlet(&self) -> &ConnectedPort {
&self.liquid_outlet
}
/// Returns the vapor outlet port.
pub fn vapor_outlet(&self) -> &ConnectedPort {
&self.vapor_outlet
}
/// Returns the recirculation ratio (m_liquid / m_feed).
///
/// Requires mass flow information to be available in the state vector.
/// Returns 0.0 if mass flow cannot be determined (e.g., zero feed flow).
///
/// # Arguments
///
/// * `state` - State vector containing mass flows at indices 0-3:
/// - state[0]: m_feed (feed inlet mass flow)
/// - state[1]: m_return (evaporator return mass flow)
/// - state[2]: m_liq (liquid outlet mass flow, positive = out)
/// - state[3]: m_vap (vapor outlet mass flow, positive = out)
pub fn recirculation_ratio(&self, state: &StateSlice) -> f64 {
if state.len() < 4 {
return 0.0;
}
let m_feed = state[0];
let m_liq = state[2]; // Liquid outlet flow (positive = leaving drum)
if m_feed.abs() < 1e-10 {
0.0
} else {
m_liq / m_feed
}
}
/// Gets saturated liquid enthalpy at a given pressure.
fn saturated_liquid_enthalpy(&self, pressure_pa: f64) -> Result<f64, ComponentError> {
let fluid = FluidId::new(&self.fluid_id);
let state = FluidState::from_px(Pressure::from_pascals(pressure_pa), Quality(0.0));
self.fluid_backend
.property(fluid, Property::Enthalpy, state)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to get saturated liquid enthalpy: {}",
e
))
})
}
/// Gets saturated vapor enthalpy at a given pressure.
fn saturated_vapor_enthalpy(&self, pressure_pa: f64) -> Result<f64, ComponentError> {
let fluid = FluidId::new(&self.fluid_id);
let state = FluidState::from_px(Pressure::from_pascals(pressure_pa), Quality(1.0));
self.fluid_backend
.property(fluid, Property::Enthalpy, state)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to get saturated vapor enthalpy: {}",
e
))
})
}
}
impl Clone for Drum {
fn clone(&self) -> Self {
Self {
fluid_id: self.fluid_id.clone(),
feed_inlet: self.feed_inlet.clone(),
evaporator_return: self.evaporator_return.clone(),
liquid_outlet: self.liquid_outlet.clone(),
vapor_outlet: self.vapor_outlet.clone(),
fluid_backend: Arc::clone(&self.fluid_backend),
circuit_id: self.circuit_id,
operational_state: self.operational_state,
}
}
}
impl Component for Drum {
/// Returns 8 equations:
/// - 1 mass balance
/// - 1 energy balance
/// - 2 pressure equalities
/// - 2 saturation constraints
/// - 2 fluid continuity (implicit)
fn n_equations(&self) -> usize {
8
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n_eqs = self.n_equations();
if residuals.len() < n_eqs {
return Err(ComponentError::InvalidResidualDimensions {
expected: n_eqs,
actual: residuals.len(),
});
}
if state.len() < 4 {
return Err(ComponentError::InvalidStateDimensions {
expected: 4,
actual: state.len(),
});
}
// State variables:
// state[0]: m_feed (feed inlet mass flow, kg/s)
// state[1]: m_return (evaporator return mass flow, kg/s)
// state[2]: m_liq (liquid outlet mass flow, kg/s, positive = leaving drum)
// state[3]: m_vap (vapor outlet mass flow, kg/s, positive = leaving drum)
let m_feed = state[0];
let m_return = state[1];
let m_liq = state[2];
let m_vap = state[3];
let p_feed = self.feed_inlet.pressure().to_pascals();
let h_feed = self.feed_inlet.enthalpy().to_joules_per_kg();
let h_return = self.evaporator_return.enthalpy().to_joules_per_kg();
let p_liq = self.liquid_outlet.pressure().to_pascals();
let h_liq = self.liquid_outlet.enthalpy().to_joules_per_kg();
let p_vap = self.vapor_outlet.pressure().to_pascals();
let h_vap = self.vapor_outlet.enthalpy().to_joules_per_kg();
let h_sat_l = self.saturated_liquid_enthalpy(p_feed)?;
let h_sat_v = self.saturated_vapor_enthalpy(p_feed)?;
let mut idx = 0;
// Equation 1: Pressure equality (liquid)
// P_liq - P_feed = 0
residuals[idx] = p_liq - p_feed;
idx += 1;
// Equation 2: Pressure equality (vapor)
// P_vap - P_feed = 0
residuals[idx] = p_vap - p_feed;
idx += 1;
// Equation 3: Saturated liquid constraint
// h_liq - h_sat(P, x=0) = 0
residuals[idx] = h_liq - h_sat_l;
idx += 1;
// Equation 4: Saturated vapor constraint
// h_vap - h_sat(P, x=1) = 0
residuals[idx] = h_vap - h_sat_v;
idx += 1;
// Equation 5: Mass balance
// m_liq + m_vap = m_feed + m_return
// Residual: (m_liq + m_vap) - (m_feed + m_return) = 0
residuals[idx] = (m_liq + m_vap) - (m_feed + m_return);
idx += 1;
// Equation 6: Energy balance
// m_liq * h_liq + m_vap * h_vap = m_feed * h_feed + m_return * h_return
// Residual: (m_liq * h_liq + m_vap * h_vap) - (m_feed * h_feed + m_return * h_return) = 0
let energy_out = m_liq * h_liq + m_vap * h_vap;
let energy_in = m_feed * h_feed + m_return * h_return;
residuals[idx] = energy_out - energy_in;
idx += 1;
// Equations 7-8: Fluid continuity (implicit, enforced by using same fluid_id)
// These are satisfied by construction since all ports use the same fluid
residuals[idx] = 0.0;
idx += 1;
residuals[idx] = 0.0;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let n_eqs = self.n_equations();
for i in 0..n_eqs {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
// Note: This is a temporary implementation that returns an empty slice.
// To properly return the ports, we would need to store them in a Vec
// or use a different approach. For now, we document the ports here:
// - Port 0: feed_inlet (from economizer)
// - Port 1: evaporator_return (two-phase enriched)
// - Port 2: liquid_outlet (to pump)
// - Port 3: vapor_outlet (to compressor)
&[]
}
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
fn port_mass_flows(&self, state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
if state.len() < 4 {
return Err(ComponentError::InvalidStateDimensions {
expected: 4,
actual: state.len(),
});
}
Ok(vec![
MassFlow::from_kg_per_s(state[0]),
MassFlow::from_kg_per_s(state[1]),
MassFlow::from_kg_per_s(-state[2]),
MassFlow::from_kg_per_s(-state[3]),
])
}
fn port_enthalpies(&self, _state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
Ok(vec![
self.feed_inlet.enthalpy(),
self.evaporator_return.enthalpy(),
self.liquid_outlet.enthalpy(),
self.vapor_outlet.enthalpy(),
])
}
fn signature(&self) -> String {
format!("Drum({})", self.fluid_id)
}
}
impl StateManageable for Drum {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
self.operational_state = state;
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::port::Port;
fn create_connected_port(fluid: &str, pressure_pa: f64, enthalpy_j_kg: f64) -> ConnectedPort {
let p1 = Port::new(
FluidId::new(fluid),
Pressure::from_pascals(pressure_pa),
Enthalpy::from_joules_per_kg(enthalpy_j_kg),
);
let p2 = Port::new(
FluidId::new(fluid),
Pressure::from_pascals(pressure_pa),
Enthalpy::from_joules_per_kg(enthalpy_j_kg),
);
let (c1, _c2) = p1.connect(p2).expect("ports should connect");
c1
}
fn create_test_drum() -> Drum {
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let feed_inlet = create_connected_port("R410A", 1_000_000.0, 250_000.0);
let evaporator_return = create_connected_port("R410A", 1_000_000.0, 350_000.0);
let liquid_outlet = create_connected_port("R410A", 1_000_000.0, 200_000.0);
let vapor_outlet = create_connected_port("R410A", 1_000_000.0, 400_000.0);
Drum::new(
"R410A",
feed_inlet,
evaporator_return,
liquid_outlet,
vapor_outlet,
backend,
)
.expect("drum should be created")
}
#[test]
fn test_drum_equations_count() {
let drum = create_test_drum();
assert_eq!(drum.n_equations(), 8);
}
#[test]
fn test_drum_fluid_id() {
let drum = create_test_drum();
assert_eq!(drum.fluid_id(), "R410A");
}
#[test]
fn test_drum_energy_transfers() {
let drum = create_test_drum();
let state: Vec<f64> = vec![];
let (heat, work) = drum.energy_transfers(&state).unwrap();
assert_eq!(heat.to_watts(), 0.0);
assert_eq!(work.to_watts(), 0.0);
}
#[test]
fn test_drum_state_manageable() {
let drum = create_test_drum();
assert_eq!(drum.state(), OperationalState::On);
assert!(drum.can_transition_to(OperationalState::Off));
assert!(drum.can_transition_to(OperationalState::Bypass));
}
#[test]
fn test_drum_compute_residuals() {
let drum = create_test_drum();
let state: Vec<f64> = vec![0.1, 0.2, 0.15, 0.05];
let mut residuals = vec![0.0; 8];
let result = drum.compute_residuals(&state, &mut residuals);
// TestBackend doesn't support FluidState::from_px for saturation queries,
// so the computation will fail. This is expected - the Drum component
// requires a real backend (CoolProp) for saturation properties.
// We test that the method correctly propagates the error.
assert!(
result.is_err(),
"Expected error from TestBackend (doesn't support from_px)"
);
// Verify error message mentions saturation
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("saturated") || err_msg.contains("UnsupportedProperty"),
"Error should mention saturation or unsupported property: {}",
err_msg
);
}
#[test]
fn test_drum_jacobian_entries() {
let drum = create_test_drum();
let state: Vec<f64> = vec![];
let mut jacobian = JacobianBuilder::new();
let result = drum.jacobian_entries(&state, &mut jacobian);
assert!(result.is_ok());
assert_eq!(jacobian.len(), 8);
}
#[test]
fn test_drum_invalid_state_dimensions() {
let drum = create_test_drum();
let state: Vec<f64> = vec![];
let mut residuals = vec![0.0; 4];
let result = drum.compute_residuals(&state, &mut residuals);
assert!(result.is_err());
}
#[test]
fn test_drum_port_mass_flows() {
let drum = create_test_drum();
let state: Vec<f64> = vec![0.1, 0.2, 0.15, 0.05];
let flows = drum.port_mass_flows(&state).unwrap();
assert_eq!(flows.len(), 4);
assert!((flows[0].to_kg_per_s() - 0.1).abs() < 1e-10);
assert!((flows[1].to_kg_per_s() - 0.2).abs() < 1e-10);
assert!((flows[2].to_kg_per_s() - (-0.15)).abs() < 1e-10);
assert!((flows[3].to_kg_per_s() - (-0.05)).abs() < 1e-10);
}
#[test]
fn test_drum_port_enthalpies() {
let drum = create_test_drum();
let state: Vec<f64> = vec![];
let enthalpies = drum.port_enthalpies(&state).unwrap();
assert_eq!(enthalpies.len(), 4);
assert!((enthalpies[0].to_joules_per_kg() - 250_000.0).abs() < 1e-10);
assert!((enthalpies[1].to_joules_per_kg() - 350_000.0).abs() < 1e-10);
assert!((enthalpies[2].to_joules_per_kg() - 200_000.0).abs() < 1e-10);
assert!((enthalpies[3].to_joules_per_kg() - 400_000.0).abs() < 1e-10);
}
#[test]
fn test_drum_signature() {
let drum = create_test_drum();
assert_eq!(drum.signature(), "Drum(R410A)");
}
#[test]
fn test_drum_fluid_mismatch() {
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let feed_inlet = create_connected_port("R410A", 1_000_000.0, 250_000.0);
let evaporator_return = create_connected_port("R134a", 1_000_000.0, 350_000.0);
let liquid_outlet = create_connected_port("R410A", 1_000_000.0, 200_000.0);
let vapor_outlet = create_connected_port("R410A", 1_000_000.0, 400_000.0);
let result = Drum::new(
"R410A",
feed_inlet,
evaporator_return,
liquid_outlet,
vapor_outlet,
backend,
);
assert!(result.is_err());
}
#[test]
fn test_drum_state_transition() {
let mut drum = create_test_drum();
assert!(drum.set_state(OperationalState::Off).is_ok());
assert_eq!(drum.state(), OperationalState::Off);
assert!(drum.set_state(OperationalState::Bypass).is_ok());
assert_eq!(drum.state(), OperationalState::Bypass);
}
#[test]
fn test_drum_circuit_id() {
let mut drum = create_test_drum();
let new_id = CircuitId::from_number(42);
drum.set_circuit_id(new_id);
assert_eq!(drum.circuit_id(), &new_id);
}
#[test]
fn test_drum_clone() {
let drum = create_test_drum();
let cloned = drum.clone();
assert_eq!(drum.fluid_id(), cloned.fluid_id());
assert_eq!(drum.n_equations(), cloned.n_equations());
}
#[test]
fn test_drum_debug() {
let drum = create_test_drum();
let debug_str = format!("{:?}", drum);
assert!(debug_str.contains("Drum"));
assert!(debug_str.contains("R410A"));
}
#[test]
fn test_drum_recirculation_ratio_basic() {
let drum = create_test_drum();
// state[0] = m_feed, state[2] = m_liq
// ratio = m_liq / m_feed
let state = vec![0.1, 0.2, 0.25, 0.05]; // m_feed=0.1, m_liq=0.25
let ratio = drum.recirculation_ratio(&state);
assert!(
(ratio - 2.5).abs() < 1e-10,
"Expected ratio 2.5, got {}",
ratio
);
}
#[test]
fn test_drum_recirculation_ratio_zero_feed() {
let drum = create_test_drum();
// Zero feed flow should return 0.0 (avoid division by zero)
let state = vec![0.0, 0.2, 0.15, 0.05];
let ratio = drum.recirculation_ratio(&state);
assert_eq!(ratio, 0.0, "Expected ratio 0.0 for zero feed flow");
}
#[test]
fn test_drum_recirculation_ratio_small_feed() {
let drum = create_test_drum();
// Very small feed flow should return 0.0 (avoid numerical issues)
let state = vec![1e-12, 0.2, 0.15, 0.05];
let ratio = drum.recirculation_ratio(&state);
assert_eq!(ratio, 0.0, "Expected ratio 0.0 for very small feed flow");
}
#[test]
fn test_drum_recirculation_ratio_empty_state() {
let drum = create_test_drum();
// Empty state should return 0.0
let state: Vec<f64> = vec![];
let ratio = drum.recirculation_ratio(&state);
assert_eq!(ratio, 0.0, "Expected ratio 0.0 for empty state");
}
#[test]
fn test_drum_recirculation_ratio_insufficient_state() {
let drum = create_test_drum();
// State with less than 4 elements should return 0.0
let state = vec![0.1, 0.2, 0.15]; // Only 3 elements
let ratio = drum.recirculation_ratio(&state);
assert_eq!(ratio, 0.0, "Expected ratio 0.0 for insufficient state");
}
#[test]
fn test_drum_recirculation_ratio_unity() {
let drum = create_test_drum();
// When m_liq = m_feed, ratio should be 1.0
let state = vec![0.1, 0.1, 0.1, 0.1]; // m_feed=0.1, m_liq=0.1
let ratio = drum.recirculation_ratio(&state);
assert!(
(ratio - 1.0).abs() < 1e-10,
"Expected ratio 1.0, got {}",
ratio
);
}
}