433 lines
13 KiB
Markdown
433 lines
13 KiB
Markdown
# Story 11.1: Node - Sonde Passive
|
|
|
|
**Epic:** 11 - Advanced HVAC Components
|
|
**Priorité:** P0-CRITIQUE
|
|
**Estimation:** 4h
|
|
**Statut:** done
|
|
**Dépendances:** Epic 9 (Coherence Corrections)
|
|
|
|
---
|
|
|
|
## Story
|
|
|
|
> En tant que modélisateur de systèmes thermodynamiques,
|
|
> Je veux un composant Node passif (0 équations),
|
|
> Afin de pouvoir extraire P, h, T, titre, surchauffe, sous-refroidissement à n'importe quel point du circuit.
|
|
|
|
---
|
|
|
|
## Contexte
|
|
|
|
Actuellement, il n'existe pas de moyen simple d'extraire des mesures à un point donné du circuit sans affecter le système d'équations. Les composants existants (FlowSplitter, FlowMerger) ajoutent des équations et ne sont pas conçus comme des sondes.
|
|
|
|
**Besoin métier:**
|
|
- Extraire la surchauffe après l'évaporateur
|
|
- Mesurer le sous-refroidissement après le condenseur
|
|
- Obtenir la température en un point quelconque
|
|
- Servir de point de jonction dans la topologie sans ajouter de contraintes
|
|
|
|
---
|
|
|
|
## Solution Proposée
|
|
|
|
### Composant Node
|
|
|
|
```
|
|
┌─────────┐
|
|
in ───►│ Node │───► out
|
|
└─────────┘
|
|
|
|
0 équations (passif)
|
|
|
|
Mesures disponibles:
|
|
- pressure (Pa)
|
|
- temperature (K)
|
|
- enthalpy (J/kg)
|
|
- quality (-) [si diphasique]
|
|
- superheat (K) [si surchauffé]
|
|
- subcooling (K) [si sous-refroidi]
|
|
- mass_flow (kg/s)
|
|
- saturation_temp (K)
|
|
- phase (SubcooledLiquid|TwoPhase|SuperheatedVapor|Supercritical)
|
|
```
|
|
|
|
### Architecture
|
|
|
|
```rust
|
|
// crates/components/src/node.rs
|
|
|
|
use entropyk_core::{Pressure, Temperature, Enthalpy, MassFlow, Power};
|
|
use entropyk_fluids::{FluidBackend, FluidId};
|
|
use crate::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
|
|
use std::sync::Arc;
|
|
|
|
/// Node - Sonde passive pour extraction de mesures
|
|
#[derive(Debug)]
|
|
pub struct Node {
|
|
name: String,
|
|
inlet: ConnectedPort,
|
|
outlet: ConnectedPort,
|
|
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
|
measurements: NodeMeasurements,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct NodeMeasurements {
|
|
pub pressure: f64,
|
|
pub temperature: f64,
|
|
pub enthalpy: f64,
|
|
pub entropy: Option<f64>,
|
|
pub quality: Option<f64>,
|
|
pub superheat: Option<f64>,
|
|
pub subcooling: Option<f64>,
|
|
pub mass_flow: f64,
|
|
pub saturation_temp: Option<f64>,
|
|
pub phase: Option<Phase>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Phase {
|
|
SubcooledLiquid,
|
|
TwoPhase,
|
|
SuperheatedVapor,
|
|
Supercritical,
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Fichiers à Créer/Modifier
|
|
|
|
| Fichier | Action |
|
|
|---------|--------|
|
|
| `crates/components/src/node.rs` | Créer |
|
|
| `crates/components/src/lib.rs` | Ajouter `mod node; pub use node::*` |
|
|
|
|
---
|
|
|
|
## Implémentation Détaillée
|
|
|
|
### Constructeurs
|
|
|
|
```rust
|
|
impl Node {
|
|
/// Crée une sonde passive simple
|
|
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
|
|
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
|
self.fluid_backend = Some(backend);
|
|
self
|
|
}
|
|
}
|
|
```
|
|
|
|
### Méthodes d'accès
|
|
|
|
```rust
|
|
impl Node {
|
|
pub fn name(&self) -> &str { &self.name }
|
|
pub fn pressure(&self) -> f64 { self.measurements.pressure }
|
|
pub fn temperature(&self) -> f64 { self.measurements.temperature }
|
|
pub fn enthalpy(&self) -> f64 { self.measurements.enthalpy }
|
|
pub fn quality(&self) -> Option<f64> { self.measurements.quality }
|
|
pub fn superheat(&self) -> Option<f64> { self.measurements.superheat }
|
|
pub fn subcooling(&self) -> Option<f64> { self.measurements.subcooling }
|
|
pub fn mass_flow(&self) -> f64 { self.measurements.mass_flow }
|
|
pub fn measurements(&self) -> &NodeMeasurements { &self.measurements }
|
|
}
|
|
```
|
|
|
|
### Implémentation Component
|
|
|
|
```rust
|
|
impl Component for Node {
|
|
fn n_equations(&self) -> usize { 0 } // Passif!
|
|
|
|
fn compute_residuals(
|
|
&self,
|
|
_state: &SystemState,
|
|
_residuals: &mut ResidualVector,
|
|
) -> Result<(), ComponentError> {
|
|
Ok(()) // Pas de résidus
|
|
}
|
|
|
|
fn jacobian_entries(
|
|
&self,
|
|
_state: &SystemState,
|
|
_jacobian: &mut JacobianBuilder,
|
|
) -> Result<(), ComponentError> {
|
|
Ok(()) // Pas de Jacobien
|
|
}
|
|
|
|
fn post_solve(&mut self, state: &SystemState) -> Result<(), ComponentError> {
|
|
self.update_measurements(state)
|
|
}
|
|
|
|
fn energy_transfers(&self, _state: &SystemState) -> Option<(Power, Power)> {
|
|
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
|
|
}
|
|
}
|
|
```
|
|
|
|
### Calcul des mesures avancées
|
|
|
|
```rust
|
|
impl Node {
|
|
pub fn update_measurements(&mut self, state: &SystemState) -> Result<(), ComponentError> {
|
|
// Extraction des valeurs de base
|
|
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();
|
|
|
|
// Calculs avancés si backend disponible
|
|
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;
|
|
|
|
// Température
|
|
self.measurements.temperature = backend.temperature_ph(fluid_id, p, h)?;
|
|
|
|
// 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étermination de la phase
|
|
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;
|
|
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;
|
|
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(())
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Critères d'Acceptation
|
|
|
|
- [ ] `Node::n_equations()` retourne `0`
|
|
- [ ] `Node::compute_residuals()` ne modifie pas les résidus
|
|
- [ ] `Node::post_solve()` met à jour les mesures
|
|
- [ ] `pressure()`, `temperature()`, `enthalpy()`, `mass_flow()` retournent les valeurs du port
|
|
- [ ] `quality()` retourne `Some(x)` en zone diphasique, `None` sinon
|
|
- [ ] `superheat()` retourne `Some(SH)` si surchauffé, `None` sinon
|
|
- [ ] `subcooling()` retourne `Some(SC)` si sous-refroidi, `None` sinon
|
|
- [ ] `energy_transfers()` retourne `(Power(0), Power(0))`
|
|
- [ ] Node peut être inséré dans la topologie entre deux composants
|
|
- [ ] Node fonctionne sans backend (mesures de base uniquement)
|
|
|
|
---
|
|
|
|
## Tests 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_no_residuals() {
|
|
let node = Node::new("test", inlet, outlet);
|
|
let state = SystemState::default();
|
|
let mut residuals = ResidualVector::new(10);
|
|
|
|
node.compute_residuals(&state, &mut residuals).unwrap();
|
|
|
|
// Aucun résidu modifié
|
|
assert!(residuals.iter().all(|&r| r == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_extract_pressure() {
|
|
let mut node = Node::new("test", inlet, outlet);
|
|
// Configurer port avec P = 300 kPa
|
|
|
|
node.update_measurements(&state).unwrap();
|
|
|
|
assert!((node.pressure() - 300_000.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_superheat_calculation() {
|
|
// Test avec R410A surchauffé
|
|
let backend = CoolPropBackend::new();
|
|
let mut node = Node::new("evap_out", inlet, outlet)
|
|
.with_fluid_backend(Arc::new(backend));
|
|
|
|
// Configurer: P = 10 bar, T = 15°C (surchauffe ~5K)
|
|
node.update_measurements(&state).unwrap();
|
|
|
|
assert!(node.superheat().is_some());
|
|
assert!(node.superheat().unwrap() > 0.0);
|
|
assert!(node.subcooling().is_none());
|
|
assert_eq!(node.measurements().phase, Some(Phase::SuperheatedVapor));
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_subcooling_calculation() {
|
|
// Test avec R410A sous-refroidi
|
|
let backend = CoolPropBackend::new();
|
|
let mut node = Node::new("cond_out", inlet, outlet)
|
|
.with_fluid_backend(Arc::new(backend));
|
|
|
|
// Configurer: P = 25 bar, T = 40°C (sous-refroidissement ~5K)
|
|
node.update_measurements(&state).unwrap();
|
|
|
|
assert!(node.subcooling().is_some());
|
|
assert!(node.subcooling().unwrap() > 0.0);
|
|
assert!(node.superheat().is_none());
|
|
assert_eq!(node.measurements().phase, Some(Phase::SubcooledLiquid));
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_two_phase_quality() {
|
|
// Test avec R410A diphasique
|
|
let backend = CoolPropBackend::new();
|
|
let mut node = Node::new("mid_evap", inlet, outlet)
|
|
.with_fluid_backend(Arc::new(backend));
|
|
|
|
// Configurer: P = 10 bar, x = 0.5
|
|
node.update_measurements(&state).unwrap();
|
|
|
|
assert!(node.quality().is_some());
|
|
assert!((node.quality().unwrap() - 0.5).abs() < 0.1);
|
|
assert!(node.superheat().is_none());
|
|
assert!(node.subcooling().is_none());
|
|
assert_eq!(node.measurements().phase, Some(Phase::TwoPhase));
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_no_backend_graceful() {
|
|
// Test sans backend - mesures de base uniquement
|
|
let mut node = Node::new("test", inlet, outlet);
|
|
// Pas de with_fluid_backend()
|
|
|
|
node.update_measurements(&state).unwrap();
|
|
|
|
// Mesures de base disponibles
|
|
assert!(node.pressure() > 0.0);
|
|
assert!(node.mass_flow() > 0.0);
|
|
|
|
// Mesures avancées non disponibles
|
|
assert!(node.quality().is_none());
|
|
assert!(node.superheat().is_none());
|
|
assert!(node.subcooling().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_node_in_topology() {
|
|
// Test que Node peut être inséré dans la topologie
|
|
let mut system = System::new();
|
|
|
|
let comp = system.add_component(Box::new(Compressor::new(...)));
|
|
let node = system.add_component(Box::new(Node::new("probe", ...)));
|
|
let cond = system.add_component(Box::new(Condenser::new(...)));
|
|
|
|
// Connecter: comp → node → cond
|
|
system.connect(comp_outlet, node_inlet).unwrap();
|
|
system.connect(node_outlet, cond_inlet).unwrap();
|
|
|
|
// Le système doit avoir le même nombre d'équations
|
|
// (Node n'ajoute pas d'équations)
|
|
system.finalize().unwrap();
|
|
|
|
// Solve devrait fonctionner normalement
|
|
let result = system.solve();
|
|
assert!(result.is_ok());
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Example d'Utilisation
|
|
|
|
```rust
|
|
use entropyk_components::Node;
|
|
use entropyk_fluids::CoolPropBackend;
|
|
|
|
// Créer une sonde après l'évaporateur
|
|
let backend = Arc::new(CoolPropBackend::new());
|
|
|
|
let probe = Node::new(
|
|
"evaporator_outlet",
|
|
evaporator.outlet_port(),
|
|
compressor.inlet_port(),
|
|
)
|
|
.with_fluid_backend(backend);
|
|
|
|
// Après convergence
|
|
let t_sh = probe.superheat().expect("Should be superheated");
|
|
println!("Superheat: {:.1} K", t_sh);
|
|
|
|
let p = probe.pressure();
|
|
let t = probe.temperature();
|
|
let m = probe.mass_flow();
|
|
|
|
println!("P = {:.2} bar, T = {:.1}°C, m = {:.3} kg/s",
|
|
p / 1e5, t - 273.15, m);
|
|
```
|
|
|
|
---
|
|
|
|
## Références
|
|
|
|
- [Epic 11 Technical Specifications](../planning-artifacts/epic-11-technical-specifications.md)
|
|
- [Story 7.2 - Energy Balance Validation](./7-2-energy-balance-validation.md)
|
|
- [Component Trait](./1-1-component-trait-definition.md)
|