//! 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, /// 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, feed_inlet: ConnectedPort, evaporator_return: ConnectedPort, liquid_outlet: ConnectedPort, vapor_outlet: ConnectedPort, backend: Arc, ) -> Result { 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 { 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 { 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, 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, 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 ); } }