//! MovingBoundaryHX - Zone Discretization Heat Exchanger Component //! //! A heat exchanger component that discretizes the heat transfer area into //! phase zones (superheated, two-phase, subcooled) for more accurate modeling //! of refrigerant-side heat transfer. use super::bphx_correlation::CorrelationSelector; use super::bphx_geometry::BphxGeometry; use super::eps_ntu::EpsNtuModel; use super::exchanger::HeatExchanger; use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, }; use entropyk_core::{Enthalpy, MassFlow, Power}; use std::cell::{Cell, RefCell}; use std::sync::Arc; /// Zone type for refrigerant-side phase classification #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ZoneType { /// Superheated vapor zone (T > Tsat) Superheated, /// Two-phase zone (mixture of liquid and vapor) #[default] TwoPhase, /// Subcooled liquid zone (T < Tsat) Subcooled, } /// Zone boundary with relative position and zone type #[derive(Debug, Clone)] pub struct ZoneBoundary { /// Relative position along the heat exchanger (0.0 to 1.0) pub position: f64, /// Zone type at this boundary pub zone_type: ZoneType, /// UA value for this zone (W/K) pub ua: f64, /// Hot-side temperature at this boundary (K) pub t_hot: f64, /// Cold-side temperature at this boundary (K) pub t_cold: f64, /// Vapor quality at this boundary (0-1 for two-phase) pub quality: f64, } /// Zone discretization result containing all zones and summary data #[derive(Debug, Clone, Default)] pub struct ZoneDiscretization { /// List of zone boundaries (ordered by position) pub boundaries: Vec, /// Total UA (sum of all zone UAs) (W/K) pub total_ua: f64, /// Pinch temperature (minimum temperature difference) (K) pub pinch_temp: f64, /// Position of pinch point (relative, 0.0 to 1.0) pub pinch_position: f64, } /// Cache for MovingBoundaryHX calculations #[derive(Debug, Clone, Default)] pub struct MovingBoundaryCache { /// Whether the cache is valid and initialized pub valid: bool, /// Reference pressure for cache validity (Pa) pub p_ref: f64, /// Reference mass flow for cache validity (kg/s) pub m_ref: f64, /// Cached liquid saturation enthalpy (J/kg) pub h_sat_l: f64, /// Cached vapor saturation enthalpy (J/kg) pub h_sat_v: f64, /// Cached zone discretization result pub discretization: ZoneDiscretization, } impl MovingBoundaryCache { /// Checks if the cache remains valid given the current pressure and mass flow. /// Cache is valid if pressure deviates < 5% and mass flow deviates < 10%. pub fn is_valid_for(&self, p_current: f64, m_current: f64) -> bool { if !self.valid { return false; } let p_dev = (p_current - self.p_ref).abs() / self.p_ref.max(1e-10); let m_dev = (m_current - self.m_ref).abs() / self.m_ref.max(1e-10); p_dev < 0.05 && m_dev < 0.10 } } /// MovingBoundaryHX - Zone discretization heat exchanger component pub struct MovingBoundaryHX { inner: HeatExchanger, geometry: BphxGeometry, _correlation_selector: CorrelationSelector, refrigerant_id: String, secondary_fluid_id: String, fluid_backend: Option>, // Discretization parameters n_discretization: usize, cache: RefCell, // Internal state caching _last_htc: Cell, _last_validity_warning: Cell, } impl Default for MovingBoundaryHX { fn default() -> Self { Self::new() } } impl MovingBoundaryHX { /// Creates a new `MovingBoundaryHX` with default settings and 51 discretization points. pub fn new() -> Self { let geometry = BphxGeometry::from_dh_area(0.003, 0.5, 20); let model = EpsNtuModel::counter_flow(1000.0); Self { inner: HeatExchanger::new(model, "MovingBoundaryHX"), geometry, _correlation_selector: CorrelationSelector::default(), refrigerant_id: String::new(), secondary_fluid_id: String::new(), fluid_backend: None, n_discretization: 51, cache: RefCell::new(MovingBoundaryCache::default()), _last_htc: Cell::new(0.0), _last_validity_warning: Cell::new(false), } } /// Returns the number of discretization points. pub fn n_discretization(&self) -> usize { self.n_discretization } /// Sets the number of discretization points and returns self. pub fn with_discretization(mut self, n: usize) -> Self { self.n_discretization = n; self } /// Sets the geometry specification. pub fn with_geometry(mut self, geometry: BphxGeometry) -> Self { self.geometry = geometry; self } /// Sets the refrigerant fluid identifier. pub fn with_refrigerant(mut self, fluid: impl Into) -> Self { self.refrigerant_id = fluid.into(); self } /// Sets the secondary fluid identifier. pub fn with_secondary_fluid(mut self, fluid: impl Into) -> Self { self.secondary_fluid_id = fluid.into(); self } /// Attaches a fluid backend and returns self. pub fn with_fluid_backend(mut self, backend: Arc) -> Self { self.fluid_backend = Some(backend.clone()); self.inner = self.inner.with_fluid_backend(backend); self } } impl Component for MovingBoundaryHX { fn n_equations(&self) -> usize { 3 } fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { let (p, m_refrig, t_sec_in, t_sec_out) = if let (Some(hot), Some(cold)) = (self.inner.hot_conditions(), self.inner.cold_conditions()) { ( hot.pressure_pa(), hot.mass_flow_kg_s(), cold.temperature_k(), cold.temperature_k() + 5.0, ) } else { (500_000.0, 0.1, 300.0, 320.0) }; // Extract enthalpies exactly as HeatExchanger does: let enthalpies = self.port_enthalpies(state)?; let h_in = enthalpies .get(0) .map(|h| h.to_joules_per_kg()) .unwrap_or(400_000.0); let h_out = enthalpies .get(1) .map(|h| h.to_joules_per_kg()) .unwrap_or(200_000.0); let mut cache = self.cache.borrow_mut(); let use_cache = cache.is_valid_for(p, m_refrig); if !use_cache { let (disc, h_sat_l, h_sat_v) = self.identify_zones(h_in, h_out, p, t_sec_in, t_sec_out)?; cache.valid = true; cache.p_ref = p; cache.m_ref = m_refrig; cache.h_sat_l = h_sat_l; cache.h_sat_v = h_sat_v; cache.discretization = disc; } let total_ua = cache.discretization.total_ua; let base_ua = self.inner.ua_nominal(); let custom_ua_scale = if base_ua > 0.0 { total_ua / base_ua } else { 1.0 }; self.inner .compute_residuals_with_ua_scale(state, residuals, custom_ua_scale) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { self.inner.jacobian_entries(state, jacobian) } 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<(Power, Power)> { self.inner.energy_transfers(state) } fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc) { if self.fluid_backend.is_none() { self.fluid_backend = Some(backend.clone()); self.inner.set_fluid_backend_from_builder(backend); } } fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool { self.inner.update_calib_factor(factor, value) } } impl StateManageable for MovingBoundaryHX { 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); } } impl MovingBoundaryHX { /// Identifies the phase zones along the heat exchanger and calculates boundaries. pub fn identify_zones( &self, h_refrig_in: f64, h_refrig_out: f64, p_refrig: f64, t_secondary_in: f64, t_secondary_out: f64, ) -> Result<(ZoneDiscretization, f64, f64), ComponentError> { let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::CalculationFailed("No FluidBackend configured".to_string()) })?; let fluid = entropyk_fluids::FluidId::new(&self.refrigerant_id); let p = entropyk_core::Pressure::from_pascals(p_refrig); let h_sat_l = backend .property( fluid.clone(), entropyk_fluids::Property::Enthalpy, entropyk_fluids::FluidState::from_px(p, entropyk_fluids::Quality::new(0.0)), ) .map_err(|e| ComponentError::CalculationFailed(format!("h_sat_l failed: {}", e)))?; let h_sat_v = backend .property( fluid.clone(), entropyk_fluids::Property::Enthalpy, entropyk_fluids::FluidState::from_px(p, entropyk_fluids::Quality::new(1.0)), ) .map_err(|e| ComponentError::CalculationFailed(format!("h_sat_v failed: {}", e)))?; let mut boundaries = Vec::new(); // Calculate transition positions and types let is_condensing = h_refrig_in > h_refrig_out; // Add inlet boundary let inlet_type = if h_refrig_in > h_sat_v + 1e-3 { ZoneType::Superheated } else if h_refrig_in < h_sat_l - 1e-3 { ZoneType::Subcooled } else { ZoneType::TwoPhase }; boundaries.push(self.create_boundary( 0.0, h_refrig_in, p_refrig, inlet_type, t_secondary_in, h_sat_l, h_sat_v, )?); let (h_min, h_max) = if is_condensing { (h_refrig_out, h_refrig_in) } else { (h_refrig_in, h_refrig_out) }; if h_min < h_sat_l && h_max > h_sat_l { let pos = (h_sat_l - h_refrig_in) / (h_refrig_out - h_refrig_in); let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in); // After sat_l, type is SC (if condensing) or TP (if evaporating) let post_type = if is_condensing { ZoneType::Subcooled } else { ZoneType::TwoPhase }; boundaries.push( self.create_boundary(pos, h_sat_l, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?, ); } if h_min < h_sat_v && h_max > h_sat_v { let pos = (h_sat_v - h_refrig_in) / (h_refrig_out - h_refrig_in); let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in); // After sat_v, type is TP (if condensing) or SH (if evaporating) let post_type = if is_condensing { ZoneType::TwoPhase } else { ZoneType::Superheated }; boundaries.push( self.create_boundary(pos, h_sat_v, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?, ); } // Add outlet boundary let outlet_type = if h_refrig_out > h_sat_v + 1e-3 { ZoneType::Superheated } else if h_refrig_out < h_sat_l - 1e-3 { ZoneType::Subcooled } else { ZoneType::TwoPhase }; boundaries.push(self.create_boundary( 1.0, h_refrig_out, p_refrig, outlet_type, t_secondary_out, h_sat_l, h_sat_v, )?); // Sort boundaries by position boundaries.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); // Calculate UA for each zone let mut total_ua = 0.0; for i in 0..boundaries.len() - 1 { let ua_zone = self.compute_zone_ua(&boundaries[i], &boundaries[i + 1])?; boundaries[i].ua = ua_zone; total_ua += ua_zone; } let (pinch_temp, pinch_pos) = self.calculate_pinch(&boundaries); Ok(( ZoneDiscretization { boundaries, total_ua, pinch_temp, pinch_position: pinch_pos, }, h_sat_l, h_sat_v, )) } fn compute_zone_ua(&self, b1: &ZoneBoundary, b2: &ZoneBoundary) -> Result { let area_zone = self.geometry.area * (b2.position - b1.position); if area_zone <= 1e-10 { return Ok(0.0); } // Without access to fluid phase properties and geometry correlation, // we use a simplified approximation based on zone type. // A true implementation would query self.correlation_selector let h_refrig = match b1.zone_type { ZoneType::TwoPhase => 5000.0, // Boiling or condensation ZoneType::Superheated => 500.0, // Vapor ZoneType::Subcooled => 1500.0, // Liquid }; let h_secondary = 5000.0; // Generally high for water/glycol let u_overall = 1.0 / (1.0 / h_refrig + 1.0 / h_secondary); Ok(u_overall * area_zone) } fn calculate_pinch(&self, boundaries: &[ZoneBoundary]) -> (f64, f64) { let mut min_dt = f64::MAX; let mut pinch_pos = 0.0; for b in boundaries { let dt = (b.t_hot - b.t_cold).abs(); if dt < min_dt { min_dt = dt; pinch_pos = b.position; } } (min_dt, pinch_pos) } fn create_boundary( &self, pos: f64, h: f64, p: f64, zone_type: ZoneType, t_sec: f64, h_sat_l: f64, h_sat_v: f64, ) -> Result { let quality = if h_sat_v > h_sat_l { ((h - h_sat_l) / (h_sat_v - h_sat_l)).clamp(0.0, 1.0) } else { 0.0 }; let t_refrig = if let Some(backend) = &self.fluid_backend { let fluid = entropyk_fluids::FluidId::new(&self.refrigerant_id); backend .property( fluid, entropyk_fluids::Property::Temperature, entropyk_fluids::FluidState::from_ph( entropyk_core::Pressure::from_pascals(p), entropyk_core::Enthalpy::from_joules_per_kg(h), ), ) .map_err(|e| ComponentError::CalculationFailed(format!("T_refrig failed: {}", e)))? } else { 300.0 }; Ok(ZoneBoundary { position: pos, zone_type, ua: 0.0, t_hot: if t_sec > t_refrig { t_sec } else { t_refrig }, t_cold: if t_sec > t_refrig { t_refrig } else { t_sec }, quality, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_zone_type_enum_exists() { let zone = ZoneType::Superheated; assert_eq!(zone, ZoneType::Superheated); let zone = ZoneType::TwoPhase; assert_eq!(zone, ZoneType::TwoPhase); let zone = ZoneType::Subcooled; assert_eq!(zone, ZoneType::Subcooled); } #[test] fn test_zone_boundary_struct_exists() { let boundary = ZoneBoundary { position: 0.5, zone_type: ZoneType::TwoPhase, ua: 1000.0, t_hot: 300.0, t_cold: 290.0, quality: 0.5, }; assert!((boundary.position - 0.5).abs() < 1e-10); assert_eq!(boundary.zone_type, ZoneType::TwoPhase); assert!((boundary.ua - 1000.0).abs() < 1e-10); } #[test] fn test_moving_boundary_hx_with_fluids() { let hx = MovingBoundaryHX::new() .with_refrigerant("R410A") .with_secondary_fluid("Water"); assert_eq!(hx.refrigerant_id, "R410A"); assert_eq!(hx.secondary_fluid_id, "Water"); } #[test] fn test_identify_zones_basic() { use entropyk_core::Pressure; use entropyk_fluids::{ CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState, }; struct MockBackend { h_sat_l: f64, h_sat_v: f64, t_sat: f64, } impl entropyk_fluids::FluidBackend for MockBackend { fn property( &self, _fluid: FluidId, property: entropyk_fluids::Property, state: entropyk_fluids::FluidState, ) -> FluidResult { match property { entropyk_fluids::Property::Temperature => Ok(self.t_sat), entropyk_fluids::Property::Enthalpy => { let q = match state { entropyk_fluids::FluidState::PressureQuality(_, q) => Some(q.value()), _ => None, }; match q { Some(0.0) => Ok(self.h_sat_l), Some(1.0) => Ok(self.h_sat_v), _ => Ok(self.h_sat_v), } } _ => Err(FluidError::UnsupportedProperty { property: format!("{:?}", property), }), } } fn critical_point(&self, fluid: FluidId) -> FluidResult { Err(FluidError::NoCriticalPoint { fluid: fluid.0 }) } fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true } fn phase( &self, _fluid: FluidId, _state: entropyk_fluids::FluidState, ) -> FluidResult { Ok(Phase::Unknown) } fn full_state( &self, _fluid: FluidId, _p: Pressure, _h: Enthalpy, ) -> FluidResult { Err(FluidError::UnsupportedProperty { property: "full_state".to_string(), }) } fn list_fluids(&self) -> Vec { vec![] } } let backend = MockBackend { h_sat_l: 200_000.0, h_sat_v: 400_000.0, t_sat: 280.0, }; let hx = MovingBoundaryHX::new() .with_refrigerant("R410A") .with_fluid_backend(Arc::new(backend)); // Condensing: 450,000 (SH) -> 150,000 (SC) let result = hx.identify_zones(450_000.0, 150_000.0, 500_000.0, 300.0, 320.0); assert!(result.is_ok()); let (disc, h_sat_l_res, h_sat_v_res) = result.unwrap(); assert_eq!(h_sat_l_res, 200_000.0); assert_eq!(h_sat_v_res, 400_000.0); // Should have 4 boundaries: inlet(SH), sat_v(SH/TP), sat_l(TP/SC), outlet(SC) assert_eq!(disc.boundaries.len(), 4); assert_eq!(disc.boundaries[0].zone_type, ZoneType::Superheated); assert_eq!(disc.boundaries[1].zone_type, ZoneType::TwoPhase); assert_eq!(disc.boundaries[2].zone_type, ZoneType::Subcooled); assert_eq!(disc.boundaries[3].zone_type, ZoneType::Subcooled); // Total UA should be positive assert!(disc.total_ua > 0.0); } #[test] fn test_cache_is_valid_for() { let mut cache = MovingBoundaryCache { valid: true, p_ref: 100_000.0, m_ref: 1.0, h_sat_l: 100.0, h_sat_v: 200.0, discretization: ZoneDiscretization::default(), }; // Identical assert!(cache.is_valid_for(100_000.0, 1.0)); // P < 5% deviation (104,000 is 4%) assert!(cache.is_valid_for(104_000.0, 1.0)); // P > 5% deviation (106,000 is 6%) assert!(!cache.is_valid_for(106_000.0, 1.0)); // M < 10% deviation (1.09 is 9%) assert!(cache.is_valid_for(100_000.0, 1.09)); // M > 10% deviation (1.11 is 11%) assert!(!cache.is_valid_for(100_000.0, 1.11)); // Invalid if explicitly invalid cache.valid = false; assert!(!cache.is_valid_for(100_000.0, 1.0)); } #[test] fn test_compute_residuals_uses_cache() { use crate::{Component, ResidualVector}; use entropyk_core::Pressure; use entropyk_fluids::{ CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState, }; struct TrackingMockBackend { pub calls: std::sync::atomic::AtomicUsize, } impl entropyk_fluids::FluidBackend for TrackingMockBackend { fn property( &self, _fluid: FluidId, _property: entropyk_fluids::Property, _state: entropyk_fluids::FluidState, ) -> FluidResult { self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(100.0) } fn critical_point(&self, _fluid: FluidId) -> FluidResult { Err(FluidError::NoCriticalPoint { fluid: "".to_string(), }) } fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true } fn phase( &self, _fluid: FluidId, _state: entropyk_fluids::FluidState, ) -> FluidResult { Ok(Phase::Unknown) } fn full_state( &self, _fluid: FluidId, _p: Pressure, _h: Enthalpy, ) -> FluidResult { Err(FluidError::UnsupportedProperty { property: "full_state".to_string(), }) } fn list_fluids(&self) -> Vec { vec![] } } let backend = Arc::new(TrackingMockBackend { calls: std::sync::atomic::AtomicUsize::new(0), }); let hx = MovingBoundaryHX::new() .with_refrigerant("R410A") .with_fluid_backend(backend.clone()); let state = vec![500_000.0, 400_000.0]; let mut residuals = vec![0.0; 3]; // First call should calculate property (backend calls) let _ = hx.compute_residuals(&state, &mut residuals); let calls_first = backend.calls.load(std::sync::atomic::Ordering::SeqCst); assert!(calls_first > 0); // Second call with same state should use cache -> 0 new backend calls let _ = hx.compute_residuals(&state, &mut residuals); let calls_second = backend.calls.load(std::sync::atomic::Ordering::SeqCst); assert_eq!(calls_first, calls_second); // Calls remained the same because cache was used } #[test] fn test_performance_speedup() { use crate::{Component, ResidualVector}; use entropyk_core::Pressure; use entropyk_fluids::{ CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState, }; use std::time::Instant; struct SlowMockBackend; impl entropyk_fluids::FluidBackend for SlowMockBackend { fn property( &self, _fluid: FluidId, _property: entropyk_fluids::Property, _state: entropyk_fluids::FluidState, ) -> FluidResult { // Simulate somewhat slow fluid property calculation std::thread::sleep(std::time::Duration::from_micros(10)); Ok(100.0) } fn critical_point(&self, _fluid: FluidId) -> FluidResult { Err(FluidError::NoCriticalPoint { fluid: "".to_string(), }) } fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true } fn phase( &self, _fluid: FluidId, _state: entropyk_fluids::FluidState, ) -> FluidResult { Ok(Phase::Unknown) } fn full_state( &self, _fluid: FluidId, _p: Pressure, _h: Enthalpy, ) -> FluidResult { Err(FluidError::UnsupportedProperty { property: "full_state".to_string(), }) } fn list_fluids(&self) -> Vec { vec![] } } let backend = Arc::new(SlowMockBackend); let hx = MovingBoundaryHX::new() .with_refrigerant("R410A") .with_fluid_backend(backend.clone()); let state = vec![500_000.0, 400_000.0]; let mut residuals = vec![0.0; 3]; // First run (no cache) let start = Instant::now(); let _ = hx.compute_residuals(&state, &mut residuals); let duration_uncached = start.elapsed(); // Second run (cached) let start = Instant::now(); let _ = hx.compute_residuals(&state, &mut residuals); let duration_cached = start.elapsed(); println!("Uncached duration: {:?}", duration_uncached); println!("Cached duration: {:?}", duration_cached); let speedup = duration_uncached.as_secs_f64() / duration_cached.as_secs_f64().max(1e-9); println!("Speedup multiplier: {:.1}x", speedup); assert!(duration_cached < duration_uncached); } }