47 KiB
47 KiB
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
- Story 11.1: Node - Sonde Passive
- Story 11.2: Drum - Ballon de Recirculation
- Story 11.3: FloodedEvaporator
- Story 11.4: FloodedCondenser
- Story 11.5: BphxExchanger Base
- Story 11.6-7: BphxEvaporator/Condenser
- Story 11.8: CorrelationSelector
- Story 11.9-10: MovingBoundaryHX
- 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