# Epic 11: Spécifications Techniques Détaillées **Date:** 2026-02-22 **Status:** 📋 Prêt pour développement **Dépendances:** Epic 9 (Coherence Corrections) --- ## Table des Matières 1. [Story 11.1: Node - Sonde Passive](#story-111-node---sonde-passive) 2. [Story 11.2: Drum - Ballon de Recirculation](#story-112-drum---ballon-de-recirculation) 3. [Story 11.3: FloodedEvaporator](#story-113-floodedevaporator) 4. [Story 11.4: FloodedCondenser](#story-114-floodedcondenser) 5. [Story 11.5: BphxExchanger Base](#story-115-bphxexchanger-base) 6. [Story 11.6-7: BphxEvaporator/Condenser](#story-116-7-bphxevaporatorcondenser) 7. [Story 11.8: CorrelationSelector](#story-118-correlationselector) 8. [Story 11.9-10: MovingBoundaryHX](#story-119-10-movingboundaryhx) 9. [Story 11.11-15: VendorBackend](#story-1111-15-vendorbackend) --- ## Story 11.1: Node - Sonde Passive ### Vue d'ensemble Composant passif (0 équations) servant de point de mesure dans le circuit. Peut être inséré n'importe où pour extraire des valeurs. ### Spécification Rust ```rust // Fichier: crates/components/src/node.rs use entropyk_core::{Pressure, Temperature, Enthalpy, MassFlow}; use entropyk_fluids::{FluidBackend, FluidId, ThermoState, Property}; use crate::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState}; use std::sync::Arc; /// Node - Sonde passive pour extraction de mesures /// /// Composant passif (0 équations) qui permet d'extraire: /// - Pression (P) /// - Température (T) /// - Enthalpie (h) /// - Titre (x) - si diphasique /// - Surchauffe (SH) - si surchauffé /// - Sous-refroidissement (SC) - si sous-refroidi /// - Débit massique (ṁ) /// /// # Example /// /// ```rust /// use entropyk_components::Node; /// /// // Créer une sonde après l'évaporateur /// let probe = Node::new("evaporator_outlet", inlet_port, outlet_port); /// /// // Après convergence, extraire les valeurs /// let t_sh = probe.superheat().unwrap(); // Surchauffe en K /// let p = probe.pressure(); // Pression en Pa /// ``` #[derive(Debug)] pub struct Node { /// Nom de la sonde (pour identification) name: String, /// Port d'entrée inlet: ConnectedPort, /// Port de sortie outlet: ConnectedPort, /// Backend fluide pour calculs avancés (optionnel) fluid_backend: Option>, /// Mesures calculées (mises à jour post-solve) measurements: NodeMeasurements, } /// Mesures extraites de la sonde #[derive(Debug, Clone, Default)] pub struct NodeMeasurements { /// Pression (Pa) pub pressure: f64, /// Température (K) pub temperature: f64, /// Enthalpie (J/kg) pub enthalpy: f64, /// Entropie (J/kg·K) pub entropy: Option, /// Titre de vapeur (-), None si monophasique pub quality: Option, /// Surchauffe (K), None si pas surchauffé pub superheat: Option, /// Sous-refroidissement (K), None si pas sous-refroidi pub subcooling: Option, /// Débit massique (kg/s) pub mass_flow: f64, /// Température de saturation (K), None si hors zone diphasique pub saturation_temp: Option, /// Phase du fluide pub phase: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { SubcooledLiquid, TwoPhase, SuperheatedVapor, Supercritical, } impl Node { /// Crée une nouvelle sonde passive pub fn new( name: impl Into, inlet: ConnectedPort, outlet: ConnectedPort, ) -> Self { Self { name: name.into(), inlet, outlet, fluid_backend: None, measurements: NodeMeasurements::default(), } } /// Ajoute un backend fluide pour calculs avancés (surchauffe, titre, etc.) pub fn with_fluid_backend(mut self, backend: Arc) -> Self { self.fluid_backend = Some(backend); self } /// Retourne le nom de la sonde pub fn name(&self) -> &str { &self.name } /// Retourne la pression (Pa) pub fn pressure(&self) -> f64 { self.measurements.pressure } /// Retourne la température (K) pub fn temperature(&self) -> f64 { self.measurements.temperature } /// Retourne l'enthalpie (J/kg) pub fn enthalpy(&self) -> f64 { self.measurements.enthalpy } /// Retourne le titre de vapeur (-), ou None pub fn quality(&self) -> Option { self.measurements.quality } /// Retourne la surchauffe (K), ou None pub fn superheat(&self) -> Option { self.measurements.superheat } /// Retourne le sous-refroidissement (K), ou None pub fn subcooling(&self) -> Option { self.measurements.subcooling } /// Retourne le débit massique (kg/s) pub fn mass_flow(&self) -> f64 { self.measurements.mass_flow } /// Retourne toutes les mesures pub fn measurements(&self) -> &NodeMeasurements { &self.measurements } /// Met à jour les mesures depuis l'état du système (appelé post-solve) pub fn update_measurements(&mut self, state: &SystemState) -> Result<(), ComponentError> { // Extraction des valeurs de base depuis les ports self.measurements.pressure = self.inlet.pressure().to_pascals(); self.measurements.enthalpy = self.inlet.enthalpy().to_joules_per_kg(); self.measurements.mass_flow = self.inlet.mass_flow().to_kg_per_s(); // Si backend disponible, calculs avancés if let Some(ref backend) = self.fluid_backend { if let Some(ref fluid_id) = self.inlet.fluid_id() { self.compute_advanced_measurements(backend, fluid_id)?; } } Ok(()) } fn compute_advanced_measurements( &mut self, backend: &dyn FluidBackend, fluid_id: &FluidId, ) -> Result<(), ComponentError> { let p = self.measurements.pressure; let h = self.measurements.enthalpy; // Calculer la température self.measurements.temperature = backend.temperature_ph(fluid_id, p, h)?; // Calculer l'entropie self.measurements.entropy = backend.entropy_ph(fluid_id, p, h).ok(); // Calculer les propriétés de saturation let h_sat_l = backend.enthalpy_px(fluid_id, p, 0.0).ok(); let h_sat_v = backend.enthalpy_px(fluid_id, p, 1.0).ok(); let t_sat = backend.saturation_temperature(fluid_id, p).ok(); self.measurements.saturation_temp = t_sat; // Déterminer la phase et calculer titre/surchauffe/sous-refroidissement if let (Some(h_l), Some(h_v), Some(t_sat)) = (h_sat_l, h_sat_v, t_sat) { if h <= h_l { // Liquide sous-refroidi self.measurements.phase = Some(Phase::SubcooledLiquid); self.measurements.quality = None; // Calcul Cp liquide pour sous-refroidissement let cp_l = backend.cp_ph(fluid_id, p, h_l).unwrap_or(4180.0); self.measurements.subcooling = Some((h_l - h) / cp_l); self.measurements.superheat = None; } else if h >= h_v { // Vapeur surchauffée self.measurements.phase = Some(Phase::SuperheatedVapor); self.measurements.quality = None; // Calcul Cp vapeur pour surchauffe let cp_v = backend.cp_ph(fluid_id, p, h_v).unwrap_or(1000.0); self.measurements.superheat = Some((h - h_v) / cp_v); self.measurements.subcooling = None; } else { // Zone diphasique self.measurements.phase = Some(Phase::TwoPhase); self.measurements.quality = Some((h - h_l) / (h_v - h_l)); self.measurements.superheat = None; self.measurements.subcooling = None; } } Ok(()) } } impl Component for Node { /// 0 équations - composant passif fn n_equations(&self) -> usize { 0 } fn compute_residuals( &self, _state: &SystemState, _residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // Pas de résidus - composant passif Ok(()) } fn jacobian_entries( &self, _state: &SystemState, _jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Pas de Jacobien - composant passif Ok(()) } fn get_ports(&self) -> &[ConnectedPort] { // Retourne les ports pour la topologie &[] // Les ports sont gérés séparément } /// Hook post-solve pour mettre à jour les mesures fn post_solve(&mut self, state: &SystemState) -> Result<(), ComponentError> { self.update_measurements(state) } fn port_mass_flows( &self, _state: &SystemState, ) -> Result, ComponentError> { // Passif - pas de contribution aux bilans Ok(vec![]) } fn energy_transfers(&self, _state: &SystemState) -> Option<(entropyk_core::Power, entropyk_core::Power)> { // Pas de transfert d'énergie Some((entropyk_core::Power::from_watts(0.0), entropyk_core::Power::from_watts(0.0))) } } ``` ### Fichiers à créer/modifier | Fichier | Action | |---------|--------| | `crates/components/src/node.rs` | Créer | | `crates/components/src/lib.rs` | Ajouter `mod node; pub use node::*` | | `crates/components/src/flow_junction.rs` | Référencer Node pour documentation | ### Tests Unitaires Requis ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_node_zero_equations() { let node = Node::new("test", inlet, outlet); assert_eq!(node.n_equations(), 0); } #[test] fn test_node_extract_pressure() { // Test extraction pression depuis port } #[test] fn test_node_superheat_calculation() { // Test calcul surchauffe avec backend } #[test] fn test_node_subcooling_calculation() { // Test calcul sous-refroidissement avec backend } #[test] fn test_node_quality_calculation() { // Test calcul titre en zone diphasique } #[test] fn test_node_no_backend_graceful() { // Test que Node fonctionne sans backend (mesures de base uniquement) } } ``` --- ## Story 11.2: Drum - Ballon de Recirculation ### Vue d'ensemble Ballon de recirculation pour évaporateurs à recirculation. Sépare un mélange diphasique en liquide saturé et vapeur saturée. ### Équations Mathématiques ``` Ports: in1: Feed (depuis économiseur) in2: Retour évaporateur (diphasique enrichi) out1: Liquide saturé (x=0) vers pompe out2: Vapeur saturée (x=1) vers compresseur Équations (8): 1. Mélange entrées: ṁ_total = ṁ_in1 + ṁ_in2 h_mixed = (ṁ_in1·h_in1 + ṁ_in2·h_in2) / ṁ_total 2. Bilan masse: ṁ_out1 + ṁ_out2 = ṁ_total 3. Bilan énergie: ṁ_out1·h_out1 + ṁ_out2·h_out2 = ṁ_total·h_mixed 4. Égalité pression: P_out1 = P_in1 P_out2 = P_in1 5. Liquide saturé: h_out1 = h_sat(P, x=0) 6. Vapeur saturée: h_out2 = h_sat(P, x=1) 7. Continuité fluide: fluid_out1 = fluid_in1 fluid_out2 = fluid_in1 ``` ### Spécification Rust ```rust // Fichier: crates/components/src/drum.rs use entropyk_core::{Pressure, Enthalpy, MassFlow, Power}; use entropyk_fluids::{FluidBackend, FluidId, Property}; use crate::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState}; use std::sync::Arc; /// Drum - Ballon de recirculation pour évaporateurs /// /// Sépare un mélange diphasique (2 entrées) en: /// - Liquide saturé (x=0) vers la pompe de recirculation /// - Vapeur saturée (x=1) vers le compresseur /// /// # Example /// /// ```rust /// use entropyk_components::Drum; /// /// let drum = Drum::new( /// "R410A", /// feed_inlet, // Depuis économiseur /// evaporator_return, // Retour évaporateur /// liquid_outlet, // Vers pompe /// vapor_outlet, // Vers compresseur /// backend /// ); /// ``` #[derive(Debug)] pub struct Drum { /// Identifiant du fluide (doit être pur) fluid_id: String, /// Entrée feed (depuis économiseur) feed_inlet: ConnectedPort, /// Retour évaporateur (diphasique) evaporator_return: ConnectedPort, /// Sortie liquide saturé (x=0) liquid_outlet: ConnectedPort, /// Sortie vapeur saturée (x=1) vapor_outlet: ConnectedPort, /// Backend fluide pour calculs de saturation fluid_backend: Arc, /// Facteurs de calibration calib: Calib, } impl Drum { /// Crée un nouveau ballon de recirculation pub fn new( fluid: impl Into, feed_inlet: ConnectedPort, evaporator_return: ConnectedPort, liquid_outlet: ConnectedPort, vapor_outlet: ConnectedPort, backend: Arc, ) -> Result { // Validation: fluide pur requis pour calculs de saturation let fluid = fluid.into(); Self::validate_pure_fluid(&fluid)?; Ok(Self { fluid_id: fluid, feed_inlet, evaporator_return, liquid_outlet, vapor_outlet, fluid_backend: backend, calib: Calib::default(), }) } fn validate_pure_fluid(fluid: &str) -> Result<(), ComponentError> { // Les mélanges zeotropiques ont un glide et ne peuvent pas // être représentés par x=0 et x=1 à une seule température // Mais R410A, R407C, etc. sont souvent traités comme pseudo-purs Ok(()) } /// Retourne le débit de liquide vers la pompe (kg/s) pub fn liquid_mass_flow(&self, state: &SystemState) -> f64 { self.liquid_outlet.mass_flow().to_kg_per_s() } /// Retourne le débit de vapeur vers le compresseur (kg/s) pub fn vapor_mass_flow(&self, state: &SystemState) -> f64 { self.vapor_outlet.mass_flow().to_kg_per_s() } /// Retourne le ratio de recirculation pub fn recirculation_ratio(&self, state: &SystemState) -> f64 { let m_liquid = self.liquid_mass_flow(state); let m_feed = self.feed_inlet.mass_flow().to_kg_per_s(); if m_feed > 0.0 { m_liquid / m_feed } else { 0.0 } } } impl Component for Drum { fn n_equations(&self) -> usize { 8 // mass + energy + 2 pressure + 2 saturation + 2 fluid } fn compute_residuals( &self, state: &SystemState, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // Extraction des variables d'état let p_feed = self.feed_inlet.pressure().to_pascals(); let h_feed = self.feed_inlet.enthalpy().to_joules_per_kg(); let m_feed = self.feed_inlet.mass_flow().to_kg_per_s(); let h_return = self.evaporator_return.enthalpy().to_joules_per_kg(); let m_return = self.evaporator_return.mass_flow().to_kg_per_s(); let p_liq = self.liquid_outlet.pressure().to_pascals(); let h_liq = self.liquid_outlet.enthalpy().to_joules_per_kg(); let m_liq = self.liquid_outlet.mass_flow().to_kg_per_s(); let p_vap = self.vapor_outlet.pressure().to_pascals(); let h_vap = self.vapor_outlet.enthalpy().to_joules_per_kg(); let m_vap = self.vapor_outlet.mass_flow().to_kg_per_s(); // Mélange des entrées let m_total = m_feed + m_return; let h_mixed = if m_total > 1e-10 { (m_feed * h_feed + m_return * h_return) / m_total } else { 0.0 }; // Propriétés de saturation à la pression de feed let h_sat_l = self.fluid_backend.enthalpy_px( &FluidId::new(&self.fluid_id), p_feed, 0.0 )?; let h_sat_v = self.fluid_backend.enthalpy_px( &FluidId::new(&self.fluid_id), p_feed, 1.0 )?; // Titre du mélange let x_mixed = if h_sat_v > h_sat_l { ((h_mixed - h_sat_l) / (h_sat_v - h_sat_l)).clamp(0.0, 1.0) } else { 0.5 }; // Débits dérivés du titre let m_vap_expected = m_total * x_mixed; let m_liq_expected = m_total * (1.0 - x_mixed); let mut idx = 0; // 1. Bilan masse: m_liq + m_vap - m_total = 0 residuals[idx] = m_liq + m_vap - m_total; idx += 1; // 2. Bilan énergie (via enthalpies) // m_liq * h_liq + m_vap * h_vap - m_total * h_mixed = 0 residuals[idx] = m_liq * h_liq + m_vap * h_vap - m_total * h_mixed; idx += 1; // 3-4. Égalité pression: P_liq = P_feed, P_vap = P_feed residuals[idx] = p_liq - p_feed; idx += 1; residuals[idx] = p_vap - p_feed; idx += 1; // 5. Liquide saturé: h_liq - h_sat(P, x=0) = 0 residuals[idx] = h_liq - h_sat_l; idx += 1; // 6. Vapeur saturée: h_vap - h_sat(P, x=1) = 0 residuals[idx] = h_vap - h_sat_v; idx += 1; // 7-8. Continuité fluide (implicite via FluidId des ports) residuals[idx] = 0.0; idx += 1; residuals[idx] = 0.0; Ok(()) } fn jacobian_entries( &self, state: &SystemState, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Jacobien analytique pour Drum // Les dérivées partielles dépendent des indices de state // ... implémentation détaillée Ok(()) } fn energy_transfers(&self, _state: &SystemState) -> Option<(Power, Power)> { // Adiabatique: Q=0, W=0 Some((Power::from_watts(0.0), Power::from_watts(0.0))) } } ``` ### Fichiers à créer/modifier | Fichier | Action | |---------|--------| | `crates/components/src/drum.rs` | Créer | | `crates/components/src/lib.rs` | Ajouter `mod drum; pub use drum::*` | ### Tests Unitaires Requis ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_drum_equations_count() { assert_eq!(drum.n_equations(), 8); } #[test] fn test_drum_mass_balance() { // Test: m_liq + m_vap = m_feed + m_return } #[test] fn test_drum_saturated_outlets() { // Test: h_liq = h_sat(x=0), h_vap = h_sat(x=1) } #[test] fn test_drum_pressure_equality() { // Test: P_liq = P_vap = P_feed } #[test] fn test_drum_recirculation_ratio() { // Test: ratio = m_liq / m_feed } } ``` --- ## Story 11.3: FloodedEvaporator ### Vue d'ensemble Évaporateur où le réfrigérant liquide inonde complètement les tubes via un récepteur basse pression. Produit un mélange diphasique (50-80% gaz). ### Équations Mathématiques ``` Ports: refrigerant_in: Entrée réfrigérant (liquide sous-refroidi ou diphasique) refrigerant_out: Sortie réfrigérant (diphasique, titre ~0.5-0.8) fluid_in: Entrée fluide secondaire (eau/glycol) fluid_out: Sortie fluide secondaire (refroidi) Paramètres: UA: Coefficient global de transfert thermique (W/K) V_recept: Volume du récepteur (m³) - optionnel Équations: 1. Transfert thermique (LMTD ou ε-NTU): Q = UA × ΔT_lm ou Q = ε × C_min × (T_fluid_in - T_ref_in) 2. Bilan énergie côté réfrigérant: Q = ṁ_ref × (h_ref_out - h_ref_in) 3. Bilan énergie côté fluide: Q = ṁ_fluid × cp_fluid × (T_fluid_in - T_fluid_out) 4. Titre de sortie (typiquement 0.5-0.8): x_out = f(Q, ṁ_ref, h_sat) 5. Perte de charge (optionnelle): ΔP_ref = f(ṁ_ref, géométrie) ``` ### Spécification Rust ```rust // Fichier: crates/components/src/flooded_evaporator.rs use entropyk_core::{Power, Calib}; use entropyk_fluids::{FluidBackend, FluidId}; use crate::heat_exchanger::{HeatTransferModel, LmtdModel, EpsNtuModel}; use crate::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState}; use std::sync::Arc; /// FloodedEvaporator - Évaporateur noyé /// /// Évaporateur où le réfrigérant liquide inonde les tubes. /// Typiquement utilisé dans les chillers avec recirculation. #[derive(Debug)] pub struct FloodedEvaporator { /// Modèle de transfert thermique model: Box, /// Identifiant réfrigérant refrigerant_id: String, /// Identifiant fluide secondaire secondary_fluid_id: String, /// Ports réfrigérant refrigerant_inlet: ConnectedPort, refrigerant_outlet: ConnectedPort, /// Ports fluide secondaire secondary_inlet: ConnectedPort, secondary_outlet: ConnectedPort, /// Backend fluide fluid_backend: Arc, /// Facteurs de calibration calib: Calib, /// Titre de sortie cible (typiquement 0.5-0.8) target_outlet_quality: f64, } impl FloodedEvaporator { /// Crée un évaporateur flooded avec modèle LMTD pub fn with_lmtd( ua: f64, refrigerant: impl Into, secondary_fluid: impl Into, refrigerant_inlet: ConnectedPort, refrigerant_outlet: ConnectedPort, secondary_inlet: ConnectedPort, secondary_outlet: ConnectedPort, backend: Arc, ) -> Self { Self { model: Box::new(LmtdModel::counter_flow(ua)), refrigerant_id: refrigerant.into(), secondary_fluid_id: secondary_fluid.into(), refrigerant_inlet, refrigerant_outlet, secondary_inlet, secondary_outlet, fluid_backend: backend, calib: Calib::default(), target_outlet_quality: 0.7, // 70% vapeur typique } } /// Crée un évaporateur flooded avec modèle ε-NTU pub fn with_eps_ntu( ua: f64, refrigerant: impl Into, secondary_fluid: impl Into, refrigerant_inlet: ConnectedPort, refrigerant_outlet: ConnectedPort, secondary_inlet: ConnectedPort, secondary_outlet: ConnectedPort, backend: Arc, ) -> Self { Self { model: Box::new(EpsNtuModel::counter_flow(ua)), // ... reste identique target_outlet_quality: 0.7, } } /// Définit le titre de sortie cible pub fn with_target_quality(mut self, quality: f64) -> Self { self.target_outlet_quality = quality.clamp(0.0, 1.0); self } /// Retourne le transfert thermique (W) pub fn heat_transfer(&self) -> f64 { // Calculé après convergence 0.0 // Placeholder } /// Retourne le titre de sortie pub fn outlet_quality(&self, state: &SystemState) -> f64 { let h_out = self.refrigerant_outlet.enthalpy().to_joules_per_kg(); let p = self.refrigerant_outlet.pressure().to_pascals(); let h_sat_l = self.fluid_backend.enthalpy_px( &FluidId::new(&self.refrigerant_id), p, 0.0 ).unwrap_or(0.0); let h_sat_v = self.fluid_backend.enthalpy_px( &FluidId::new(&self.refrigerant_id), p, 1.0 ).unwrap_or(1.0); if h_sat_v > h_sat_l { ((h_out - h_sat_l) / (h_sat_v - h_sat_l)).clamp(0.0, 1.0) } else { 0.5 } } } impl Component for FloodedEvaporator { fn n_equations(&self) -> usize { 4 // Q equation + energy ref + energy secondary + pressure drop (optional) } fn compute_residuals( &self, state: &SystemState, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // Utilise le modèle de transfert thermique // ... implémentation détaillée Ok(()) } fn jacobian_entries( &self, state: &SystemState, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { Ok(()) } fn energy_transfers(&self, _state: &SystemState) -> Option<(Power, Power)> { // Q > 0 (absorbe la chaleur), W = 0 let q = Power::from_watts(self.heat_transfer()); Some((q, Power::from_watts(0.0))) } } ``` --- ## Story 11.8: CorrelationSelector ### Vue d'ensemble Système de sélection de corrélation pour les calculs de transfert thermique. ### Spécification Rust ```rust // Fichier: crates/components/src/correlations/mod.rs mod longo; mod shah; mod kandlikar; mod gnielinski; pub use longo::LongoCorrelation; pub use shah::ShahCorrelation; pub use kandlikar::KandlikarCorrelation; pub use gnielinski::GnielinskiCorrelation; use entropyk_fluids::{FluidBackend, FluidId, Phase}; /// Contexte pour le calcul de corrélation pub struct CorrelationContext<'a> { pub backend: &'a dyn FluidBackend, pub fluid_id: &'a FluidId, pub pressure: f64, pub enthalpy: f64, pub mass_flow: f64, pub quality: Option, pub heat_flux: f64, pub geometry: HeatExchangerGeometry, } /// Géométrie de l'échangeur #[derive(Debug, Clone)] pub struct HeatExchangerGeometry { /// Diamètre hydraulique (m) pub dh: f64, /// Surface d'échange (m²) pub area: f64, /// Angle de chevron (degrés) - pour PHE pub chevron_angle: Option, /// Type d'échangeur pub exchanger_type: ExchangerGeometryType, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum ExchangerGeometryType { /// Tube lisse SmoothTube, /// Tube à ailettes FinnedTube, /// Plaques brasées (BPHX) BrazedPlate, /// Plaques à joints GasketedPlate, /// Shell-and-tube ShellAndTube, } /// Type de corrélation #[derive(Debug, Clone, Copy, PartialEq)] pub enum CorrelationType { /// Évaporation Evaporation, /// Condensation Condensation, /// Monophasique (chauffage) SinglePhaseHeating, /// Monophasique (refroidissement) SinglePhaseCooling, } /// Résultat de corrélation #[derive(Debug, Clone)] pub struct CorrelationResult { /// Coefficient de transfert thermique (W/m²·K) pub h: f64, /// Nombre de Reynolds pub re: Option, /// Nombre de Prandtl pub pr: Option, /// Nombre de Nusselt pub nu: Option, /// Zone de validité pub validity: ValidityRange, } #[derive(Debug, Clone, Default)] pub struct ValidityRange { pub re_min: Option, pub re_max: Option, pub quality_min: Option, pub quality_max: Option, pub mass_flux_min: Option, pub mass_flux_max: Option, pub is_valid: bool, pub warning: Option, } /// Trait pour les corrélations de transfert thermique pub trait HeatTransferCorrelation: Send + Sync { /// Nom de la corrélation fn name(&self) -> &str; /// Année de publication fn year(&self) -> u16; /// Type supporté fn supported_types(&self) -> Vec; /// Géométries supportées fn supported_geometries(&self) -> Vec; /// Calcule le coefficient de transfert thermique fn compute(&self, ctx: &CorrelationContext) -> Result; /// Retourne la plage de validité fn validity_range(&self) -> ValidityRange; /// Référence bibliographique fn reference(&self) -> &str; } /// Sélecteur de corrélation #[derive(Debug)] pub struct CorrelationSelector { /// Corrélation par défaut pour chaque type defaults: HashMap>, /// Corrélation actuellement sélectionnée selected: Option>, } impl CorrelationSelector { /// Crée un sélecteur avec les corrélations par défaut pub fn new() -> Self { let mut defaults = HashMap::new(); // Longo (2004) - Défaut pour plaques defaults.insert( CorrelationType::Evaporation, Box::new(LongoCorrelation::evaporation()) as Box ); defaults.insert( CorrelationType::Condensation, Box::new(LongoCorrelation::condensation()) as Box ); // Gnielinski - Défaut pour monophasique defaults.insert( CorrelationType::SinglePhaseHeating, Box::new(GnielinskiCorrelation::new()) as Box ); defaults.insert( CorrelationType::SinglePhaseCooling, Box::new(GnielinskiCorrelation::new()) as Box ); Self { defaults, selected: None, } } /// Sélectionne une corrélation pub fn select(&mut self, correlation: Box) { self.selected = Some(correlation); } /// Utilise la corrélation par défaut pour un type pub fn use_default(&mut self, corr_type: CorrelationType) { if let Some(default) = self.defaults.get(&corr_type) { // Clone la corrélation par défaut // Note: nécessite implémentation de clone pour chaque corrélation } } /// Liste les corrélations disponibles pub fn available_correlations(&self, corr_type: CorrelationType) -> Vec<&str> { match corr_type { CorrelationType::Evaporation => vec![ "Longo (2004)", // Défaut BPHX "Kandlikar (1990)", // Tubes "Shah (1982)", // Tubes "Gungor-Winterton (1986)", "Chen (1966)", "Djordjevic-Kabelac (2008)", ], CorrelationType::Condensation => vec![ "Longo (2004)", // Défaut BPHX "Shah (1979)", // Défaut tubes "Shah (2021)", // Plaques, récent "Ko (2021)", // Low-GWP, récent "Cavallini-Zecchin (1974)", ], CorrelationType::SinglePhaseHeating | CorrelationType::SinglePhaseCooling => { vec![ "Gnielinski (1976)", // Défaut turbulent "Dittus-Boelter (1930)", "Sieder-Tate (1936)", // Laminaire ] } } } /// Calcule avec la corrélation sélectionnée ou par défaut pub fn compute( &self, corr_type: CorrelationType, ctx: &CorrelationContext, ) -> Result { if let Some(ref selected) = self.selected { selected.compute(ctx) } else if let Some(default) = self.defaults.get(&corr_type) { default.compute(ctx) } else { Err(CorrelationError::NoCorrelationSelected) } } } impl Default for CorrelationSelector { fn default() -> Self { Self::new() } } ``` ### Corrélation Longo (2004) - Implémentation ```rust // Fichier: crates/components/src/correlations/longo.rs use super::{HeatTransferCorrelation, CorrelationContext, CorrelationResult, CorrelationType, ExchangerGeometryType, ValidityRange, CorrelationError}; /// Corrélation Longo (2004) pour échangeurs à plaques /// /// Référence: Longo, G.A., Gasparella, A., Sartori, R. (2004) /// "Experimental heat transfer coefficients during refrigerant vaporisation /// and condensation inside herringbone-type plate heat exchangers" /// International Journal of Heat and Mass Transfer #[derive(Debug, Clone)] pub struct LongoCorrelation { correlation_type: CorrelationType, } impl LongoCorrelation { /// Crée la corrélation pour l'évaporation pub fn evaporation() -> Self { Self { correlation_type: CorrelationType::Evaporation, } } /// Crée la corrélation pour la condensation pub fn condensation() -> Self { Self { correlation_type: CorrelationType::Condensation, } } } impl HeatTransferCorrelation for LongoCorrelation { fn name(&self) -> &str { "Longo (2004)" } fn year(&self) -> u16 { 2004 } fn supported_types(&self) -> Vec { vec![ CorrelationType::Evaporation, CorrelationType::Condensation, ] } fn supported_geometries(&self) -> Vec { vec![ ExchangerGeometryType::BrazedPlate, ExchangerGeometryType::GasketedPlate, ] } fn compute(&self, ctx: &CorrelationContext) -> Result { // Propriétés du fluide let p = ctx.pressure; let h = ctx.enthalpy; let g = ctx.mass_flow / ctx.geometry.area; // Flux massique (kg/m²·s) // Obtenir les propriétés thermophysiques let backend = ctx.backend; let fluid = ctx.fluid_id; let rho_l = backend.density_px(fluid, p, 0.0)?; let rho_v = backend.density_px(fluid, p, 1.0)?; let mu_l = backend.viscosity_px(fluid, p, 0.0)?; let mu_v = backend.viscosity_px(fluid, p, 1.0)?; let k_l = backend.thermal_conductivity_px(fluid, p, 0.0)?; let cp_l = backend.cp_px(fluid, p, 0.0)?; // Titre de vapeur let x = ctx.quality.unwrap_or(0.5); // Densité moyenne (void fraction simplifié) let rho_mean = 1.0 / (x / rho_v + (1.0 - x) / rho_l); // Viscosité moyenne (modèle Dukler) let mu_mean = x * mu_v + (1.0 - x) * mu_l; // Reynolds diphasique let re_tp = g * ctx.geometry.dh / mu_mean; // Prandtl liquide let pr_l = mu_l * cp_l / k_l; // Corrélation Longo pour évaporation/condensation // h = C * Re^n * Pr^(1/3) * (k_l / Dh) // où C et n dépendent du type et du flux let (c, n) = match self.correlation_type { CorrelationType::Evaporation => { if g < 20.0 { (0.15, 0.75) // Low mass flux } else { (0.20, 0.70) // High mass flux } } CorrelationType::Condensation => { (0.25, 0.65) } _ => return Err(CorrelationError::UnsupportedCorrelationType), }; let nu = c * re_tp.powf(n) * pr_l.powf(1.0/3.0); let h = nu * k_l / ctx.geometry.dh; Ok(CorrelationResult { h, re: Some(re_tp), pr: Some(pr_l), nu: Some(nu), validity: self.validity_range(), }) } fn validity_range(&self) -> ValidityRange { ValidityRange { re_min: Some(500.0), re_max: Some(50_000.0), quality_min: Some(0.0), quality_max: Some(1.0), mass_flux_min: Some(10.0), mass_flux_max: Some(100.0), // kg/m²·s is_valid: true, warning: None, } } fn reference(&self) -> &str { "Longo, G.A. et al. (2004). Int. J. Heat Mass Transfer, 47, 1039-1047" } } ``` --- ## Story 11.9-10: MovingBoundaryHX ### Algorithme de Discrétisation ``` Entrée: États (P, h) entrée/sortie côtés chaud et froid, UA_total 1. Initialisation: - Calculer T_sat_hot et T_sat_cold si applicable - Identifier les zones potentielles: * Superheated (SH) : T > T_sat * Two-Phase (TP) : x ∈ [0, 1] * Subcooled (SC) : T < T_sat 2. Détection des frontières de zone: - Pour chaque côté, trouver où h = h_sat_l et h = h_sat_v - Mapper sur une position relative [0, 1] le long de l'échangeur 3. Création des sections: - Chaque section = intervalle entre deux frontières - Pour chaque section: déterminer phase_hot, phase_cold 4. Pour chaque section i: - Calculer ΔT_lm,i (log mean temp diff) - Calculer UA_i = UA_total × (ΔT_lm,i / Σ ΔT_lm) - Calculer Q_i = UA_i × ΔT_lm,i 5. Validation pinch: - Vérifier min(T_hot - T_cold) > T_pinch - Si violation, ajuster les frontières 6. Résultats: - Q_total = Σ Q_i - UA_effective = Σ UA_i ``` ### Cache Optimization ```rust /// Cache pour MovingBoundaryHX #[derive(Debug, Clone)] pub struct MovingBoundaryCache { /// Positions des frontières de zone (0.0 à 1.0) pub zone_boundaries: Vec, /// UA par zone pub ua_per_zone: Vec, /// Enthalpies de saturation pub h_sat_l_hot: f64, pub h_sat_v_hot: f64, pub h_sat_l_cold: f64, pub h_sat_v_cold: f64, /// Conditions de validité pub p_ref_hot: f64, pub p_ref_cold: f64, pub m_ref_hot: f64, pub m_ref_cold: f64, /// Cache valide? pub valid: bool, /// Timestamp de création pub created_at: std::time::Instant, } impl MovingBoundaryCache { /// Vérifie si le cache peut être utilisé pub fn is_valid_for( &self, p_hot: f64, p_cold: f64, m_hot: f64, m_cold: f64, max_p_deviation: f64, // ex: 0.05 = 5% max_m_deviation: f64, // ex: 0.10 = 10% ) -> bool { if !self.valid { return false; } // Vérifier déviation pression let p_dev_hot = (p_hot - self.p_ref_hot).abs() / self.p_ref_hot; let p_dev_cold = (p_cold - self.p_ref_cold).abs() / self.p_ref_cold; if p_dev_hot > max_p_deviation || p_dev_cold > max_p_deviation { return false; } // Vérifier déviation débit let m_dev_hot = (m_hot - self.m_ref_hot).abs() / self.m_ref_hot.max(1e-10); let m_dev_cold = (m_cold - self.m_ref_cold).abs() / self.m_ref_cold.max(1e-10); if m_dev_hot > max_m_deviation || m_dev_cold > max_m_deviation { return false; } true } /// Invalide le cache pub fn invalidate(&mut self) { self.valid = false; } } ``` --- ## Story 11.11-15: VendorBackend ### Architecture ``` entropyk-vendors/ ├── Cargo.toml ├── data/ │ ├── copeland/ │ │ ├── compressors/ │ │ │ ├── ZP54KCE-TFD.json │ │ │ ├── ZP49KCE-TFD.json │ │ │ └── index.json │ │ └── metadata.json │ ├── danfoss/ │ │ └── ... │ ├── swep/ │ │ ├── bphx/ │ │ │ ├── B5THx20.json │ │ │ └── index.json │ │ └── ua_curves/ │ │ └── B5THx20_ua.csv │ └── bitzer/ │ └── compressors/ │ └── 4NFC-20Y.csv └── src/ ├── lib.rs ├── error.rs ├── vendor_api.rs # Trait VendorBackend ├── compressors/ │ ├── mod.rs │ ├── copeland.rs │ ├── danfoss.rs │ └── bitzer.rs └── heat_exchangers/ ├── mod.rs └── swep.rs ``` ### Trait VendorBackend ```rust // Fichier: entropyk-vendors/src/vendor_api.rs use serde::{Deserialize, Serialize}; /// Erreur vendor #[derive(Debug, thiserror::Error)] pub enum VendorError { #[error("Model not found: {0}")] ModelNotFound(String), #[error("Invalid data format: {0}")] InvalidFormat(String), #[error("Data file not found: {0}")] FileNotFound(String), #[error("Parse error: {0}")] ParseError(#[from] serde_json::Error), #[error("IO error: {0}")] IoError(#[from] std::io::Error), } /// Coefficients compresseur AHRI 540 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompressorCoefficients { /// Identifiant modèle pub model: String, /// Fabricant pub manufacturer: String, /// Fluide pub refrigerant: String, /// 10 coefficients AHRI 540 /// Capacity = a0 + a1*T_s + a2*T_d + a3*T_s² + a4*T_d² + a5*T_s*T_d + ... pub capacity_coeffs: [f64; 10], /// Coefficients puissance pub power_coeffs: [f64; 10], /// Coefficients débit massique (optionnel) pub mass_flow_coeffs: Option<[f64; 10]>, /// Plage de validité pub validity: ValidityRange, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidityRange { pub t_suction_min: f64, pub t_suction_max: f64, pub t_discharge_min: f64, pub t_discharge_max: f64, } /// Paramètres BPHX #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BphxParameters { /// Identifiant modèle pub model: String, /// Fabricant pub manufacturer: String, /// Nombre de plaques pub num_plates: usize, /// Surface d'échange (m²) pub area: f64, /// Diamètre hydraulique (m) pub dh: f64, /// Angle de chevron (degrés) pub chevron_angle: f64, /// UA nominal (W/K) pour conditions de référence pub ua_nominal: f64, /// Courbes UA part-load (optionnel) pub ua_curve: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UaCurve { /// Points: (mass_flow_ratio, ua_ratio) pub points: Vec<(f64, f64)>, } /// Paramètres pour calcul UA pub struct UaCalcParams { pub mass_flow: f64, pub mass_flow_ref: f64, pub temperature_hot_in: f64, pub temperature_cold_in: f64, pub refrigerant: String, } /// Trait pour backend vendor pub trait VendorBackend: Send + Sync { /// Nom du vendor fn vendor_name(&self) -> &str; /// Liste les modèles de compresseurs disponibles fn list_compressor_models(&self) -> Result, VendorError>; /// Obtient les coefficients d'un compresseur fn get_compressor_coefficients( &self, model: &str ) -> Result; /// Liste les modèles BPHX disponibles fn list_bphx_models(&self) -> Result, VendorError>; /// Obtient les paramètres d'un BPHX fn get_bphx_parameters( &self, model: &str ) -> Result; /// Calcule UA avec méthode propriétaire (optionnel) fn compute_ua( &self, model: &str, params: &UaCalcParams, ) -> Result { // Défaut: utiliser UA nominal let bphx = self.get_bphx_parameters(model)?; Ok(bphx.ua_nominal) } } ``` ### Parser Copeland ```rust // Fichier: entropyk-vendors/src/compressors/copeland.rs use crate::{VendorBackend, VendorError, CompressorCoefficients, BphxParameters, UaCalcParams}; use std::collections::HashMap; use std::path::PathBuf; /// Backend Copeland (Emerson) pub struct CopelandBackend { data_path: PathBuf, compressor_cache: HashMap, } impl CopelandBackend { pub fn new() -> Result { let data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("data") .join("copeland"); let mut backend = Self { data_path, compressor_cache: HashMap::new(), }; // Pré-charger l'index backend.load_index()?; Ok(backend) } fn load_index(&mut self) -> Result<(), VendorError> { let index_path = self.data_path.join("compressors").join("index.json"); let index_content = std::fs::read_to_string(&index_path)?; let models: Vec = serde_json::from_str(&index_content)?; // Pré-charger les modèles (lazy loading aussi possible) for model in models { if let Ok(coeffs) = self.load_model(&model) { self.compressor_cache.insert(model, coeffs); } } Ok(()) } fn load_model(&self, model: &str) -> Result { let model_path = self.data_path .join("compressors") .join(format!("{}.json", model)); let content = std::fs::read_to_string(&model_path)?; let coeffs: CompressorCoefficients = serde_json::from_str(&content)?; Ok(coeffs) } } impl VendorBackend for CopelandBackend { fn vendor_name(&self) -> &str { "Copeland (Emerson)" } fn list_compressor_models(&self) -> Result, VendorError> { Ok(self.compressor_cache.keys().cloned().collect()) } fn get_compressor_coefficients( &self, model: &str, ) -> Result { self.compressor_cache .get(model) .cloned() .ok_or_else(|| VendorError::ModelNotFound(model.to_string())) } fn list_bphx_models(&self) -> Result, VendorError> { // Copeland ne fournit pas de BPHX Ok(vec![]) } fn get_bphx_parameters(&self, _model: &str) -> Result { Err(VendorError::ModelNotFound("Copeland does not provide BPHX data".into())) } } ``` ### Format JSON Copeland ```json // data/copeland/compressors/ZP54KCE-TFD.json { "model": "ZP54KCE-TFD", "manufacturer": "Copeland", "refrigerant": "R410A", "capacity_coeffs": [ 18000.0, 350.0, -120.0, 2.5, 1.8, -4.2, 0.05, 0.03, -0.02, 0.01 ], "power_coeffs": [ 4500.0, 95.0, 45.0, 0.8, 0.5, 1.2, 0.02, 0.01, 0.01, 0.005 ], "validity": { "t_suction_min": -10.0, "t_suction_max": 20.0, "t_discharge_min": 25.0, "t_discharge_max": 65.0 } } ``` --- ## Résumé des Fichiers à Créer | Story | Fichiers | |-------|----------| | 11.1 | `crates/components/src/node.rs` | | 11.2 | `crates/components/src/drum.rs` | | 11.3 | `crates/components/src/flooded_evaporator.rs` | | 11.4 | `crates/components/src/flooded_condenser.rs` | | 11.5-7 | `crates/components/src/bphx.rs` | | 11.8 | `crates/components/src/correlations/mod.rs`, `longo.rs`, `shah.rs`, `kandlikar.rs`, `gnielinski.rs` | | 11.9-10 | `crates/components/src/moving_boundary.rs` | | 11.11-15 | `crates/vendors/` (nouveau crate) | --- ## Ordre de Développement Recommandé ``` Sprint A (Semaine 1-2): ├── 11.1 Node → 4h ✅ Base pour tests ├── 11.2 Drum → 6h ✅ Nécessite Node └── Tests → 4h Sprint B (Semaine 3-4): ├── 11.3 FloodedEvap → 6h ├── 11.4 FloodedCond → 4h ├── 11.8 Correlations → 4h ✅ Base pour BPHX └── Tests → 4h Sprint C (Semaine 5-6): ├── 11.5 BphxBase → 4h ├── 11.6 BphxEvap → 4h ├── 11.7 BphxCond → 4h └── Tests → 4h Sprint D (Semaine 7-8): ├── 11.9 MovingBound → 8h ├── 11.10 Cache → 4h └── Tests → 4h Sprint E (Semaine 9-10): ├── 11.11 Vendor Trait → 4h ├── 11.12 Copeland → 4h ├── 11.13 SWEP → 4h ├── 11.14 Danfoss → 4h ├── 11.15 Bitzer → 4h └── Tests → 4h ```