//! Air-Cooled Condenser Component //! //! A condenseur à condensation par air où l'ingénieur fournit directement : //! - `oat_k` : Outdoor Air Temperature [K] (OAT = température de l'air extérieur) //! - `approach_k` : Approach temperature [K], différence de température entre la //! condensation et l'air extérieur. Valeur typique : 10–15 K. //! Défaut : 12 K (représentatif d'un chiller air-cooled industriel). //! //! La température de condensation est calculée automatiquement : //! ```text //! T_cond = OAT + approach_k //! ``` //! //! ## Équations (2) //! //! - r0 = P_out − P_sat(T_cond) [drive outlet pressure to condensing saturation] //! - r1 = H_out − H_sat_liq(T_cond) [drive outlet enthalpy to saturated-liquid] //! //! ## Jacobian (analytique) //! //! - ∂r0/∂P_out = 1 //! - ∂r1/∂H_out = 1 //! //! ## Exemple JSON //! //! ```json //! { //! "type": "AirCooledCondenser", //! "name": "cond", //! "oat_k": 308.15, //! "approach_k": 12.0 //! } //! ``` //! //! Soit : OAT = 35°C → T_cond = 47°C → P_cond_sat(R290) ≈ 16.8 bar use super::condenser::Condenser; use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; use entropyk_core::Calib; use entropyk_fluids::FluidBackend; use std::sync::Arc; /// Air-cooled condenser : T_cond = OAT + approach. /// /// L'ingénieur saisit directement la température extérieure (OAT) et /// l'approche de condensation (approach_k). Aucune conversion manuelle /// en `t_sat_k` n'est nécessaire. /// /// # Example /// /// ``` /// use entropyk_components::heat_exchanger::AirCooledCondenser; /// use entropyk_components::Component; /// /// // OAT = 35°C, approach = 12 K → T_cond = 47°C = 320.15 K /// let cond = AirCooledCondenser::new(308.15, 12.0); /// assert_eq!(cond.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3) /// assert!((cond.t_cond_k() - 320.15).abs() < 1e-9); /// ``` #[derive(Debug)] pub struct AirCooledCondenser { inner: Condenser, oat_k: f64, approach_k: f64, } impl AirCooledCondenser { /// Crée un condenseur à air. /// /// # Arguments /// /// * `oat_k` — Outdoor Air Temperature \[K\] /// * `approach_k` — Approach temperature = T_cond − OAT \[K\]. Valeur typique : 10–15 K. pub fn new(oat_k: f64, approach_k: f64) -> Self { let t_cond_k = oat_k + approach_k; Self { inner: Condenser::with_saturation_temp(0.0, t_cond_k), oat_k, approach_k, } } /// Crée un condenseur à air avec UA connu. /// /// Le UA n'est pas utilisé dans les équations du solver (approach-based), /// mais il est disponible pour les calculs de performance post-traitement. pub fn with_ua(oat_k: f64, approach_k: f64, ua: f64) -> Self { let t_cond_k = oat_k + approach_k; Self { inner: Condenser::with_saturation_temp(ua, t_cond_k), oat_k, approach_k, } } /// Attache un identifiant de fluide réfrigérant. pub fn with_refrigerant(mut self, id: &str) -> Self { self.inner = self.inner.with_refrigerant(id); self } /// Attache un backend de propriétés thermodynamiques. pub fn with_fluid_backend(mut self, backend: Arc) -> Self { self.inner = self.inner.with_fluid_backend(backend); self } /// Retourne la température de condensation calculée [K]. pub fn t_cond_k(&self) -> f64 { self.oat_k + self.approach_k } /// Retourne la température extérieure (OAT) [K]. pub fn oat_k(&self) -> f64 { self.oat_k } /// Retourne l'approche de condensation [K]. pub fn approach_k(&self) -> f64 { self.approach_k } /// Retourne le UA [W/K]. pub fn ua(&self) -> f64 { self.inner.ua() } /// Retourne les facteurs de calibration. pub fn calib(&self) -> &Calib { self.inner.calib() } /// Met à jour les facteurs de calibration. pub fn set_calib(&mut self, calib: Calib) { self.inner.set_calib(calib); } /// Met à jour OAT en runtime (ex. simulation paramétrique). pub fn set_oat_k(&mut self, oat_k: f64) { self.oat_k = oat_k; self.inner.set_saturation_temp(self.oat_k + self.approach_k); } /// Met à jour l'approche en runtime. pub fn set_approach_k(&mut self, approach_k: f64) { self.approach_k = approach_k; self.inner.set_saturation_temp(self.oat_k + self.approach_k); } } impl Component for AirCooledCondenser { fn set_system_context( &mut self, state_offset: usize, external_edge_state_indices: &[(usize, usize, usize)], ) { self.inner .set_system_context(state_offset, external_edge_state_indices); } fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { 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_fluid_backend_from_builder(&mut self, backend: Arc) { self.inner.set_fluid_backend_from_builder(backend); } 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 { format!( "AirCooledCondenser(oat={:.1}K, approach={:.1}K, t_cond={:.1}K)", self.oat_k, self.approach_k, self.t_cond_k() ) } 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 AirCooledCondenser { 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_new_t_cond_computed() { // OAT = 35°C = 308.15 K, approach = 12 K → T_cond = 47°C = 320.15 K let cond = AirCooledCondenser::new(308.15, 12.0); assert!((cond.t_cond_k() - 320.15).abs() < 1e-9); assert_eq!(cond.oat_k(), 308.15); assert_eq!(cond.approach_k(), 12.0); // CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser) assert_eq!(cond.n_equations(), 3); } #[test] fn test_with_ua() { let cond = AirCooledCondenser::with_ua(308.15, 12.0, 6000.0); assert_eq!(cond.ua(), 6000.0); assert!((cond.t_cond_k() - 320.15).abs() < 1e-9); } #[test] fn test_set_oat_updates_t_cond() { let mut cond = AirCooledCondenser::new(308.15, 12.0); // Change OAT to 40°C = 313.15 K → T_cond = 52°C = 325.15 K cond.set_oat_k(313.15); assert!((cond.t_cond_k() - 325.15).abs() < 1e-9); } #[test] fn test_set_approach_updates_t_cond() { let mut cond = AirCooledCondenser::new(308.15, 12.0); cond.set_approach_k(10.0); assert!((cond.t_cond_k() - 318.15).abs() < 1e-9); } #[test] fn test_no_backend_does_not_fabricate_residuals() { let cond = AirCooledCondenser::new(308.15, 12.0); let state = vec![0.0_f64; 10]; let mut residuals = vec![99.0_f64; 3]; let result = cond.compute_residuals(&state, &mut residuals); assert!(matches!(result, Err(ComponentError::InvalidState(_)))); } }