# 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>, measurements: NodeMeasurements, } #[derive(Debug, Clone, Default)] pub struct NodeMeasurements { pub pressure: f64, pub temperature: f64, pub enthalpy: f64, pub entropy: Option, pub quality: Option, pub superheat: Option, pub subcooling: Option, pub mass_flow: f64, pub saturation_temp: Option, pub phase: Option, } #[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, 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) -> 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 { self.measurements.quality } pub fn superheat(&self) -> Option { self.measurements.superheat } pub fn subcooling(&self) -> Option { 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)