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

13 KiB

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

// 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

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

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

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

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

#[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

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