//! Condenser Coil Component //! //! An air-side (finned) heat exchanger for refrigerant condensation. //! The refrigerant (hot side) condenses, releasing heat to air (cold side). //! Used in split systems and air-source heat pumps. //! //! ## Port Convention //! //! - **Hot side (refrigerant)**: Condensing //! - **Cold side (air)**: Heat sink — connect to Fan outlet/inlet //! //! ## Integration with Fan //! //! Connect Fan outlet → CondenserCoil air inlet, CondenserCoil air outlet → Fan inlet. //! Use `FluidId::new("Air")` for air ports. use super::condenser::Condenser; use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; /// Condenser coil (air-side finned heat exchanger). /// /// Explicit component for air-source condensers. Uses LMTD method. /// Refrigerant condenses on hot side, air on cold side. /// /// # Example /// /// ``` /// use entropyk_components::heat_exchanger::CondenserCoil; /// use entropyk_components::Component; /// /// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K /// assert_eq!(coil.ua(), 10_000.0); /// assert_eq!(coil.n_equations(), 2); /// ``` #[derive(Debug)] pub struct CondenserCoil { inner: Condenser, air_validated: std::sync::atomic::AtomicBool, } impl CondenserCoil { /// Creates a new condenser coil with the given UA value. /// /// # Arguments /// /// * `ua` - Overall heat transfer coefficient × Area (W/K) pub fn new(ua: f64) -> Self { Self { inner: Condenser::new(ua), air_validated: std::sync::atomic::AtomicBool::new(false), } } /// Creates a condenser coil with a specific saturation temperature. pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self { Self { inner: Condenser::with_saturation_temp(ua, saturation_temp), air_validated: std::sync::atomic::AtomicBool::new(false), } } /// Returns the name of this component. pub fn name(&self) -> &str { "CondenserCoil" } /// 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() } /// Sets the saturation temperature. pub fn set_saturation_temp(&mut self, temp: f64) { self.inner.set_saturation_temp(temp); } } impl Component for CondenserCoil { 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.cold_fluid_id() { if fluid_id.0.as_str() != "Air" { return Err(ComponentError::InvalidState(format!( "CondenserCoil requires Air on the cold 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) } fn signature(&self) -> String { self.inner.signature() } fn to_params(&self) -> crate::ComponentParams { self.inner.to_params() } fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool { self.inner.update_calib_factor(factor, value) } } impl StateManageable for CondenserCoil { 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_condenser_coil_creation() { let coil = CondenserCoil::new(10_000.0); assert_eq!(coil.ua(), 10_000.0); assert_eq!(coil.name(), "CondenserCoil"); } #[test] fn test_condenser_coil_n_equations() { let coil = CondenserCoil::new(10_000.0); assert_eq!(coil.n_equations(), 2); } #[test] fn test_condenser_coil_with_saturation_temp() { let coil = CondenserCoil::with_saturation_temp(10_000.0, 323.15); assert_eq!(coil.saturation_temp(), 323.15); } #[test] fn test_condenser_coil_compute_residuals() { let coil = CondenserCoil::new(10_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_condenser_coil_rejects_non_air() { use crate::heat_exchanger::HxSideConditions; use entropyk_core::{MassFlow, Pressure, Temperature}; let mut coil = CondenserCoil::new(10_000.0); coil.inner.set_cold_conditions( HxSideConditions::new( Temperature::from_celsius(20.0), Pressure::from_bar(1.0), MassFlow::from_kg_per_s(1.0), "Water", ) .expect("Valid cold 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_condenser_coil_jacobian_entries() { let coil = CondenserCoil::new(10_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_condenser_coil_set_saturation_temp() { let mut coil = CondenserCoil::new(10_000.0); coil.set_saturation_temp(320.0); assert!((coil.saturation_temp() - 320.0).abs() < 1e-10); } #[test] fn test_condenser_coil_state_manageable() { use crate::state_machine::{OperationalState, StateManageable}; let mut coil = CondenserCoil::new(10_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); } }