1649 lines
47 KiB
Markdown
1649 lines
47 KiB
Markdown
# 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
|
||
```
|