Entropyk/_bmad-output/implementation-artifacts/11-1-node-passive-probe.md

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)