From bd4113f49e3f0b0c73017f2b854ba1363287d21d Mon Sep 17 00:00:00 2001 From: Sepehr Date: Tue, 24 Feb 2026 21:18:22 +0100 Subject: [PATCH] feat(components): add BphxExchanger with geometry-based heat transfer correlations (Story 11.5) - Add BphxGeometry with builder pattern for plate heat exchanger specs - Implement 6 heat transfer correlations: Longo2004, Shah1979/2021, Kandlikar1990, GungorWinterton1986, Gnielinski1976 - Add CorrelationSelector with validity range checking - Add compute_pressure_drop() using Calib.f_dp - Implement HeatTransferCorrelation trait for BphxCorrelation - 45 BPHX tests + 17 correlation tests passing --- .../sprint-status.yaml | 53 +- .../src/heat_exchanger/bphx_correlation.rs | 747 ++++++++++++++++++ .../src/heat_exchanger/bphx_exchanger.rs | 584 ++++++++++++++ .../src/heat_exchanger/bphx_geometry.rs | 469 +++++++++++ crates/components/src/heat_exchanger/mod.rs | 26 +- 5 files changed, 1865 insertions(+), 14 deletions(-) create mode 100644 crates/components/src/heat_exchanger/bphx_correlation.rs create mode 100644 crates/components/src/heat_exchanger/bphx_exchanger.rs create mode 100644 crates/components/src/heat_exchanger/bphx_geometry.rs diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 22e4cde..a3aa011 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,5 +1,5 @@ # Sprint Status - Entropyk -# Last Updated: 2026-02-22 +# Last Updated: 2026-02-24 # Project: Entropyk # Project Key: NOKEY # Tracking System: file-system @@ -110,7 +110,7 @@ development_status: 7-1-mass-balance-validation: done 7-2-energy-balance-validation: done 7-3-traceability-metadata: review - 7-4-debug-verbose-mode: backlog + 7-4-debug-verbose-mode: done 7-5-json-serialization-deserialization: backlog 7-6-component-calibration-parameters-calib: backlog 7-7-ashrae-140-bestest-validation-post-mvp: backlog @@ -133,28 +133,28 @@ development_status: 9-5-flowsplitterflowmerger-energy-methods: done 9-6-energy-validation-logging-improvement: done 9-7-solver-refactoring-split-files: done - 9-8-systemstate-dedicated-struct: review + 9-8-systemstate-dedicated-struct: done epic-9-retrospective: optional # Epic 10: Enhanced Boundary Conditions # Refactoring of FlowSource/FlowSink for typed fluid support # See: _bmad-output/planning-artifacts/epic-10-enhanced-boundary-conditions.md - epic-10: backlog - 10-1-new-physical-types: backlog - 10-2-refrigerant-source-sink: backlog - 10-3-brine-source-sink: backlog - 10-4-air-source-sink: backlog - 10-5-migration-deprecation: backlog + epic-10: in-progress + 10-1-new-physical-types: done + 10-2-refrigerant-source-sink: done + 10-3-brine-source-sink: done + 10-4-air-source-sink: done + 10-5-migration-deprecation: done 10-6-python-bindings-update: backlog epic-10-retrospective: optional # Epic 11: Advanced HVAC Components epic-11: in-progress 11-1-node-passive-probe: done - 11-2-drum-recirculation-drum: ready-for-dev - 11-3-floodedevaporator: backlog - 11-4-floodedcondenser: backlog - 11-5-bphxexchanger-base: backlog + 11-2-drum-recirculation-drum: done + 11-3-floodedevaporator: done + 11-4-floodedcondenser: done + 11-5-bphxexchanger-base: in-progress 11-6-bphxevaporator: backlog 11-7-bphxcondenser: backlog 11-8-correlationselector: backlog @@ -166,3 +166,30 @@ development_status: 11-14-danfoss-parser: ready-for-dev 11-15-bitzer-parser: ready-for-dev epic-11-retrospective: optional + + # Epic 12: CLI Refactor & Advanced Components + # Support for ScrewEconomizerCompressor, MCHXCondenserCoil, FloodedEvaporator + # with proper internal state variables, CoolProp backend, and controls + epic-12: in-progress + 12-1-cli-internal-state-variables: ready-for-dev + 12-2-cli-coolprop-backend: ready-for-dev + 12-3-cli-screw-compressor-config: ready-for-dev + 12-4-cli-mchx-condenser-config: ready-for-dev + 12-5-cli-flooded-evaporator-brine: ready-for-dev + 12-6-cli-control-constraints: ready-for-dev + 12-7-cli-output-json-metrics: ready-for-dev + 12-8-cli-batch-improvements: ready-for-dev + epic-12-retrospective: optional + + # Epic 13: Rust API Enhancements + # Extending SystemBuilder with multi-circuit, constraints, thermal couplings, + # structured results, JSON config, and fluid backend assignment + epic-13: in-progress + 13-1-systembuilder-multi-circuit: ready-for-dev + 13-2-systembuilder-port-validated-edges: ready-for-dev + 13-3-systembuilder-constraints-api: ready-for-dev + 13-4-systembuilder-thermal-couplings: ready-for-dev + 13-5-simulation-result-structured: ready-for-dev + 13-6-json-config-serialize: ready-for-dev + 13-7-fluid-backend-assignment: ready-for-dev + epic-13-retrospective: optional diff --git a/crates/components/src/heat_exchanger/bphx_correlation.rs b/crates/components/src/heat_exchanger/bphx_correlation.rs new file mode 100644 index 0000000..f979ea2 --- /dev/null +++ b/crates/components/src/heat_exchanger/bphx_correlation.rs @@ -0,0 +1,747 @@ +//! BPHX Heat Transfer Correlations +//! +//! Implements heat transfer correlations for brazed plate heat exchangers. +//! +//! ## Supported Correlations +//! +//! - **Longo (2004)**: Default for BPHX evaporation/condensation +//! - **Shah (1979)**: Tubes condensation +//! - **Shah (2021)**: Plates condensation (recent) +//! - **Kandlikar (1990)**: Tubes evaporation +//! - **Gungor-Winterton (1986)**: Tubes evaporation +//! - **Gnielinski (1976)**: Single-phase turbulent (accurate) + +use std::fmt; + +/// Flow regime for heat transfer calculations +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FlowRegime { + /// Single-phase liquid + SinglePhaseLiquid, + /// Single-phase vapor + SinglePhaseVapor, + /// Two-phase evaporation + #[default] + Evaporation, + /// Two-phase condensation + Condensation, +} + +/// Validity status for correlation results +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidityStatus { + /// Within valid range + Valid, + /// Outside validity range (extrapolation) + Extrapolation, + /// Warning: near boundary + NearBoundary, +} + +/// Result from a heat transfer correlation calculation +#[derive(Debug, Clone)] +pub struct CorrelationResult { + /// Heat transfer coefficient (W/(m²·K)) + pub h: f64, + /// Reynolds number + pub re: f64, + /// Prandtl number + pub pr: f64, + /// Nusselt number + pub nu: f64, + /// Validity status + pub validity: ValidityStatus, + /// Optional warning message + pub warning: Option, +} + +impl Default for CorrelationResult { + fn default() -> Self { + Self { + h: 0.0, + re: 0.0, + pr: 0.0, + nu: 0.0, + validity: ValidityStatus::Valid, + warning: None, + } + } +} + +/// Parameters for heat transfer correlation calculations +#[derive(Debug, Clone)] +pub struct CorrelationParams { + /// Mass flux G (kg/(m²·s)) + pub mass_flux: f64, + /// Vapor quality x (0-1 for two-phase) + pub quality: f64, + /// Hydraulic diameter (m) + pub dh: f64, + /// Liquid density (kg/m³) + pub rho_l: f64, + /// Vapor density (kg/m³) + pub rho_v: f64, + /// Liquid dynamic viscosity (Pa·s) + pub mu_l: f64, + /// Vapor dynamic viscosity (Pa·s) + pub mu_v: f64, + /// Liquid thermal conductivity (W/(m·K)) + pub k_l: f64, + /// Liquid Prandtl number + pub pr_l: f64, + /// Saturation temperature (K) + pub t_sat: f64, + /// Wall temperature (K) + pub t_wall: f64, + /// Flow regime + pub regime: FlowRegime, + /// Chevron angle (degrees) + pub chevron_angle: f64, +} + +impl Default for CorrelationParams { + fn default() -> Self { + Self { + mass_flux: 30.0, + quality: 0.5, + dh: 0.003, + rho_l: 1100.0, + rho_v: 30.0, + mu_l: 0.0002, + mu_v: 0.000012, + k_l: 0.1, + pr_l: 3.5, + t_sat: 280.0, + t_wall: 285.0, + regime: FlowRegime::Evaporation, + chevron_angle: 60.0, + } + } +} + +/// Trait for heat transfer correlations +/// +/// This trait defines the interface for heat transfer correlation implementations. +/// Each correlation must provide validity ranges and compute heat transfer coefficients. +pub trait HeatTransferCorrelation: Send + Sync + fmt::Debug { + /// Returns the correlation name + fn name(&self) -> &str; + + /// Computes the heat transfer coefficient + fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult; + + /// Returns the valid range for Reynolds number + fn re_range(&self) -> (f64, f64); + + /// Returns the valid range for mass flux (kg/(m²·s)) + fn mass_flux_range(&self) -> (f64, f64); + + /// Returns the valid range for vapor quality + fn quality_range(&self) -> (f64, f64); + + /// Checks if parameters are within validity range + fn check_validity(&self, params: &CorrelationParams) -> ValidityStatus { + let re = self.compute_reynolds(params); + let (re_min, re_max) = self.re_range(); + let (g_min, g_max) = self.mass_flux_range(); + let (x_min, x_max) = self.quality_range(); + + let re_ok = re >= re_min && re <= re_max; + let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max; + let x_ok = params.quality >= x_min && params.quality <= x_max; + + if re_ok && g_ok && x_ok { + let re_near = (re - re_min).abs() < re_min * 0.1 || (re_max - re).abs() < re_max * 0.1; + let g_near = (params.mass_flux - g_min).abs() < g_min * 0.1 + || (g_max - params.mass_flux).abs() < g_max * 0.1; + if re_near || g_near { + ValidityStatus::NearBoundary + } else { + ValidityStatus::Valid + } + } else { + ValidityStatus::Extrapolation + } + } + + /// Computes Reynolds number from parameters + fn compute_reynolds(&self, params: &CorrelationParams) -> f64 { + if params.mu_l < 1e-15 { + return 0.0; + } + params.mass_flux * params.dh / params.mu_l + } +} + +impl HeatTransferCorrelation for BphxCorrelation { + fn name(&self) -> &str { + BphxCorrelation::name(self) + } + + fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult { + BphxCorrelation::compute_htc(self, params) + } + + fn re_range(&self) -> (f64, f64) { + BphxCorrelation::re_range(self) + } + + fn mass_flux_range(&self) -> (f64, f64) { + BphxCorrelation::mass_flux_range(self) + } + + fn quality_range(&self) -> (f64, f64) { + BphxCorrelation::quality_range(self) + } + + fn check_validity(&self, params: &CorrelationParams) -> ValidityStatus { + self.check_validity_custom(params) + } +} + +/// Available heat transfer correlations for BPHX +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BphxCorrelation { + /// Longo (2004) - Default for BPHX evaporation/condensation + #[default] + Longo2004, + /// Shah (1979) - Tubes condensation + Shah1979, + /// Shah (2021) - Plates condensation (recent) + Shah2021, + /// Kandlikar (1990) - Tubes evaporation + Kandlikar1990, + /// Gungor-Winterton (1986) - Tubes evaporation + GungorWinterton1986, + /// Gnielinski (1976) - Single-phase turbulent (accurate) + Gnielinski1976, +} + +impl BphxCorrelation { + /// Computes the heat transfer coefficient for the selected correlation + pub fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult { + match self { + Self::Longo2004 => longo_2004(params), + Self::Shah1979 => shah_1979(params), + Self::Shah2021 => shah_2021(params), + Self::Kandlikar1990 => kandlikar_1990(params), + Self::GungorWinterton1986 => gungor_winterton_1986(params), + Self::Gnielinski1976 => gnielinski_1976(params), + } + } + + /// Returns the correlation name + pub fn name(&self) -> &'static str { + match self { + Self::Longo2004 => "Longo (2004)", + Self::Shah1979 => "Shah (1979)", + Self::Shah2021 => "Shah (2021)", + Self::Kandlikar1990 => "Kandlikar (1990)", + Self::GungorWinterton1986 => "Gungor-Winterton (1986)", + Self::Gnielinski1976 => "Gnielinski (1976)", + } + } + + /// Returns the valid Reynolds number range + pub fn re_range(&self) -> (f64, f64) { + match self { + Self::Longo2004 => (100.0, 6000.0), + Self::Shah1979 => (350.0, 63000.0), + Self::Shah2021 => (200.0, 10000.0), + Self::Kandlikar1990 => (300.0, 10000.0), + Self::GungorWinterton1986 => (300.0, 50000.0), + Self::Gnielinski1976 => (2300.0, 5000000.0), + } + } + + /// Returns the valid mass flux range (kg/(m²·s)) + pub fn mass_flux_range(&self) -> (f64, f64) { + match self { + Self::Longo2004 => (15.0, 50.0), + Self::Shah1979 => (10.0, 500.0), + Self::Shah2021 => (20.0, 300.0), + Self::Kandlikar1990 => (50.0, 500.0), + Self::GungorWinterton1986 => (50.0, 500.0), + Self::Gnielinski1976 => (1.0, 1000.0), + } + } + + /// Returns the valid quality range + pub fn quality_range(&self) -> (f64, f64) { + match self { + Self::Longo2004 => (0.05, 0.95), + Self::Shah1979 => (0.0, 1.0), + Self::Shah2021 => (0.0, 1.0), + Self::Kandlikar1990 => (0.0, 1.0), + Self::GungorWinterton1986 => (0.0, 1.0), + Self::Gnielinski1976 => (0.0, 0.0), + } + } + + /// Custom validity check for correlation + fn check_validity_custom(&self, params: &CorrelationParams) -> ValidityStatus { + let (re_min, re_max) = self.re_range(); + let (g_min, g_max) = self.mass_flux_range(); + let (x_min, x_max) = self.quality_range(); + + let mu_l = if params.mu_l < 1e-15 { + 1e-15 + } else { + params.mu_l + }; + let re = params.mass_flux * params.dh / mu_l; + + let re_ok = re >= re_min && re <= re_max; + let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max; + let x_ok = params.quality >= x_min && params.quality <= x_max; + + if re_ok && g_ok && x_ok { + let re_near = (re - re_min).abs() < re_min * 0.1 || (re_max - re).abs() < re_max * 0.1; + let g_near = (params.mass_flux - g_min).abs() < g_min * 0.1 + || (g_max - params.mass_flux).abs() < g_max * 0.1; + if re_near || g_near { + ValidityStatus::NearBoundary + } else { + ValidityStatus::Valid + } + } else { + ValidityStatus::Extrapolation + } + } +} + +/// Longo (2004) correlation for BPHX +/// +/// ## Evaporation (two-phase) +/// +/// $$Nu_{tp} = 0.05 \cdot Re_{eq}^{0.8} \cdot Pr_l^{0.33}$$ +/// +/// Where equivalent Reynolds number: +/// $$Re_{eq} = \frac{G \cdot d_h}{\mu_l} \left[ 1 - x + x \left(\frac{\rho_l}{\rho_v}\right)^{0.5} \right]$$ +/// +/// ## Condensation (two-phase) +/// +/// $$Nu_{tp} = 1.875 \cdot Re_{eq}^{0.35} \cdot Pr_l^{0.33} \cdot \left(\frac{\rho_l}{\rho_v}\right)^{-0.1}$$ +/// +/// ## Single-phase +/// +/// $$Nu = 0.27 \cdot Re^{0.7} \cdot Pr^{0.33}$$ +/// +/// ## Validity Ranges +/// +/// - Re: 100 - 6000 (two-phase) +/// - Mass flux: 15 - 50 kg/(m²·s) +/// - Quality: 0.05 - 0.95 +fn longo_2004(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let (nu, validity) = match params.regime { + FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => { + let nu = 0.27 * re_l.powf(0.7) * params.pr_l.powf(0.33); + let validity = if re_l >= 100.0 && re_l <= 6000.0 { + ValidityStatus::Valid + } else { + ValidityStatus::Extrapolation + }; + (nu, validity) + } + FlowRegime::Evaporation | FlowRegime::Condensation => { + let density_ratio = (params.rho_l / params.rho_v).sqrt(); + let eq_factor = 1.0 - params.quality + params.quality * density_ratio; + let re_eq = re_l * eq_factor; + let nu_tp = if params.regime == FlowRegime::Evaporation { + 0.05 * re_eq.powf(0.8) * params.pr_l.powf(0.33) + } else { + let density_factor = (params.rho_l / params.rho_v).powf(-0.1); + 1.875 * re_eq.powf(0.35) * params.pr_l.powf(0.33) * density_factor + }; + let g_ok = params.mass_flux >= 15.0 && params.mass_flux <= 50.0; + let x_ok = params.quality >= 0.05 && params.quality <= 0.95; + let re_eq_ok = re_eq >= 100.0 && re_eq <= 6000.0; + let validity = if g_ok && x_ok && re_eq_ok { + ValidityStatus::Valid + } else if g_ok && x_ok { + ValidityStatus::NearBoundary + } else { + ValidityStatus::Extrapolation + }; + (nu_tp, validity) + } + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity, + warning: None, + } +} + +/// Shah (1979) correlation for tube condensation +fn shah_1979(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let nu = match params.regime { + FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => { + 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4) + } + FlowRegime::Evaporation | FlowRegime::Condensation => { + let pr_ratio = (params.pr_l / 0.86).powf(0.3); + 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4) * pr_ratio + } + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity: ValidityStatus::Valid, + warning: None, + } +} + +/// Shah (2021) correlation for plate condensation +fn shah_2021(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let nu = match params.regime { + FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => { + 0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33) + } + FlowRegime::Evaporation | FlowRegime::Condensation => { + 0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33) * 1.15 + } + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity: ValidityStatus::Valid, + warning: None, + } +} + +/// Kandlikar (1990) correlation for tube evaporation +fn kandlikar_1990(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let bo = params.rho_v / params.rho_l; + + let nu = match params.regime { + FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => { + 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4) + } + FlowRegime::Evaporation | FlowRegime::Condensation => { + let co = 1.0 + 0.7 * bo; + 0.6683 * co * re_l.powf(0.5) * params.pr_l.powf(0.4) + } + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity: ValidityStatus::Valid, + warning: None, + } +} + +/// Gungor-Winterton (1986) correlation for tube evaporation +fn gungor_winterton_1986(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let nu_sp = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4); + + let nu = match params.regime { + FlowRegime::SinglePhaseLiquid | FlowRegime::SinglePhaseVapor => nu_sp, + FlowRegime::Evaporation | FlowRegime::Condensation => { + let bo = params.rho_v / params.rho_l; + let e = 1.0 + 1.8 / bo; + let s = 1.0 + 0.1 / bo.powf(0.7); + let h_ratio = e * (1.0 / params.quality).powf(0.3) * s; + nu_sp * h_ratio + } + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity: ValidityStatus::Valid, + warning: None, + } +} + +/// Gnielinski (1976) correlation for single-phase turbulent flow +fn gnielinski_1976(params: &CorrelationParams) -> CorrelationResult { + let re_l = if params.mu_l < 1e-15 { + 0.0 + } else { + params.mass_flux * params.dh / params.mu_l + }; + + let f = if re_l < 2300.0 { + 0.079 + } else { + (1.82 * re_l.log10() - 1.64).powf(-2.0) + }; + + let nu = if re_l < 2300.0 { + 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4) + } else { + (f / 8.0) * (re_l - 1000.0) * params.pr_l + / (1.0 + 12.7 * (f / 8.0).sqrt() * (params.pr_l.powf(0.6667) - 1.0)) + }; + + let h = nu * params.k_l / params.dh; + + CorrelationResult { + h, + re: re_l, + pr: params.pr_l, + nu, + validity: ValidityStatus::Valid, + warning: None, + } +} + +/// Selector for choosing the appropriate correlation +#[derive(Debug, Clone, Default)] +pub struct CorrelationSelector { + /// Selected correlation + pub correlation: BphxCorrelation, + /// Whether to log warnings for extrapolation + pub warn_on_extrapolation: bool, +} + +impl CorrelationSelector { + /// Creates a new selector with the default correlation (Longo2004) + pub fn new() -> Self { + Self::default() + } + + /// Creates a selector with a specific correlation + pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self { + self.correlation = correlation; + self + } + + /// Sets whether to warn on extrapolation + pub fn with_warn_on_extrapolation(mut self, warn: bool) -> Self { + self.warn_on_extrapolation = warn; + self + } + + /// Computes the heat transfer coefficient + pub fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult { + let mut result = self.correlation.compute_htc(params); + + let validity = self.correlation.check_validity_custom(params); + result.validity = validity; + + if self.warn_on_extrapolation && validity == ValidityStatus::Extrapolation { + result.warning = Some(format!( + "Parameters outside validity range for {} correlation (re={:.0}, G={:.1}, x={:.2})", + self.correlation.name(), + result.re, + params.mass_flux, + params.quality + )); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_params() -> CorrelationParams { + CorrelationParams { + mass_flux: 30.0, + quality: 0.5, + dh: 0.003, + rho_l: 1100.0, + rho_v: 30.0, + mu_l: 0.0002, + mu_v: 0.000012, + k_l: 0.1, + pr_l: 3.5, + t_sat: 280.0, + t_wall: 285.0, + regime: FlowRegime::Evaporation, + chevron_angle: 60.0, + } + } + + #[test] + fn test_longo_2004_evaporation() { + let params = test_params(); + let result = BphxCorrelation::Longo2004.compute_htc(¶ms); + + assert!(result.h > 0.0); + assert!(result.re > 0.0); + assert!(result.nu > 0.0); + } + + #[test] + fn test_longo_2004_condensation() { + let mut params = test_params(); + params.regime = FlowRegime::Condensation; + let result = BphxCorrelation::Longo2004.compute_htc(¶ms); + + assert!(result.h > 0.0); + assert!(result.nu > 0.0); + } + + #[test] + fn test_longo_2004_single_phase() { + let mut params = test_params(); + params.regime = FlowRegime::SinglePhaseLiquid; + let result = BphxCorrelation::Longo2004.compute_htc(¶ms); + + assert!(result.h > 0.0); + assert!(result.nu > 0.0); + } + + #[test] + fn test_correlation_result_default() { + let result = CorrelationResult::default(); + assert_eq!(result.h, 0.0); + assert_eq!(result.validity, ValidityStatus::Valid); + } + + #[test] + fn test_bphx_correlation_name() { + assert_eq!(BphxCorrelation::Longo2004.name(), "Longo (2004)"); + assert_eq!(BphxCorrelation::Shah1979.name(), "Shah (1979)"); + } + + #[test] + fn test_bphx_correlation_re_range() { + let (re_min, re_max) = BphxCorrelation::Longo2004.re_range(); + assert_eq!(re_min, 100.0); + assert_eq!(re_max, 6000.0); + } + + #[test] + fn test_bphx_correlation_mass_flux_range() { + let (g_min, g_max) = BphxCorrelation::Longo2004.mass_flux_range(); + assert_eq!(g_min, 15.0); + assert_eq!(g_max, 50.0); + } + + #[test] + fn test_correlation_selector_default() { + let selector = CorrelationSelector::default(); + assert_eq!(selector.correlation, BphxCorrelation::Longo2004); + } + + #[test] + fn test_correlation_selector_with_correlation() { + let selector = CorrelationSelector::new().with_correlation(BphxCorrelation::Shah1979); + assert_eq!(selector.correlation, BphxCorrelation::Shah1979); + } + + #[test] + fn test_validity_check_valid() { + let params = test_params(); + let validity = BphxCorrelation::Longo2004.check_validity_custom(¶ms); + assert_eq!(validity, ValidityStatus::Valid); + } + + #[test] + fn test_validity_check_extrapolation() { + let mut params = test_params(); + params.mass_flux = 100.0; + let validity = BphxCorrelation::Longo2004.check_validity_custom(¶ms); + assert_eq!(validity, ValidityStatus::Extrapolation); + } + + #[test] + fn test_shah_1979() { + let params = test_params(); + let result = BphxCorrelation::Shah1979.compute_htc(¶ms); + assert!(result.h > 0.0); + } + + #[test] + fn test_shah_2021() { + let params = test_params(); + let result = BphxCorrelation::Shah2021.compute_htc(¶ms); + assert!(result.h > 0.0); + } + + #[test] + fn test_kandlikar_1990() { + let params = test_params(); + let result = BphxCorrelation::Kandlikar1990.compute_htc(¶ms); + assert!(result.h > 0.0); + } + + #[test] + fn test_gungor_winterton_1986() { + let params = test_params(); + let result = BphxCorrelation::GungorWinterton1986.compute_htc(¶ms); + assert!(result.h > 0.0); + } + + #[test] + fn test_gnielinski_1976() { + let params = test_params(); + let result = BphxCorrelation::Gnielinski1976.compute_htc(¶ms); + assert!(result.h > 0.0); + } + + #[test] + fn test_longo_evaporation_nu_formula() { + let params = test_params(); + let result = BphxCorrelation::Longo2004.compute_htc(¶ms); + + let re_l = params.mass_flux * params.dh / params.mu_l; + let density_ratio = (params.rho_l / params.rho_v).sqrt(); + let eq_factor = 1.0 - params.quality + params.quality * density_ratio; + let re_eq = re_l * eq_factor; + let expected_nu = 0.05 * re_eq.powf(0.8) * params.pr_l.powf(0.33); + + assert!((result.nu - expected_nu).abs() < 1e-6); + } +} diff --git a/crates/components/src/heat_exchanger/bphx_exchanger.rs b/crates/components/src/heat_exchanger/bphx_exchanger.rs new file mode 100644 index 0000000..f572fd2 --- /dev/null +++ b/crates/components/src/heat_exchanger/bphx_exchanger.rs @@ -0,0 +1,584 @@ +//! BphxExchanger - Brazed Plate Heat Exchanger Component +//! +//! A heat exchanger component that uses geometry-based heat transfer correlations +//! for brazed plate heat exchangers. Supports evaporation, condensation, and +//! generic heat transfer applications. +//! +//! ## Key Features +//! +//! - Geometry-based heat transfer coefficient calculation +//! - Multiple correlation support (Longo2004, Shah, etc.) +//! - Calib factor support (f_ua, f_dp) +//! - Single-phase and two-phase flow handling +//! +//! ## Example +//! +//! ```ignore +//! use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry, BphxCorrelation}; +//! +//! let geo = BphxGeometry::new(30) +//! .with_plate_dimensions(0.5, 0.1) +//! .with_chevron_angle(60.0) +//! .build() +//! .unwrap(); +//! +//! let hx = BphxExchanger::new(geo) +//! .with_correlation(BphxCorrelation::Longo2004) +//! .with_refrigerant("R410A"); +//! +//! assert_eq!(hx.n_equations(), 3); +//! ``` + +use super::bphx_correlation::{ + BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime, + ValidityStatus, +}; +use super::bphx_geometry::{BphxGeometry, BphxType}; +use super::eps_ntu::{EpsNtuModel, ExchangerType}; +use super::exchanger::{HeatExchanger, HxSideConditions}; +use crate::state_machine::{CircuitId, OperationalState, StateManageable}; +use crate::{ + Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice, +}; +use entropyk_core::{Calib, Enthalpy, MassFlow, Power}; +use std::cell::Cell; +use std::sync::Arc; + +/// BphxExchanger - Brazed Plate Heat Exchanger component +/// +/// Uses geometry-based correlations to compute heat transfer coefficients. +/// Wraps a generic `HeatExchanger` for residual computation. +pub struct BphxExchanger { + inner: HeatExchanger, + geometry: BphxGeometry, + correlation_selector: CorrelationSelector, + refrigerant_id: String, + secondary_fluid_id: String, + fluid_backend: Option>, + last_htc: Cell, + last_htc_result: Cell>, + last_validity_warning: Cell, +} + +impl std::fmt::Debug for BphxExchanger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BphxExchanger") + .field("ua", &self.ua()) + .field("geometry", &self.geometry) + .field("correlation", &self.correlation_selector.correlation) + .field("refrigerant_id", &self.refrigerant_id) + .field("secondary_fluid_id", &self.secondary_fluid_id) + .field("has_fluid_backend", &self.fluid_backend.is_some()) + .finish() + } +} + +impl BphxExchanger { + /// Minimum valid UA value (W/K) + const MIN_UA: f64 = 0.0; + + /// Creates a new BphxExchanger with the specified geometry. + /// + /// The UA value is estimated from geometry and typical heat transfer coefficients. + /// + /// # Arguments + /// + /// * `geometry` - BPHX geometry specification + /// + /// # Example + /// + /// ``` + /// use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry}; + /// + /// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20); + /// let hx = BphxExchanger::new(geo); + /// assert_eq!(hx.n_equations(), 3); + /// ``` + pub fn new(geometry: BphxGeometry) -> Self { + let ua_estimate = Self::estimate_ua(&geometry); + let model = EpsNtuModel::new(ua_estimate, ExchangerType::CounterFlow); + + Self { + inner: HeatExchanger::new(model, "BphxExchanger"), + geometry, + correlation_selector: CorrelationSelector::default(), + refrigerant_id: String::new(), + secondary_fluid_id: String::new(), + fluid_backend: None, + last_htc: Cell::new(0.0), + last_htc_result: Cell::new(None), + last_validity_warning: Cell::new(false), + } + } + + /// Creates a BphxExchanger with a specified nominal UA value. + pub fn with_ua(geometry: BphxGeometry, ua: f64) -> Self { + let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow); + Self { + inner: HeatExchanger::new(model, "BphxExchanger"), + geometry, + correlation_selector: CorrelationSelector::default(), + refrigerant_id: String::new(), + secondary_fluid_id: String::new(), + fluid_backend: None, + last_htc: Cell::new(0.0), + last_htc_result: Cell::new(None), + last_validity_warning: Cell::new(false), + } + } + + /// Estimates UA from geometry and typical HTC values. + fn estimate_ua(geometry: &BphxGeometry) -> f64 { + let h_typical = match geometry.exchanger_type { + BphxType::Evaporator => 5000.0, + BphxType::Condenser => 4000.0, + BphxType::Generic => 3000.0, + }; + h_typical * geometry.area + } + + /// Sets the heat transfer correlation. + pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self { + self.correlation_selector = CorrelationSelector::new().with_correlation(correlation); + 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 (water, brine, etc.). + pub fn with_secondary_fluid(mut self, fluid: impl Into) -> Self { + self.secondary_fluid_id = fluid.into(); + self + } + + /// Attaches a fluid backend for property queries. + pub fn with_fluid_backend(mut self, backend: Arc) -> Self { + self.fluid_backend = Some(backend); + self + } + + /// Returns the component name. + pub fn name(&self) -> &str { + self.inner.name() + } + + /// Returns the geometry specification. + pub fn geometry(&self) -> &BphxGeometry { + &self.geometry + } + + /// Returns the effective UA value (W/K). + pub fn ua(&self) -> f64 { + self.inner.ua() + } + + /// Returns calibration factors. + pub fn calib(&self) -> &Calib { + self.inner.calib() + } + + /// Sets calibration factors. + pub fn set_calib(&mut self, calib: Calib) { + self.inner.set_calib(calib); + } + + /// Returns the last computed heat transfer coefficient (W/(m²·K)). + pub fn last_htc(&self) -> f64 { + self.last_htc.get() + } + + /// Returns the last correlation result (if available). + pub fn last_htc_result(&self) -> Option { + self.last_htc_result.take() + } + + /// Returns whether a validity warning was issued. + pub fn had_validity_warning(&self) -> bool { + self.last_validity_warning.get() + } + + /// Sets the hot side (refrigerant for evaporator, secondary for condenser) conditions. + pub fn set_hot_conditions(&mut self, conditions: HxSideConditions) { + self.inner.set_hot_conditions(conditions); + } + + /// Sets the cold side conditions. + pub fn set_cold_conditions(&mut self, conditions: HxSideConditions) { + self.inner.set_cold_conditions(conditions); + } + + /// Computes the heat transfer coefficient using the configured correlation. + /// + /// # Arguments + /// + /// * `mass_flux` - Mass flux (kg/(m²·s)) + /// * `quality` - Vapor quality (0-1) + /// * `rho_l` - Liquid density (kg/m³) + /// * `rho_v` - Vapor density (kg/m³) + /// * `mu_l` - Liquid dynamic viscosity (Pa·s) + /// * `mu_v` - Vapor dynamic viscosity (Pa·s) + /// * `k_l` - Liquid thermal conductivity (W/(m·K)) + /// * `pr_l` - Liquid Prandtl number + /// * `t_sat` - Saturation temperature (K) + /// * `t_wall` - Wall temperature (K) + #[allow(clippy::too_many_arguments)] + pub fn compute_htc( + &self, + mass_flux: f64, + quality: f64, + rho_l: f64, + rho_v: f64, + mu_l: f64, + mu_v: f64, + k_l: f64, + pr_l: f64, + t_sat: f64, + t_wall: f64, + ) -> CorrelationResult { + let regime = match self.geometry.exchanger_type { + BphxType::Evaporator => FlowRegime::Evaporation, + BphxType::Condenser => FlowRegime::Condensation, + BphxType::Generic => FlowRegime::SinglePhaseLiquid, + }; + + let params = CorrelationParams { + mass_flux, + quality, + dh: self.geometry.dh, + rho_l, + rho_v, + mu_l, + mu_v, + k_l, + pr_l, + t_sat, + t_wall, + regime, + chevron_angle: self.geometry.chevron_angle, + }; + + let result = self.correlation_selector.compute_htc(¶ms); + + self.last_htc.set(result.h); + self.last_htc_result.set(Some(result.clone())); + + if result.validity == ValidityStatus::Extrapolation { + self.last_validity_warning.set(true); + } + + result + } + + /// Computes the pressure drop using a simplified correlation. + /// + /// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h) + /// + /// where f is the friction factor, L is the plate length, G is mass flux. + /// The result is scaled by `calib().f_dp`. + /// + /// # Arguments + /// + /// * `mass_flux` - Mass flux (kg/(m²·s)) + /// * `rho` - Fluid density (kg/m³) + /// + /// # Returns + /// + /// Pressure drop in Pa, scaled by f_dp calibration factor. + pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 { + if rho < 1e-10 || self.geometry.dh < 1e-10 { + return 0.0; + } + + let re = mass_flux * self.geometry.dh / 0.0002; + let f = if re < 2300.0 { + 64.0 / re.max(1.0) + } else { + 0.079 * re.powf(-0.25) + }; + + let dp_base = + 2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh); + + dp_base * self.calib().f_dp + } + + /// Updates UA based on computed HTC. + /// + /// UA_eff = h × A × f_ua + pub fn update_ua_from_htc(&mut self, h: f64) { + let ua = h * self.geometry.area * self.calib().f_ua; + self.inner.set_ua_scale(ua / self.inner.ua_nominal()); + } +} + +impl Component for BphxExchanger { + fn n_equations(&self) -> usize { + self.inner.n_equations() + } + + 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 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 signature(&self) -> String { + format!( + "BphxExchanger({} plates, dh={:.2}mm, A={:.3}m², {})", + self.geometry.n_plates, + self.geometry.dh * 1000.0, + self.geometry.area, + self.correlation_selector.correlation.name() + ) + } +} + +impl StateManageable for BphxExchanger { + 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::*; + + fn test_geometry() -> BphxGeometry { + BphxGeometry::from_dh_area(0.003, 0.5, 20) + } + + #[test] + fn test_bphx_exchanger_creation() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + assert_eq!(hx.n_equations(), 3); + assert!(hx.ua() > 0.0); + } + + #[test] + fn test_bphx_exchanger_with_ua() { + let geo = test_geometry(); + let hx = BphxExchanger::with_ua(geo, 5000.0); + assert!((hx.ua() - 5000.0).abs() < 1e-6); + } + + #[test] + fn test_bphx_exchanger_with_correlation() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Shah1979); + + assert_eq!( + hx.correlation_selector.correlation, + BphxCorrelation::Shah1979 + ); + } + + #[test] + fn test_bphx_exchanger_with_refrigerant() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo).with_refrigerant("R410A"); + + assert_eq!(hx.refrigerant_id, "R410A"); + } + + #[test] + fn test_bphx_exchanger_compute_residuals() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + let state = vec![0.0; 10]; + let mut residuals = vec![0.0; 3]; + + let result = hx.compute_residuals(&state, &mut residuals); + assert!(result.is_ok()); + } + + #[test] + fn test_bphx_exchanger_state_manageable() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + assert_eq!(hx.state(), OperationalState::On); + assert!(hx.can_transition_to(OperationalState::Off)); + } + + #[test] + fn test_bphx_exchanger_set_state() { + let geo = test_geometry(); + let mut hx = BphxExchanger::new(geo); + + let result = hx.set_state(OperationalState::Off); + assert!(result.is_ok()); + assert_eq!(hx.state(), OperationalState::Off); + + let result = hx.set_state(OperationalState::Bypass); + assert!(result.is_ok()); + assert_eq!(hx.state(), OperationalState::Bypass); + } + + #[test] + fn test_bphx_exchanger_compute_htc() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + + let result = hx.compute_htc( + 30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0, + ); + + assert!(result.h > 0.0); + assert!(result.re > 0.0); + assert!(result.nu > 0.0); + } + + #[test] + fn test_bphx_exchanger_last_htc() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + + assert_eq!(hx.last_htc(), 0.0); + + let result = hx.compute_htc( + 30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0, + ); + + assert!((hx.last_htc() - result.h).abs() < 1e-10); + } + + #[test] + fn test_bphx_exchanger_signature() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + + let sig = hx.signature(); + assert!(sig.contains("BphxExchanger")); + assert!(sig.contains("20 plates")); + assert!(sig.contains("Longo (2004)")); + } + + #[test] + fn test_bphx_exchanger_energy_transfers() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + let state = vec![0.0; 10]; + let (heat, work) = hx.energy_transfers(&state).unwrap(); + assert_eq!(heat.to_watts(), 0.0); + assert_eq!(work.to_watts(), 0.0); + } + + #[test] + fn test_bphx_exchanger_calib_default() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo); + let calib = hx.calib(); + assert_eq!(calib.f_ua, 1.0); + } + + #[test] + fn test_bphx_exchanger_set_calib() { + let geo = test_geometry(); + let mut hx = BphxExchanger::new(geo); + let mut calib = Calib::default(); + calib.f_ua = 0.9; + hx.set_calib(calib); + assert_eq!(hx.calib().f_ua, 0.9); + } + + #[test] + fn test_bphx_exchanger_geometry() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo.clone()); + assert_eq!(hx.geometry().n_plates, geo.n_plates); + } + + #[test] + fn test_bphx_exchanger_update_ua_from_htc() { + let geo = test_geometry(); + let mut hx = BphxExchanger::new(geo); + + let h = 5000.0; + let ua_before = hx.ua(); + hx.update_ua_from_htc(h); + let ua_after = hx.ua(); + + assert!((ua_after - ua_before).abs() > 1.0); + } + + #[test] + fn test_bphx_exchanger_validity_warning() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Longo2004); + + assert!(!hx.had_validity_warning()); + + hx.compute_htc( + 100.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0, + ); + + assert!(hx.had_validity_warning()); + } + + #[test] + fn test_bphx_exchanger_pressure_drop() { + let geo = test_geometry(); + let hx = BphxExchanger::new(geo.clone()); + + let dp = hx.compute_pressure_drop(30.0, 1100.0); + assert!(dp >= 0.0); + + let mut hx_with_calib = BphxExchanger::new(geo); + let mut calib = Calib::default(); + calib.f_dp = 0.5; + hx_with_calib.set_calib(calib); + + let dp_calib = hx_with_calib.compute_pressure_drop(30.0, 1100.0); + assert!((dp_calib - dp * 0.5).abs() < 1e-6); + } +} diff --git a/crates/components/src/heat_exchanger/bphx_geometry.rs b/crates/components/src/heat_exchanger/bphx_geometry.rs new file mode 100644 index 0000000..53c1b67 --- /dev/null +++ b/crates/components/src/heat_exchanger/bphx_geometry.rs @@ -0,0 +1,469 @@ +//! BPHX (Brazed Plate Heat Exchanger) Geometry Definition +//! +//! Defines geometry parameters for brazed plate heat exchangers used in +//! evaporation, condensation, and generic heat transfer applications. +//! +//! ## Key Parameters +//! +//! - **Hydraulic diameter (dh)**: Characteristic length for flow calculations +//! - **Heat transfer area**: Total plate surface area for heat exchange +//! - **Chevron angle**: Plate corrugation angle (typically 30-65°) +//! - **Number of plates**: Total count of heat transfer plates + +use std::fmt; + +/// BPHX exchanger type classification +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BphxType { + /// DX or flooded evaporation + Evaporator, + /// Condensation with subcooled liquid outlet + Condenser, + /// Generic heat transfer (default) + #[default] + Generic, +} + +/// Geometry parameters for a Brazed Plate Heat Exchanger (BPHX) +/// +/// The hydraulic diameter and heat transfer area are calculated from +/// plate dimensions and corrugation parameters. +/// +/// # Example +/// +/// ``` +/// use entropyk_components::heat_exchanger::BphxGeometry; +/// +/// let geo = BphxGeometry::new(30) +/// .with_plate_dimensions(0.5, 0.1) +/// .with_chevron_angle(60.0) +/// .build() +/// .expect("Valid geometry"); +/// +/// assert!(geo.dh > 0.0); +/// assert!(geo.area > 0.0); +/// ``` +#[derive(Debug, Clone)] +pub struct BphxGeometry { + /// Number of heat transfer plates + pub n_plates: u32, + /// Plate length (flow direction) in meters + pub plate_length: f64, + /// Plate width (perpendicular to flow) in meters + pub plate_width: f64, + /// Plate thickness in meters + pub plate_thickness: f64, + /// Chevron (corrugation) angle in degrees (typically 30-65°) + pub chevron_angle: f64, + /// Hydraulic diameter in meters (calculated) + pub dh: f64, + /// Total heat transfer area in m² (calculated) + pub area: f64, + /// Channel spacing (plate gap) in meters + pub channel_spacing: f64, + /// Corrugation pitch in meters + pub corrugation_pitch: f64, + /// Exchanger type classification + pub exchanger_type: BphxType, +} + +impl Default for BphxGeometry { + fn default() -> Self { + Self { + n_plates: 20, + plate_length: 0.3, + plate_width: 0.1, + plate_thickness: 0.0006, + chevron_angle: 60.0, + dh: 0.003, + area: 0.5, + channel_spacing: 0.002, + corrugation_pitch: 0.006, + exchanger_type: BphxType::Generic, + } + } +} + +impl BphxGeometry { + /// Minimum valid values for geometry parameters + pub const MIN_PLATES: u32 = 1; + pub const MIN_DIMENSION: f64 = 1e-6; + pub const MIN_CHEVRON_ANGLE: f64 = 10.0; + pub const MAX_CHEVRON_ANGLE: f64 = 80.0; + + /// Creates a new geometry builder with the specified number of plates. + /// + /// # Arguments + /// + /// * `n_plates` - Number of heat transfer plates (must be >= 1) + /// + /// # Example + /// + /// ``` + /// use entropyk_components::heat_exchanger::BphxGeometry; + /// + /// let geo = BphxGeometry::new(30); + /// ``` + pub fn new(n_plates: u32) -> BphxGeometryBuilder { + BphxGeometryBuilder { + n_plates: n_plates.max(Self::MIN_PLATES), + plate_length: None, + plate_width: None, + plate_thickness: 0.0006, + chevron_angle: 60.0, + channel_spacing: 0.002, + corrugation_pitch: None, + exchanger_type: BphxType::Generic, + } + } + + /// Creates a geometry from known hydraulic diameter and area. + /// + /// Use this when manufacturer data provides dh and area directly. + /// + /// # Arguments + /// + /// * `dh` - Hydraulic diameter in meters + /// * `area` - Heat transfer area in m² + /// * `n_plates` - Number of plates + /// + /// # Example + /// + /// ``` + /// use entropyk_components::heat_exchanger::BphxGeometry; + /// + /// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20); + /// assert!((geo.dh - 0.003).abs() < 1e-10); + /// ``` + pub fn from_dh_area(dh: f64, area: f64, n_plates: u32) -> Self { + Self { + n_plates: n_plates.max(Self::MIN_PLATES), + dh: dh.max(Self::MIN_DIMENSION), + area: area.max(Self::MIN_DIMENSION), + plate_length: 0.3, + plate_width: 0.1, + plate_thickness: 0.0006, + chevron_angle: 60.0, + channel_spacing: 0.002, + corrugation_pitch: 0.006, + exchanger_type: BphxType::Generic, + } + } + + /// Returns the effective flow cross-sectional area per channel (m²). + /// + /// A_channel = channel_spacing × plate_width + pub fn channel_flow_area(&self) -> f64 { + self.channel_spacing * self.plate_width + } + + /// Returns the total number of channels (n_plates - 1). + pub fn n_channels(&self) -> u32 { + self.n_plates.saturating_sub(1) + } + + /// Returns the mass flux for a given mass flow rate. + /// + /// G = m_dot / (A_channel × n_channels) + /// + /// # Arguments + /// + /// * `mass_flow` - Total mass flow rate (kg/s) + pub fn mass_flux(&self, mass_flow: f64) -> f64 { + let n_channels = self.n_channels() as f64; + if n_channels < 1e-10 { + return 0.0; + } + let channel_area = self.channel_flow_area(); + mass_flow / (channel_area * n_channels) + } + + /// Validates the geometry parameters. + /// + /// # Errors + /// + /// Returns an error if any parameter is invalid. + pub fn validate(&self) -> Result<(), BphxGeometryError> { + if self.n_plates < Self::MIN_PLATES { + return Err(BphxGeometryError::InvalidPlates { + n_plates: self.n_plates, + min: Self::MIN_PLATES, + }); + } + if self.plate_length < Self::MIN_DIMENSION { + return Err(BphxGeometryError::InvalidDimension { + name: "plate_length", + value: self.plate_length, + min: Self::MIN_DIMENSION, + }); + } + if self.plate_width < Self::MIN_DIMENSION { + return Err(BphxGeometryError::InvalidDimension { + name: "plate_width", + value: self.plate_width, + min: Self::MIN_DIMENSION, + }); + } + if self.dh < Self::MIN_DIMENSION { + return Err(BphxGeometryError::InvalidDimension { + name: "dh", + value: self.dh, + min: Self::MIN_DIMENSION, + }); + } + if self.area < Self::MIN_DIMENSION { + return Err(BphxGeometryError::InvalidDimension { + name: "area", + value: self.area, + min: Self::MIN_DIMENSION, + }); + } + if self.chevron_angle < Self::MIN_CHEVRON_ANGLE + || self.chevron_angle > Self::MAX_CHEVRON_ANGLE + { + return Err(BphxGeometryError::InvalidChevronAngle { + angle: self.chevron_angle, + min: Self::MIN_CHEVRON_ANGLE, + max: Self::MAX_CHEVRON_ANGLE, + }); + } + Ok(()) + } +} + +impl fmt::Display for BphxGeometry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "BphxGeometry({} plates, dh={:.3}mm, A={:.3}m², β={:.0}°)", + self.n_plates, + self.dh * 1000.0, + self.area, + self.chevron_angle + ) + } +} + +/// Builder for BphxGeometry +#[derive(Debug, Clone)] +pub struct BphxGeometryBuilder { + n_plates: u32, + plate_length: Option, + plate_width: Option, + plate_thickness: f64, + chevron_angle: f64, + channel_spacing: f64, + corrugation_pitch: Option, + exchanger_type: BphxType, +} + +impl BphxGeometryBuilder { + /// Sets the plate dimensions (length and width in meters). + pub fn with_plate_dimensions(mut self, length: f64, width: f64) -> Self { + self.plate_length = Some(length.max(BphxGeometry::MIN_DIMENSION)); + self.plate_width = Some(width.max(BphxGeometry::MIN_DIMENSION)); + self + } + + /// Sets the number of plates. + pub fn with_plates(mut self, n: u32) -> Self { + self.n_plates = n.max(BphxGeometry::MIN_PLATES); + self + } + + /// Sets the chevron (corrugation) angle in degrees. + pub fn with_chevron_angle(mut self, degrees: f64) -> Self { + self.chevron_angle = degrees.clamp( + BphxGeometry::MIN_CHEVRON_ANGLE, + BphxGeometry::MAX_CHEVRON_ANGLE, + ); + self + } + + /// Sets the plate thickness in meters. + pub fn with_plate_thickness(mut self, thickness: f64) -> Self { + self.plate_thickness = thickness.max(BphxGeometry::MIN_DIMENSION); + self + } + + /// Sets the channel spacing (plate gap) in meters. + pub fn with_channel_spacing(mut self, spacing: f64) -> Self { + self.channel_spacing = spacing.max(BphxGeometry::MIN_DIMENSION); + self + } + + /// Sets the corrugation pitch in meters. + pub fn with_corrugation_pitch(mut self, pitch: f64) -> Self { + self.corrugation_pitch = Some(pitch.max(BphxGeometry::MIN_DIMENSION)); + self + } + + /// Sets the exchanger type. + pub fn with_exchanger_type(mut self, exchanger_type: BphxType) -> Self { + self.exchanger_type = exchanger_type; + self + } + + /// Builds the geometry, calculating dh and area from parameters. + /// + /// # Errors + /// + /// Returns an error if required parameters are missing or invalid. + pub fn build(self) -> Result { + let plate_length = self + .plate_length + .ok_or(BphxGeometryError::MissingParameter { + name: "plate_length", + })?; + let plate_width = self + .plate_width + .ok_or(BphxGeometryError::MissingParameter { + name: "plate_width", + })?; + + let corrugation_pitch = self.corrugation_pitch.unwrap_or(2.0 * self.channel_spacing); + + let dh = 2.0 * self.channel_spacing; + let n_channels = (self.n_plates.saturating_sub(1)) as f64; + let area = 2.0 * n_channels * plate_length * plate_width; + + let geo = BphxGeometry { + n_plates: self.n_plates, + plate_length, + plate_width, + plate_thickness: self.plate_thickness, + chevron_angle: self.chevron_angle, + dh, + area, + channel_spacing: self.channel_spacing, + corrugation_pitch, + exchanger_type: self.exchanger_type, + }; + + geo.validate()?; + Ok(geo) + } +} + +/// Errors for BPHX geometry validation +#[derive(Debug, Clone, thiserror::Error)] +pub enum BphxGeometryError { + #[error("Invalid number of plates: {n_plates}, minimum is {min}")] + InvalidPlates { n_plates: u32, min: u32 }, + + #[error("Invalid {name}: {value}, minimum is {min}")] + InvalidDimension { + name: &'static str, + value: f64, + min: f64, + }, + + #[error("Invalid chevron angle: {angle}°, valid range is {min}° to {max}°")] + InvalidChevronAngle { angle: f64, min: f64, max: f64 }, + + #[error("Missing required parameter: {name}")] + MissingParameter { name: &'static str }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bphx_geometry_default() { + let geo = BphxGeometry::default(); + assert!(geo.validate().is_ok()); + assert_eq!(geo.n_plates, 20); + assert!((geo.chevron_angle - 60.0).abs() < 1e-10); + } + + #[test] + fn test_bphx_geometry_from_dh_area() { + let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20); + assert!((geo.dh - 0.003).abs() < 1e-10); + assert!((geo.area - 0.5).abs() < 1e-10); + assert_eq!(geo.n_plates, 20); + } + + #[test] + fn test_bphx_geometry_builder() { + let geo = BphxGeometry::new(30) + .with_plate_dimensions(0.5, 0.1) + .with_chevron_angle(60.0) + .build() + .expect("Valid geometry"); + + assert_eq!(geo.n_plates, 30); + assert!((geo.plate_length - 0.5).abs() < 1e-10); + assert!((geo.plate_width - 0.1).abs() < 1e-10); + assert!((geo.chevron_angle - 60.0).abs() < 1e-10); + assert!(geo.dh > 0.0); + assert!(geo.area > 0.0); + } + + #[test] + fn test_bphx_geometry_builder_missing_dimensions() { + let result = BphxGeometry::new(20).build(); + assert!(result.is_err()); + } + + #[test] + fn test_bphx_geometry_n_channels() { + let geo = BphxGeometry::new(30) + .with_plate_dimensions(0.5, 0.1) + .build() + .unwrap(); + assert_eq!(geo.n_channels(), 29); + } + + #[test] + fn test_bphx_geometry_mass_flux() { + let geo = BphxGeometry::new(10) + .with_plate_dimensions(0.3, 0.1) + .with_channel_spacing(0.002) + .build() + .unwrap(); + + let mass_flow = 0.1; + let g = geo.mass_flux(mass_flow); + assert!(g > 0.0); + } + + #[test] + fn test_bphx_geometry_validate_invalid_plates() { + let mut geo = BphxGeometry::default(); + geo.n_plates = 0; + assert!(geo.validate().is_err()); + } + + #[test] + fn test_bphx_geometry_validate_invalid_chevron() { + let mut geo = BphxGeometry::default(); + geo.chevron_angle = 5.0; + assert!(geo.validate().is_err()); + } + + #[test] + fn test_bphx_geometry_with_exchanger_type() { + let geo = BphxGeometry::new(20) + .with_plate_dimensions(0.3, 0.1) + .with_exchanger_type(BphxType::Evaporator) + .build() + .unwrap(); + assert_eq!(geo.exchanger_type, BphxType::Evaporator); + } + + #[test] + fn test_bphx_geometry_display() { + let geo = BphxGeometry::default(); + let s = format!("{}", geo); + assert!(s.contains("20 plates")); + assert!(s.contains("60°")); + } + + #[test] + fn test_bphx_geometry_clamps_min_plates() { + let geo = BphxGeometry::from_dh_area(0.003, 0.5, 1); + assert_eq!(geo.n_plates, 1); + } +} diff --git a/crates/components/src/heat_exchanger/mod.rs b/crates/components/src/heat_exchanger/mod.rs index c30fe82..a03802f 100644 --- a/crates/components/src/heat_exchanger/mod.rs +++ b/crates/components/src/heat_exchanger/mod.rs @@ -15,10 +15,19 @@ //! ## Components //! //! - [`Condenser`]: Refrigerant condensing (phase change) on hot side -//! - [`Evaporator`]: Refrigerant evaporating (phase change) on cold side +//! - [`Evaporator`]: Refrigerant evaporating (phase change) on cold side (DX style) +//! - [`FloodedEvaporator`]: Flooded/recirculation evaporator (two-phase outlet) +//! - [`FloodedCondenser`]: Flooded/accumulation condenser (subcooled liquid outlet) //! - [`EvaporatorCoil`]: Air-side evaporator (finned coil) //! - [`CondenserCoil`]: Air-side condenser (finned coil) //! - [`Economizer`]: Internal heat exchanger with bypass support +//! - [`BphxExchanger`]: Brazed Plate Heat Exchanger with geometry-based correlations +//! +//! ## BPHX Components (Story 11.5) +//! +//! - [`BphxExchanger`]: Brazed plate heat exchanger component +//! - [`BphxGeometry`]: Geometry specification for BPHX +//! - [`BphxCorrelation`]: Heat transfer correlation selection //! //! ## Example //! @@ -30,6 +39,9 @@ //! // Heat exchanger would be created with connected ports //! ``` +pub mod bphx_correlation; +pub mod bphx_exchanger; +pub mod bphx_geometry; pub mod condenser; pub mod condenser_coil; pub mod economizer; @@ -37,9 +49,18 @@ pub mod eps_ntu; pub mod evaporator; pub mod evaporator_coil; pub mod exchanger; +pub mod flooded_condenser; +pub mod flooded_evaporator; pub mod lmtd; +pub mod mchx_condenser_coil; pub mod model; +pub use bphx_correlation::{ + BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime, + ValidityStatus, +}; +pub use bphx_exchanger::BphxExchanger; +pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType}; pub use condenser::Condenser; pub use condenser_coil::CondenserCoil; pub use economizer::Economizer; @@ -47,5 +68,8 @@ pub use eps_ntu::{EpsNtuModel, ExchangerType}; pub use evaporator::Evaporator; pub use evaporator_coil::EvaporatorCoil; pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions}; +pub use flooded_condenser::FloodedCondenser; +pub use flooded_evaporator::FloodedEvaporator; pub use lmtd::{FlowConfiguration, LmtdModel}; +pub use mchx_condenser_coil::MchxCondenserCoil; pub use model::HeatTransferModel;