//! Evaporator Coil Component //! //! An air-side (finned) heat exchanger for refrigerant evaporation. //! The refrigerant (cold side) evaporates, absorbing heat from air (hot side). //! Used in split systems and air-source heat pumps. //! //! ## Port Convention //! //! - **Hot side (air)**: Heat source — connect to Fan outlet/inlet //! - **Cold side (refrigerant)**: Evaporating //! //! ## Integration with Fan //! //! Connect Fan outlet → EvaporatorCoil air inlet, EvaporatorCoil air outlet → Fan inlet. //! Use `FluidId::new("Air")` for air ports. use super::evaporator::Evaporator; use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; /// Evaporator coil (air-side finned heat exchanger). /// /// Explicit component for air-source evaporators. Uses ε-NTU method. /// Refrigerant evaporates on cold side, air on hot side. /// /// # Example /// /// ``` /// use entropyk_components::heat_exchanger::EvaporatorCoil; /// use entropyk_components::Component; /// /// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K /// assert_eq!(coil.ua(), 8_000.0); /// assert_eq!(coil.n_equations(), 3); /// ``` #[derive(Debug)] pub struct EvaporatorCoil { inner: Evaporator, air_validated: std::sync::atomic::AtomicBool, } impl EvaporatorCoil { /// Creates a new evaporator coil with the given UA value. /// /// # Arguments /// /// * `ua` - Overall heat transfer coefficient × Area (W/K) pub fn new(ua: f64) -> Self { Self { inner: Evaporator::new(ua), air_validated: std::sync::atomic::AtomicBool::new(false), } } /// Creates an evaporator coil with specific saturation and superheat. pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self { Self { inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target), air_validated: std::sync::atomic::AtomicBool::new(false), } } /// Returns the name of this component. pub fn name(&self) -> &str { "EvaporatorCoil" } /// Returns the UA value. pub fn ua(&self) -> f64 { self.inner.ua() } /// Returns the saturation temperature. pub fn saturation_temp(&self) -> f64 { self.inner.saturation_temp() } /// Returns the superheat target. pub fn superheat_target(&self) -> f64 { self.inner.superheat_target() } /// Sets the saturation temperature. pub fn set_saturation_temp(&mut self, temp: f64) { self.inner.set_saturation_temp(temp); } /// Sets the superheat target. pub fn set_superheat_target(&mut self, superheat: f64) { self.inner.set_superheat_target(superheat); } } impl Component for EvaporatorCoil { fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { if !self .air_validated .load(std::sync::atomic::Ordering::Relaxed) { if let Some(fluid_id) = self.inner.hot_fluid_id() { if fluid_id.0.as_str() != "Air" { return Err(ComponentError::InvalidState(format!( "EvaporatorCoil requires Air on the hot side, found {}", fluid_id.0.as_str() ))); } self.air_validated .store(true, std::sync::atomic::Ordering::Relaxed); } } self.inner.compute_residuals(state, residuals) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { self.inner.jacobian_entries(state, jacobian) } fn n_equations(&self) -> usize { self.inner.n_equations() } fn get_ports(&self) -> &[ConnectedPort] { self.inner.get_ports() } fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) { self.inner.set_calib_indices(indices); } fn port_mass_flows( &self, state: &StateSlice, ) -> Result, ComponentError> { self.inner.port_mass_flows(state) } fn port_enthalpies( &self, state: &StateSlice, ) -> Result, ComponentError> { self.inner.port_enthalpies(state) } fn energy_transfers( &self, state: &StateSlice, ) -> Option<(entropyk_core::Power, entropyk_core::Power)> { self.inner.energy_transfers(state) } } impl StateManageable for EvaporatorCoil { fn state(&self) -> OperationalState { self.inner.state() } fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { self.inner.set_state(state) } fn can_transition_to(&self, target: OperationalState) -> bool { self.inner.can_transition_to(target) } fn circuit_id(&self) -> &CircuitId { self.inner.circuit_id() } fn set_circuit_id(&mut self, circuit_id: CircuitId) { self.inner.set_circuit_id(circuit_id); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_evaporator_coil_creation() { let coil = EvaporatorCoil::new(8_000.0); assert_eq!(coil.ua(), 8_000.0); assert_eq!(coil.name(), "EvaporatorCoil"); } #[test] fn test_evaporator_coil_n_equations() { let coil = EvaporatorCoil::new(5_000.0); assert_eq!(coil.n_equations(), 2); } #[test] fn test_evaporator_coil_with_superheat() { let coil = EvaporatorCoil::with_superheat(8_000.0, 278.15, 5.0); assert_eq!(coil.saturation_temp(), 278.15); assert_eq!(coil.superheat_target(), 5.0); } #[test] fn test_evaporator_coil_compute_residuals() { let coil = EvaporatorCoil::new(8_000.0); let state = vec![0.0; 10]; let mut residuals = vec![0.0; 3]; let result = coil.compute_residuals(&state, &mut residuals); assert!(result.is_ok()); assert!( residuals.iter().all(|r| r.is_finite()), "residuals must be finite" ); } #[test] fn test_evaporator_coil_rejects_non_air() { use crate::heat_exchanger::HxSideConditions; use entropyk_core::{MassFlow, Pressure, Temperature}; let mut coil = EvaporatorCoil::new(8_000.0); coil.inner.set_hot_conditions( HxSideConditions::new( Temperature::from_celsius(20.0), Pressure::from_bar(1.0), MassFlow::from_kg_per_s(1.0), "Water", ) .expect("Valid hot conditions"), ); let state = vec![0.0; 10]; let mut residuals = vec![0.0; 3]; let result = coil.compute_residuals(&state, &mut residuals); assert!(result.is_err()); if let Err(ComponentError::InvalidState(msg)) = result { assert!(msg.contains("requires Air")); } else { panic!("Expected InvalidState error"); } } #[test] fn test_evaporator_coil_jacobian_entries() { let coil = EvaporatorCoil::new(8_000.0); let state = vec![0.0; 10]; let mut jacobian = crate::JacobianBuilder::new(); let result = coil.jacobian_entries(&state, &mut jacobian); assert!(result.is_ok()); // HeatExchanger base returns empty jacobian until framework implements it assert!( jacobian.is_empty(), "delegation works; empty jacobian expected until HeatExchanger implements entries" ); } #[test] fn test_evaporator_coil_setters() { let mut coil = EvaporatorCoil::new(8_000.0); coil.set_saturation_temp(275.0); coil.set_superheat_target(7.0); assert!((coil.saturation_temp() - 275.0).abs() < 1e-10); assert!((coil.superheat_target() - 7.0).abs() < 1e-10); } #[test] fn test_evaporator_coil_state_manageable() { use crate::state_machine::{OperationalState, StateManageable}; let mut coil = EvaporatorCoil::new(8_000.0); assert_eq!(coil.state(), OperationalState::On); assert!(coil.can_transition_to(OperationalState::Off)); assert!(coil.set_state(OperationalState::Off).is_ok()); assert_eq!(coil.state(), OperationalState::Off); } }