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

1649 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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
```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<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
```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<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
```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
```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<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
```rust
/// 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
```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<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
```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<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
```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
```