Entropyk/_bmad-output/planning-artifacts/epic-11-technical-specifications.md

47 KiB
Raw Blame History

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
  2. Story 11.2: Drum - Ballon de Recirculation
  3. Story 11.3: FloodedEvaporator
  4. Story 11.4: FloodedCondenser
  5. Story 11.5: BphxExchanger Base
  6. Story 11.6-7: BphxEvaporator/Condenser
  7. Story 11.8: CorrelationSelector
  8. Story 11.9-10: MovingBoundaryHX
  9. Story 11.11-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

// 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<Arc<dyn FluidBackend>>,
    /// 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<f64>,
    /// Titre de vapeur (-), None si monophasique
    pub quality: Option<f64>,
    /// Surchauffe (K), None si pas surchauffé
    pub superheat: Option<f64>,
    /// Sous-refroidissement (K), None si pas sous-refroidi
    pub subcooling: Option<f64>,
    /// Débit massique (kg/s)
    pub mass_flow: f64,
    /// Température de saturation (K), None si hors zone diphasique
    pub saturation_temp: Option<f64>,
    /// Phase du fluide
    pub phase: Option<Phase>,
}

#[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<String>,
        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<dyn FluidBackend>) -> 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<f64> {
        self.measurements.quality
    }
    
    /// Retourne la surchauffe (K), ou None
    pub fn superheat(&self) -> Option<f64> {
        self.measurements.superheat
    }
    
    /// Retourne le sous-refroidissement (K), ou None
    pub fn subcooling(&self) -> Option<f64> {
        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<Vec<entropyk_core::MassFlow>, 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

#[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

// 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<dyn FluidBackend>,
    /// Facteurs de calibration
    calib: Calib,
}

impl Drum {
    /// Crée un nouveau ballon de recirculation
    pub fn new(
        fluid: impl Into<String>,
        feed_inlet: ConnectedPort,
        evaporator_return: ConnectedPort,
        liquid_outlet: ConnectedPort,
        vapor_outlet: ConnectedPort,
        backend: Arc<dyn FluidBackend>,
    ) -> Result<Self, ComponentError> {
        // 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

#[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

// 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<dyn HeatTransferModel>,
    /// 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<dyn FluidBackend>,
    /// 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<String>,
        secondary_fluid: impl Into<String>,
        refrigerant_inlet: ConnectedPort,
        refrigerant_outlet: ConnectedPort,
        secondary_inlet: ConnectedPort,
        secondary_outlet: ConnectedPort,
        backend: Arc<dyn FluidBackend>,
    ) -> 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<String>,
        secondary_fluid: impl Into<String>,
        refrigerant_inlet: ConnectedPort,
        refrigerant_outlet: ConnectedPort,
        secondary_inlet: ConnectedPort,
        secondary_outlet: ConnectedPort,
        backend: Arc<dyn FluidBackend>,
    ) -> 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

// 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<f64>,
    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<f64>,
    /// 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<f64>,
    /// Nombre de Prandtl
    pub pr: Option<f64>,
    /// Nombre de Nusselt
    pub nu: Option<f64>,
    /// Zone de validité
    pub validity: ValidityRange,
}

#[derive(Debug, Clone, Default)]
pub struct ValidityRange {
    pub re_min: Option<f64>,
    pub re_max: Option<f64>,
    pub quality_min: Option<f64>,
    pub quality_max: Option<f64>,
    pub mass_flux_min: Option<f64>,
    pub mass_flux_max: Option<f64>,
    pub is_valid: bool,
    pub warning: Option<String>,
}

/// 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<CorrelationType>;
    
    /// Géométries supportées
    fn supported_geometries(&self) -> Vec<ExchangerGeometryType>;
    
    /// Calcule le coefficient de transfert thermique
    fn compute(&self, ctx: &CorrelationContext) -> Result<CorrelationResult, CorrelationError>;
    
    /// 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<CorrelationType, Box<dyn HeatTransferCorrelation>>,
    /// Corrélation actuellement sélectionnée
    selected: Option<Box<dyn HeatTransferCorrelation>>,
}

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<dyn HeatTransferCorrelation>
        );
        defaults.insert(
            CorrelationType::Condensation,
            Box::new(LongoCorrelation::condensation()) as Box<dyn HeatTransferCorrelation>
        );
        
        // Gnielinski - Défaut pour monophasique
        defaults.insert(
            CorrelationType::SinglePhaseHeating,
            Box::new(GnielinskiCorrelation::new()) as Box<dyn HeatTransferCorrelation>
        );
        defaults.insert(
            CorrelationType::SinglePhaseCooling,
            Box::new(GnielinskiCorrelation::new()) as Box<dyn HeatTransferCorrelation>
        );
        
        Self {
            defaults,
            selected: None,
        }
    }
    
    /// Sélectionne une corrélation
    pub fn select(&mut self, correlation: Box<dyn HeatTransferCorrelation>) {
        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<CorrelationResult, CorrelationError> {
        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

// 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<CorrelationType> {
        vec![
            CorrelationType::Evaporation,
            CorrelationType::Condensation,
        ]
    }
    
    fn supported_geometries(&self) -> Vec<ExchangerGeometryType> {
        vec![
            ExchangerGeometryType::BrazedPlate,
            ExchangerGeometryType::GasketedPlate,
        ]
    }
    
    fn compute(&self, ctx: &CorrelationContext) -> Result<CorrelationResult, CorrelationError> {
        // 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

/// Cache pour MovingBoundaryHX
#[derive(Debug, Clone)]
pub struct MovingBoundaryCache {
    /// Positions des frontières de zone (0.0 à 1.0)
    pub zone_boundaries: Vec<f64>,
    /// UA par zone
    pub ua_per_zone: Vec<f64>,
    /// 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

// 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<UaCurve>,
}

#[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<Vec<String>, VendorError>;
    
    /// Obtient les coefficients d'un compresseur
    fn get_compressor_coefficients(
        &self, 
        model: &str
    ) -> Result<CompressorCoefficients, VendorError>;
    
    /// Liste les modèles BPHX disponibles
    fn list_bphx_models(&self) -> Result<Vec<String>, VendorError>;
    
    /// Obtient les paramètres d'un BPHX
    fn get_bphx_parameters(
        &self, 
        model: &str
    ) -> Result<BphxParameters, VendorError>;
    
    /// Calcule UA avec méthode propriétaire (optionnel)
    fn compute_ua(
        &self,
        model: &str,
        params: &UaCalcParams,
    ) -> Result<f64, VendorError> {
        // Défaut: utiliser UA nominal
        let bphx = self.get_bphx_parameters(model)?;
        Ok(bphx.ua_nominal)
    }
}

Parser Copeland

// 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<String, CompressorCoefficients>,
}

impl CopelandBackend {
    pub fn new() -> Result<Self, VendorError> {
        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<String> = 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<CompressorCoefficients, VendorError> {
        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<Vec<String>, VendorError> {
        Ok(self.compressor_cache.keys().cloned().collect())
    }
    
    fn get_compressor_coefficients(
        &self,
        model: &str,
    ) -> Result<CompressorCoefficients, VendorError> {
        self.compressor_cache
            .get(model)
            .cloned()
            .ok_or_else(|| VendorError::ModelNotFound(model.to_string()))
    }
    
    fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
        // Copeland ne fournit pas de BPHX
        Ok(vec![])
    }
    
    fn get_bphx_parameters(&self, _model: &str) -> Result<BphxParameters, VendorError> {
        Err(VendorError::ModelNotFound("Copeland does not provide BPHX data".into()))
    }
}

Format JSON Copeland

// 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