//! Pipe Component Implementation //! //! This module provides a pipe component for fluid transport with //! pressure drop calculation using the Darcy-Weisbach equation. //! //! **Pipe serves for both refrigerant and incompressible fluid circuits** (water, //! seawater, glycol, etc.). Use [`Pipe::for_incompressible`] or [`Pipe::for_refrigerant`] //! with **explicit ρ and μ** obtained from a fluid backend. //! //! ## Fluid Support //! //! - **Refrigerant** (compressible): ρ and μ vary with P,T. Use [`Pipe::for_refrigerant`] //! with design-point values from CoolProp or tabular backend. //! //! - **Incompressible** (water, seawater, glycol): ρ and μ from `IncompressibleBackend` //! (Story 2.7). **Do not hardcode**—obtain properties via fluid backend. //! //! ## Darcy-Weisbach Equation //! //! ```text //! ΔP = f × (L/D) × (ρ × v² / 2) //! ``` //! //! Where: //! - f = Darcy friction factor (dimensionless) //! - L = Pipe length (m) //! - D = Pipe inner diameter (m) //! - ρ = Fluid density (kg/m³) //! - v = Flow velocity (m/s) //! //! ## Haaland Friction Factor //! //! For turbulent flow, the Haaland approximation is used: //! //! ```text //! 1/√f = -1.8 × log10[(ε/D/3.7)^1.11 + 6.9/Re] //! ``` //! //! Where: //! - ε = Pipe roughness (m) //! - Re = Reynolds number = ρ × v × D / μ use crate::port::{Connected, Disconnected, FluidId, Port}; use crate::state_machine::StateManageable; use crate::{ CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector, StateSlice, }; use entropyk_core::{Calib, MassFlow}; use std::marker::PhantomData; /// Common pipe materials and their typical roughness values (in meters). pub mod roughness { /// Smooth drawn tubing (copper, plastic) - 0.0015 mm pub const SMOOTH: f64 = 1.5e-6; /// Commercial steel pipe - 0.045 mm pub const STEEL_COMMERCIAL: f64 = 4.5e-5; /// Galvanized iron - 0.15 mm pub const GALVANIZED_IRON: f64 = 1.5e-4; /// Cast iron - 0.26 mm pub const CAST_IRON: f64 = 2.6e-4; /// Concrete - 1.0 mm pub const CONCRETE: f64 = 1.0e-3; /// PVC/HDPE plastic - 0.0015 mm pub const PLASTIC: f64 = 1.5e-6; } /// Pipe geometry specification. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PipeGeometry { /// Pipe length in meters pub length_m: f64, /// Inner diameter in meters pub diameter_m: f64, /// Pipe roughness in meters pub roughness_m: f64, } impl PipeGeometry { /// Creates a new pipe geometry specification. /// /// # Arguments /// /// * `length_m` - Pipe length in meters /// * `diameter_m` - Inner diameter in meters /// * `roughness_m` - Pipe roughness in meters (use values from `roughness` module) /// /// # Errors /// /// Returns an error if any dimension is non-positive. pub fn new(length_m: f64, diameter_m: f64, roughness_m: f64) -> Result { if length_m <= 0.0 { return Err(ComponentError::InvalidState( "Pipe length must be positive".to_string(), )); } if diameter_m <= 0.0 { return Err(ComponentError::InvalidState( "Pipe diameter must be positive".to_string(), )); } if roughness_m < 0.0 { return Err(ComponentError::InvalidState( "Pipe roughness cannot be negative".to_string(), )); } Ok(Self { length_m, diameter_m, roughness_m, }) } /// Creates a smooth pipe geometry. pub fn smooth(length_m: f64, diameter_m: f64) -> Result { Self::new(length_m, diameter_m, roughness::SMOOTH) } /// Creates a commercial steel pipe geometry. pub fn steel(length_m: f64, diameter_m: f64) -> Result { Self::new(length_m, diameter_m, roughness::STEEL_COMMERCIAL) } /// Returns the cross-sectional area in m². pub fn area(&self) -> f64 { std::f64::consts::PI * self.diameter_m * self.diameter_m / 4.0 } /// Returns the length-to-diameter ratio (L/D). pub fn ld_ratio(&self) -> f64 { self.length_m / self.diameter_m } /// Returns the relative roughness (ε/D). pub fn relative_roughness(&self) -> f64 { self.roughness_m / self.diameter_m } } /// Friction factor calculation methods. pub mod friction_factor { use entropyk_core::smoothing::cubic_blend; /// Reynolds number below which the flow is treated as fully laminar. pub const RE_LAMINAR: f64 = 2300.0; /// Reynolds number above which the flow is treated as fully turbulent. pub const RE_TURBULENT: f64 = 4000.0; /// Pure turbulent Haaland friction factor (no transition handling). /// /// 1/√f = -1.8 × log10[(ε/D / 3.7)^1.11 + 6.9/Re] fn haaland_turbulent(relative_roughness: f64, reynolds: f64) -> f64 { let re_clamped = reynolds.max(1.0); let term1 = (relative_roughness / 3.7).powf(1.11); let term2 = 6.9 / re_clamped; let inv_sqrt_f = -1.8 * (term1 + term2).log10(); 1.0 / (inv_sqrt_f * inv_sqrt_f) } /// Calculates the Darcy friction factor with a C¹ laminar→turbulent blend. /// /// Below [`RE_LAMINAR`] the laminar law `f = 64/Re` is used; above /// [`RE_TURBULENT`] the explicit Haaland turbulent correlation is used. In /// the transition band `[RE_LAMINAR, RE_TURBULENT]` the two are blended with /// a C¹ [`cubic_blend`], so the friction factor — and therefore the analytic /// pressure-drop Jacobian — has **no slope discontinuity** at `Re = 2300`. /// The previous hard `if Re < 2300` switch introduced a kink that made the /// Newton Jacobian jump (the PR#563-style failure mode). /// /// # Arguments /// /// * `relative_roughness` - ε/D (dimensionless) /// * `reynolds` - Reynolds number (dimensionless) /// /// # Returns /// /// Darcy friction factor f pub fn haaland(relative_roughness: f64, reynolds: f64) -> f64 { if reynolds <= 0.0 { return 0.02; // Default for invalid input } // Laminar law. Reynolds is not clamped here so the pressure drop stays // linear near zero flow (Story 3.5 zero-flow regularization). let f_laminar = 64.0 / reynolds; if reynolds <= RE_LAMINAR { return f_laminar; } let f_turbulent = haaland_turbulent(relative_roughness, reynolds); if reynolds >= RE_TURBULENT { return f_turbulent; } // Transition band: C¹ blend. At both edges the blend weight and its // derivative are zero, so the slope matches the pure laminar / turbulent // branches there — the join is C¹. cubic_blend(f_laminar, f_turbulent, reynolds, RE_LAMINAR, RE_TURBULENT) } } /// A pipe component with pressure drop calculation. /// /// Uses the Darcy-Weisbach equation with the Haaland friction factor /// for accurate pressure drop estimation. /// /// **Dual refrigerant/incompressible usage:** Use [`Pipe::for_incompressible`] for water, /// seawater, glycol (ρ, μ from backend); use [`Pipe::for_refrigerant`] for refrigerant /// circuits with design-point ρ and μ from a fluid backend. /// /// # Example /// /// ```ignore /// use entropyk_components::pipe::{Pipe, PipeGeometry, roughness}; /// use entropyk_components::port::{FluidId, Port}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// // Create a 10m long, 50mm diameter steel pipe /// let geometry = PipeGeometry::steel(10.0, 0.05).unwrap(); /// /// let inlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(2.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// let outlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(2.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// /// let pipe = Pipe::new(geometry, inlet, outlet, 1000.0, 0.001).unwrap(); /// ``` #[derive(Debug, Clone)] pub struct Pipe { /// Pipe geometry geometry: PipeGeometry, /// Inlet port port_inlet: Port, /// Outlet port port_outlet: Port, /// Fluid density in kg/m³ fluid_density_kg_per_m3: f64, /// Fluid dynamic viscosity in Pa·s fluid_viscosity_pa_s: f64, /// Calibration: ΔP_eff = f_dp × ΔP_nominal calib: Calib, /// Circuit identifier circuit_id: CircuitId, /// Operational state operational_state: OperationalState, /// Design-point pressure drop (Pa) for the edge-coupled (P,h) solver model. /// When `> 0`, ΔP is imposed as this constant. When `0`, ΔP is computed from /// Darcy–Weisbach using geometry + live mass flow (`inlet_m_idx`). design_dp_pa: f64, /// When true, the component uses the edge-coupled (P,h) solver model /// (`n_equations() == 2`) instead of the legacy mass-flow model. edge_coupled: bool, /// Inlet edge mass-flow index (edge-coupled Darcy mode). inlet_m_idx: Option, /// Inlet edge pressure index in the global state vector (edge-coupled mode). inlet_p_idx: Option, /// Inlet edge enthalpy index in the global state vector (edge-coupled mode). inlet_h_idx: Option, /// Outlet edge pressure index in the global state vector (edge-coupled mode). outlet_p_idx: Option, /// Outlet edge enthalpy index in the global state vector (edge-coupled mode). outlet_h_idx: Option, /// Phantom data for type state _state: PhantomData, } impl Pipe { /// Creates a new disconnected pipe. /// /// # Arguments /// /// * `geometry` - Pipe geometry specification /// * `port_inlet` - Inlet port (disconnected) /// * `port_outlet` - Outlet port (disconnected) /// * `fluid_density` - Fluid density in kg/m³ /// * `fluid_viscosity` - Fluid dynamic viscosity in Pa·s pub fn new( geometry: PipeGeometry, port_inlet: Port, port_outlet: Port, fluid_density: f64, fluid_viscosity: f64, ) -> Result { if port_inlet.fluid_id() != port_outlet.fluid_id() { return Err(ComponentError::InvalidState( "Inlet and outlet ports must have the same fluid type".to_string(), )); } if fluid_density <= 0.0 { return Err(ComponentError::InvalidState( "Fluid density must be positive".to_string(), )); } if fluid_viscosity <= 0.0 { return Err(ComponentError::InvalidState( "Fluid viscosity must be positive".to_string(), )); } Ok(Self { geometry, port_inlet, port_outlet, fluid_density_kg_per_m3: fluid_density, fluid_viscosity_pa_s: fluid_viscosity, calib: Calib::default(), circuit_id: CircuitId::default(), operational_state: OperationalState::default(), design_dp_pa: 0.0, edge_coupled: false, inlet_m_idx: None, inlet_p_idx: None, inlet_h_idx: None, outlet_p_idx: None, outlet_h_idx: None, _state: PhantomData, }) } /// Creates a pipe for incompressible fluid circuits (water, seawater, glycol). /// /// **Obtain ρ and μ from a fluid backend** (e.g. `IncompressibleBackend` from Story 2.7). /// Do not hardcode—water, seawater, and glycol have different properties. /// /// # Arguments /// /// * `geometry` - Pipe geometry specification /// * `port_inlet` - Inlet port (disconnected) /// * `port_outlet` - Outlet port (disconnected) /// * `density` - Fluid density at design point (kg/m³) /// * `viscosity` - Fluid dynamic viscosity at design point (Pa·s) /// /// # Example /// /// ``` /// use entropyk_components::pipe::{Pipe, PipeGeometry}; /// use entropyk_components::port::{FluidId, Port}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// let geometry = PipeGeometry::smooth(5.0, 0.025).unwrap(); /// let inlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(2.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// let outlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(2.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// // Get ρ, μ from IncompressibleBackend.property() at design temperature /// let pipe = Pipe::for_incompressible(geometry, inlet, outlet, 998.0, 0.001).unwrap(); /// assert!((pipe.fluid_density() - 998.0).abs() < 1e-6); /// ``` pub fn for_incompressible( geometry: PipeGeometry, port_inlet: Port, port_outlet: Port, density: f64, viscosity: f64, ) -> Result { Self::new(geometry, port_inlet, port_outlet, density, viscosity) } /// Creates a pipe for refrigerant circuits with explicit design-point properties. /// /// Refrigerant ρ and μ vary with pressure and temperature. These values are /// **design-point typical values** at the operating condition. For accurate /// simulation, obtain ρ and μ from the fluid properties backend (CoolProp, /// tabular interpolation, etc.). /// /// Typical design-point values (liquid phase): R134a 40°C ~1140 kg/m³, ~0.0002 Pa·s; /// R410A 40°C ~1050 kg/m³, ~0.00015 Pa·s. /// /// # Arguments /// /// * `geometry` - Pipe geometry specification /// * `port_inlet` - Inlet port (disconnected) /// * `port_outlet` - Outlet port (disconnected) /// * `density` - Fluid density at design point (kg/m³) /// * `viscosity` - Fluid dynamic viscosity at design point (Pa·s) /// /// # Example /// /// ``` /// use entropyk_components::pipe::{Pipe, PipeGeometry}; /// use entropyk_components::port::{FluidId, Port}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// let geometry = PipeGeometry::smooth(3.0, 0.012).unwrap(); /// let inlet = Port::new( /// FluidId::new("R134a"), /// Pressure::from_bar(10.0), /// Enthalpy::from_joules_per_kg(250000.0), /// ); /// let outlet = Port::new( /// FluidId::new("R134a"), /// Pressure::from_bar(10.0), /// Enthalpy::from_joules_per_kg(250000.0), /// ); /// // R134a liquid at ~40°C: ρ ≈ 1140, μ ≈ 0.0002 /// let pipe = Pipe::for_refrigerant(geometry, inlet, outlet, 1140.0, 0.0002).unwrap(); /// assert_eq!(pipe.fluid_id().as_str(), "R134a"); /// ``` pub fn for_refrigerant( geometry: PipeGeometry, port_inlet: Port, port_outlet: Port, density: f64, viscosity: f64, ) -> Result { Self::new(geometry, port_inlet, port_outlet, density, viscosity) } /// Returns the fluid identifier. pub fn fluid_id(&self) -> &FluidId { self.port_inlet.fluid_id() } /// Returns the pipe geometry. pub fn geometry(&self) -> &PipeGeometry { &self.geometry } /// Returns the fluid density. pub fn fluid_density(&self) -> f64 { self.fluid_density_kg_per_m3 } /// Returns the fluid viscosity. pub fn fluid_viscosity(&self) -> f64 { self.fluid_viscosity_pa_s } /// Returns calibration factors (f_dp for pressure drop scaling). pub fn calib(&self) -> &Calib { &self.calib } /// Sets calibration factors. pub fn set_calib(&mut self, calib: Calib) { self.calib = calib; } /// Connects the pipe to inlet and outlet ports. /// /// This consumes the disconnected pipe and returns a connected one, /// transitioning the state at compile time. pub fn connect( self, inlet: Port, outlet: Port, ) -> Result, ComponentError> { let (p_in, _) = self .port_inlet .connect(inlet) .map_err(|e| ComponentError::InvalidState(e.to_string()))?; let (p_out, _) = self .port_outlet .connect(outlet) .map_err(|e| ComponentError::InvalidState(e.to_string()))?; Ok(Pipe { geometry: self.geometry, port_inlet: p_in, port_outlet: p_out, fluid_density_kg_per_m3: self.fluid_density_kg_per_m3, fluid_viscosity_pa_s: self.fluid_viscosity_pa_s, calib: self.calib, circuit_id: self.circuit_id, operational_state: self.operational_state, design_dp_pa: self.design_dp_pa, edge_coupled: self.edge_coupled, inlet_m_idx: self.inlet_m_idx, inlet_p_idx: self.inlet_p_idx, inlet_h_idx: self.inlet_h_idx, outlet_p_idx: self.outlet_p_idx, outlet_h_idx: self.outlet_h_idx, _state: PhantomData, }) } } impl Pipe { /// Enables the edge-coupled (P,h) solver model so the pipe participates in /// the refrigeration/hydronic graph solver. /// /// In this mode the pipe owns 2 equations on its outlet edge: /// - `r0 = P_out - (P_in - ΔP)` — if `design_dp_pa > 0`, ΔP is that constant; /// if `design_dp_pa == 0`, ΔP is Darcy–Weisbach from geometry + live ṁ /// - `r1 = h_out - h_in` — adiabatic pass-through pub fn with_design_pressure_drop_pa(mut self, design_dp_pa: f64) -> Self { self.design_dp_pa = design_dp_pa.max(0.0); self.edge_coupled = true; self } /// Returns the inlet port. pub fn port_inlet(&self) -> &Port { &self.port_inlet } /// Returns the outlet port. pub fn port_outlet(&self) -> &Port { &self.port_outlet } /// Calculates the flow velocity. /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Velocity in m/s pub fn velocity(&self, flow_m3_per_s: f64) -> f64 { let area = self.geometry.area(); if area > 0.0 { flow_m3_per_s / area } else { 0.0 } } /// Calculates the Reynolds number. /// /// Re = ρ × v × D / μ pub fn reynolds_number(&self, flow_m3_per_s: f64) -> f64 { let velocity = self.velocity(flow_m3_per_s); velocity * self.geometry.diameter_m * self.fluid_density_kg_per_m3 / self.fluid_viscosity_pa_s } /// Calculates the Darcy friction factor using Haaland equation. pub fn friction_factor(&self, flow_m3_per_s: f64) -> f64 { let rel_roughness = self.geometry.relative_roughness(); let re = self.reynolds_number(flow_m3_per_s); friction_factor::haaland(rel_roughness, re) } /// Calculates the pressure drop using Darcy-Weisbach equation. /// /// ΔP = f × (L/D) × (ρ × v² / 2) /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Pressure drop in Pascals (positive value) pub fn pressure_drop(&self, flow_m3_per_s: f64) -> f64 { let abs_flow = flow_m3_per_s.abs(); if abs_flow <= std::f64::EPSILON { return 0.0; } let velocity = self.velocity(abs_flow); let f = self.friction_factor(abs_flow); let ld = self.geometry.ld_ratio(); // Darcy-Weisbach nominal: ΔP_nominal = f × (L/D) × (ρ × v² / 2); ΔP_eff = f_dp × ΔP_nominal let dp_nominal = f * ld * self.fluid_density_kg_per_m3 * velocity * velocity / 2.0; let dp = dp_nominal * self.calib.z_dp; if flow_m3_per_s < 0.0 { -dp } else { dp } } /// Calculates mass flow from volumetric flow. pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow { MassFlow::from_kg_per_s(flow_m3_per_s * self.fluid_density_kg_per_m3) } /// Calculates volumetric flow from mass flow. pub fn volumetric_from_mass_flow(&self, mass_flow: MassFlow) -> f64 { mass_flow.to_kg_per_s() / self.fluid_density_kg_per_m3 } /// Returns the pipe geometry. pub fn geometry(&self) -> &PipeGeometry { &self.geometry } /// Returns the fluid density. pub fn fluid_density(&self) -> f64 { self.fluid_density_kg_per_m3 } /// Returns the fluid viscosity. pub fn fluid_viscosity(&self) -> f64 { self.fluid_viscosity_pa_s } /// Returns calibration factors (f_dp for pressure drop scaling). pub fn calib(&self) -> &Calib { &self.calib } /// Sets calibration factors. pub fn set_calib(&mut self, calib: Calib) { self.calib = calib; } /// Returns both ports as a slice. pub fn get_ports_slice(&self) -> [&Port; 2] { [&self.port_inlet, &self.port_outlet] } } impl Component for Pipe { fn set_system_context( &mut self, _state_offset: usize, external_edge_state_indices: &[(usize, usize, usize)], ) { // Layout: [0] = incoming edge (upstream→pipe), [1] = outgoing edge (pipe→downstream) // Triple: (m_idx, p_idx, h_idx) if !external_edge_state_indices.is_empty() { self.inlet_m_idx = Some(external_edge_state_indices[0].0); self.inlet_p_idx = Some(external_edge_state_indices[0].1); self.inlet_h_idx = Some(external_edge_state_indices[0].2); } if external_edge_state_indices.len() >= 2 { self.outlet_p_idx = Some(external_edge_state_indices[1].1); self.outlet_h_idx = Some(external_edge_state_indices[1].2); } } fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // Edge-coupled (P,h) model: constrain the outlet edge. if self.edge_coupled { if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = ( self.inlet_p_idx, self.inlet_h_idx, self.outlet_p_idx, self.outlet_h_idx, ) { if residuals.len() < 2 { return Err(ComponentError::InvalidResidualDimensions { expected: 2, actual: residuals.len(), }); } let dp = match self.operational_state { OperationalState::Bypass => 0.0, _ if self.design_dp_pa > 0.0 => self.calib.z_dp * self.design_dp_pa, _ => { // Darcy–Weisbach from geometry + live ṁ (design_dp unset/0). let m = self.inlet_m_idx.map(|i| state[i]).unwrap_or(0.0); let flow_m3 = m / self.fluid_density_kg_per_m3; self.pressure_drop(flow_m3).abs() } }; // r0: pressure drop across the pipe residuals[0] = state[out_p] - (state[in_p] - dp); // r1: adiabatic pass-through (enthalpy conserved) residuals[1] = state[out_h] - state[in_h]; return Ok(()); } return Err(ComponentError::InvalidState( "Pipe edge-coupled model requires live inlet/outlet edge state indices".to_string(), )); } if residuals.len() != self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } match self.operational_state { OperationalState::Off => { // Blocked pipe: no flow if state.is_empty() { return Err(ComponentError::InvalidStateDimensions { expected: 1, actual: 0, }); } residuals[0] = state[0]; return Ok(()); } OperationalState::Bypass => { // No pressure drop (perfect pipe) let p_in = self.port_inlet.pressure().to_pascals(); let p_out = self.port_outlet.pressure().to_pascals(); residuals[0] = p_in - p_out; return Ok(()); } OperationalState::On => {} } if state.is_empty() { return Err(ComponentError::InvalidStateDimensions { expected: 1, actual: 0, }); } let mass_flow_kg_s = state[0]; let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3; // Calculate pressure drop let dp_calc = self.pressure_drop(flow_m3_s); // Get actual pressure difference let p_in = self.port_inlet.pressure().to_pascals(); let p_out = self.port_outlet.pressure().to_pascals(); let dp_actual = p_in - p_out; // Residual: calculated drop - actual drop = 0 residuals[0] = dp_calc - dp_actual; Ok(()) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Edge-coupled (P,h) model. if self.edge_coupled { if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = ( self.inlet_p_idx, self.inlet_h_idx, self.outlet_p_idx, self.outlet_h_idx, ) { // r0 = P_out - (P_in - dp) → ∂r0/∂P_out = 1, ∂r0/∂P_in = -1 jacobian.add_entry(0, out_p, 1.0); jacobian.add_entry(0, in_p, -1.0); // Darcy: dp depends on ṁ → ∂r0/∂ṁ = +∂(dp)/∂ṁ if self.design_dp_pa <= 0.0 { if let Some(m_idx) = self.inlet_m_idx { let m = state[m_idx]; let h = 1e-6_f64.max(m.abs() * 1e-5); let rho = self.fluid_density_kg_per_m3; let dp_plus = self.pressure_drop((m + h) / rho).abs(); let dp_minus = self.pressure_drop((m - h) / rho).abs(); let ddp_dm = (dp_plus - dp_minus) / (2.0 * h); // r0 = P_out - P_in + dp(m) → ∂r0/∂m = ∂dp/∂m jacobian.add_entry(0, m_idx, ddp_dm); } } // r1 = h_out - h_in → ∂r1/∂h_out = 1, ∂r1/∂h_in = -1 jacobian.add_entry(1, out_h, 1.0); jacobian.add_entry(1, in_h, -1.0); } return Ok(()); } match self.operational_state { OperationalState::Off => { jacobian.add_entry(0, 0, 1.0); return Ok(()); } OperationalState::Bypass => { jacobian.add_entry(0, 0, 0.0); return Ok(()); } OperationalState::On => {} } if state.is_empty() { return Err(ComponentError::InvalidStateDimensions { expected: 1, actual: 0, }); } let mass_flow_kg_s = state[0]; let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3; // Numerical derivative of pressure drop with respect to mass flow let h = 1e-6_f64.max(mass_flow_kg_s.abs() * 1e-5); let dp_plus = self.pressure_drop(flow_m3_s + h / self.fluid_density_kg_per_m3); let dp_minus = self.pressure_drop(flow_m3_s - h / self.fluid_density_kg_per_m3); let dp_dm = (dp_plus - dp_minus) / (2.0 * h); jacobian.add_entry(0, 0, dp_dm); Ok(()) } fn n_equations(&self) -> usize { if self.edge_coupled { 2 } else { 1 } } fn port_mass_flows( &self, state: &StateSlice, ) -> Result, ComponentError> { if state.is_empty() { return Err(ComponentError::InvalidStateDimensions { expected: 1, actual: 0, }); } let m = entropyk_core::MassFlow::from_kg_per_s(state[0]); Ok(vec![ m, entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()), ]) } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_enthalpies( &self, _state: &StateSlice, ) -> Result, ComponentError> { Ok(vec![ self.port_inlet.enthalpy(), self.port_outlet.enthalpy(), ]) } fn energy_transfers( &self, _state: &StateSlice, ) -> Option<(entropyk_core::Power, entropyk_core::Power)> { match self.operational_state { OperationalState::Off | OperationalState::Bypass | OperationalState::On => { // Pipes are adiabatic Some(( entropyk_core::Power::from_watts(0.0), entropyk_core::Power::from_watts(0.0), )) } } } fn signature(&self) -> String { format!("Pipe(circuit={})", self.circuit_id.0) } fn to_params(&self) -> crate::ComponentParams { crate::ComponentParams::new("Pipe") .with_param("circuitId", self.circuit_id.0) .with_param("lengthM", self.geometry.length_m) .with_param("diameterM", self.geometry.diameter_m) .with_param("roughnessM", self.geometry.roughness_m) .with_param("fluidDensityKgPerM3", self.fluid_density_kg_per_m3) .with_param("fluidViscosityPaS", self.fluid_viscosity_pa_s) .with_param( "calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null), ) } fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool { let mut c = self.calib().clone(); if c.set_factor(factor, value) { self.set_calib(c); true } else { false } } } impl StateManageable for Pipe { fn state(&self) -> OperationalState { self.operational_state } fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { if self.operational_state.can_transition_to(state) { let from = self.operational_state; self.operational_state = state; self.on_state_change(from, state); Ok(()) } else { Err(ComponentError::InvalidStateTransition { from: self.operational_state, to: state, reason: "Transition not allowed".to_string(), }) } } fn can_transition_to(&self, target: OperationalState) -> bool { self.operational_state.can_transition_to(target) } fn circuit_id(&self) -> &CircuitId { &self.circuit_id } fn set_circuit_id(&mut self, circuit_id: CircuitId) { self.circuit_id = circuit_id; } } #[cfg(test)] mod tests { use super::*; use crate::port::FluidId; use approx::assert_relative_eq; use entropyk_core::{Enthalpy, Pressure}; fn create_test_geometry() -> PipeGeometry { PipeGeometry::steel(10.0, 0.05).unwrap() } fn create_test_pipe_connected() -> Pipe { let geometry = create_test_geometry(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Water"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap(); // Water at 20°C: ρ ≈ 998 kg/m³, μ ≈ 0.001 Pa·s Pipe { geometry, port_inlet: inlet_conn, port_outlet: outlet_conn, fluid_density_kg_per_m3: 998.0, fluid_viscosity_pa_s: 0.001, calib: Calib::default(), circuit_id: CircuitId::default(), operational_state: OperationalState::default(), design_dp_pa: 0.0, edge_coupled: false, inlet_m_idx: None, inlet_p_idx: None, inlet_h_idx: None, outlet_p_idx: None, outlet_h_idx: None, _state: PhantomData, } } #[test] fn test_pipe_geometry_creation() { let geo = create_test_geometry(); assert_eq!(geo.length_m, 10.0); assert_eq!(geo.diameter_m, 0.05); assert_eq!(geo.ld_ratio(), 200.0); } #[test] fn test_pipe_geometry_area() { let geo = create_test_geometry(); let area = geo.area(); let expected = std::f64::consts::PI * 0.05 * 0.05 / 4.0; assert_relative_eq!(area, expected, epsilon = 1e-10); } #[test] fn test_pipe_geometry_invalid() { assert!(PipeGeometry::new(-1.0, 0.05, 0.001).is_err()); assert!(PipeGeometry::new(10.0, -0.05, 0.001).is_err()); assert!(PipeGeometry::new(10.0, 0.05, -0.001).is_err()); } #[test] fn test_friction_factor_laminar() { // For Re = 1000 (laminar), f = 64/1000 = 0.064 let f = friction_factor::haaland(0.001, 1000.0); assert_relative_eq!(f, 0.064, epsilon = 1e-10); } #[test] fn test_friction_factor_turbulent() { // For smooth pipe at Re = 100000 let f = friction_factor::haaland(0.0, 100000.0); // Should be around 0.018 for this Reynolds number assert!(f > 0.01); assert!(f < 0.03); } #[test] fn test_friction_factor_transition() { // Near transition region (Re ≈ 2300) let f_lam = friction_factor::haaland(0.001, 2000.0); let f_turb = friction_factor::haaland(0.001, 3000.0); // Both should be positive and reasonable assert!(f_lam > 0.0); assert!(f_turb > 0.0); } #[test] fn test_friction_factor_transition_is_c1() { // The laminar→turbulent blend must be continuous in value AND slope // across the whole transition band [2300, 4000] — no PR#563-style kink. let rel = 0.001; let f = |re: f64| friction_factor::haaland(rel, re); let dfd = |re: f64| (f(re + 0.5) - f(re - 0.5)) / 1.0; // Value continuity across the band, including the former jump at 2300. let mut prev_v = f(2299.0); let mut prev_s = dfd(2299.0); for re_i in (2300..=4000).step_by(20) { let re = re_i as f64; let v = f(re); let s = dfd(re); assert!(v > 0.0 && v.is_finite()); assert!( (v - prev_v).abs() < 1e-3, "friction value jump near Re={}: {} vs {}", re, v, prev_v ); assert!( (s - prev_s).abs() < 1e-4, "friction slope jump near Re={}: {} vs {}", re, s, prev_s ); prev_v = v; prev_s = s; } // Endpoints match the pure branches. assert_relative_eq!(f(2300.0), 64.0 / 2300.0, epsilon = 1e-9); } #[test] fn test_friction_factor_zero_flow_regularization() { // Re = 0 or very small must not cause division by zero (Story 3.5) let f0_haaland = friction_factor::haaland(0.001, 0.0); assert!(f0_haaland.is_finite()); assert_relative_eq!(f0_haaland, 0.02, epsilon = 1e-10); let f_small_haaland = friction_factor::haaland(0.001, 0.5); assert!(f_small_haaland.is_finite()); } #[test] fn test_pipe_pressure_drop_zero_and_small_flow() { let pipe = create_test_pipe_connected(); let dp_zero = pipe.pressure_drop(0.0); assert_eq!(dp_zero, 0.0); let dp_tiny = pipe.pressure_drop(1e-15); assert!(dp_tiny.is_finite()); assert!(dp_tiny >= 0.0); } #[test] fn test_pipe_small_nonzero_flow_continuity() { use entropyk_core::MIN_MASS_FLOW_REGULARIZATION_KG_S; let pipe = create_test_pipe_connected(); let dp_zero = pipe.pressure_drop(0.0); let dp_epsilon = pipe.pressure_drop(MIN_MASS_FLOW_REGULARIZATION_KG_S / pipe.fluid_density()); let dp_normal = pipe.pressure_drop(0.01); assert!(dp_zero.is_finite()); assert!(dp_epsilon.is_finite()); assert!(dp_normal.is_finite()); assert!( dp_epsilon < dp_normal, "tiny flow should have smaller dp than normal" ); assert!( dp_epsilon >= 0.0, "dp should be non-negative at epsilon flow" ); } #[test] fn test_pipe_velocity() { let pipe = create_test_pipe_connected(); let flow = 0.01; // 10 L/s let velocity = pipe.velocity(flow); // v = Q / A = 0.01 / (π × 0.05² / 4) ≈ 5.09 m/s let expected = 0.01 / pipe.geometry().area(); assert_relative_eq!(velocity, expected, epsilon = 0.01); } #[test] fn test_pipe_reynolds() { let pipe = create_test_pipe_connected(); let flow = 0.01; let re = pipe.reynolds_number(flow); // Re = ρ × v × D / μ // With water at typical flow, should be turbulent (> 4000) assert!(re > 4000.0); assert!(re < 1_000_000.0); } #[test] fn test_pipe_pressure_drop() { let pipe = create_test_pipe_connected(); let flow = 0.005; // 5 L/s let dp = pipe.pressure_drop(flow); // Should be positive and reasonable (typically < 100 kPa for this pipe) assert!(dp > 0.0); assert!(dp < 200_000.0); } #[test] fn test_pipe_pressure_drop_scales() { let pipe = create_test_pipe_connected(); let dp1 = pipe.pressure_drop(0.005); let dp2 = pipe.pressure_drop(0.010); // Double the flow // Pressure drop should increase (roughly quadruple for turbulent) assert!(dp2 > dp1); } #[test] fn test_edge_coupled_zero_design_dp_uses_darcy() { use crate::Component; let mut pipe = create_test_pipe_connected().with_design_pressure_drop_pa(0.0); // state layout: m, P_in, h_in, m_out, P_out, h_out pipe.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]); let m = 5.0_f64; // kg/s let flow_m3 = m / pipe.fluid_density(); let dp_expected = pipe.pressure_drop(flow_m3).abs(); assert!( dp_expected > 100.0, "test pipe should have meaningful Darcy ΔP" ); let p_in = 300_000.0; let h = 100_000.0; let state = vec![m, p_in, h, m, p_in - dp_expected, h]; let mut residuals = vec![0.0; 2]; pipe.compute_residuals(&state, &mut residuals).unwrap(); assert_relative_eq!(residuals[0], 0.0, epsilon = 1e-6); assert_relative_eq!(residuals[1], 0.0, epsilon = 1e-12); // Wrong P_out → residual equals ΔP error let state_bad = vec![m, p_in, h, m, p_in, h]; // isobaric — should fail pipe.compute_residuals(&state_bad, &mut residuals).unwrap(); assert_relative_eq!(residuals[0], dp_expected, epsilon = 1.0); } #[test] fn test_f_dp_scales_pressure_drop() { let mut pipe = create_test_pipe_connected(); let flow = 0.005; let dp_default = pipe.pressure_drop(flow); pipe.set_calib(Calib { z_dp: 1.1, ..Calib::default() }); let dp_calib = pipe.pressure_drop(flow); assert_relative_eq!(dp_calib / dp_default, 1.1, epsilon = 1e-10); } #[test] fn test_pipe_component_n_equations() { let pipe = create_test_pipe_connected(); assert_eq!(pipe.n_equations(), 1); } #[test] fn test_pipe_component_compute_residuals() { let pipe = create_test_pipe_connected(); let state = vec![5.0]; // 5 kg/s let mut residuals = vec![0.0; 1]; let result = pipe.compute_residuals(&state, &mut residuals); assert!(result.is_ok()); } #[test] fn test_pipe_state_manageable() { let pipe = create_test_pipe_connected(); assert_eq!(pipe.state(), OperationalState::On); assert!(pipe.can_transition_to(OperationalState::Off)); assert!(pipe.can_transition_to(OperationalState::Bypass)); } #[test] fn test_roughness_constants() { assert!(roughness::SMOOTH < roughness::STEEL_COMMERCIAL); assert!(roughness::STEEL_COMMERCIAL < roughness::CAST_IRON); assert!(roughness::PLASTIC < roughness::CONCRETE); } // Removed swamee_jain test as function was removed #[test] fn test_pipe_for_incompressible_creation() { let geometry = PipeGeometry::smooth(5.0, 0.025).unwrap(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Water"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); // ρ, μ from IncompressibleBackend at design point (e.g. water 20°C) let pipe = Pipe::for_incompressible(geometry, inlet, outlet, 998.0, 0.001).unwrap(); assert_relative_eq!(pipe.fluid_density(), 998.0, epsilon = 1e-6); assert_relative_eq!(pipe.fluid_viscosity(), 0.001, epsilon = 1e-9); assert_eq!(pipe.fluid_id().as_str(), "Water"); } #[test] fn test_pipe_for_incompressible_glycol() { // Glycol has different ρ, μ than water - user provides from backend let geometry = PipeGeometry::smooth(5.0, 0.025).unwrap(); let inlet = Port::new( FluidId::new("EthyleneGlycol30"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("EthyleneGlycol30"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let pipe = Pipe::for_incompressible(geometry, inlet, outlet, 1055.0, 0.0022).unwrap(); assert_relative_eq!(pipe.fluid_density(), 1055.0, epsilon = 1e-6); assert_eq!(pipe.fluid_id().as_str(), "EthyleneGlycol30"); } #[test] fn test_pipe_for_refrigerant_creation() { let geometry = PipeGeometry::smooth(3.0, 0.012).unwrap(); let inlet = Port::new( FluidId::new("R134a"), Pressure::from_bar(10.0), Enthalpy::from_joules_per_kg(250000.0), ); let outlet = Port::new( FluidId::new("R134a"), Pressure::from_bar(10.0), Enthalpy::from_joules_per_kg(250000.0), ); let pipe = Pipe::for_refrigerant(geometry, inlet, outlet, 1140.0, 0.0002).unwrap(); assert_eq!(pipe.fluid_id().as_str(), "R134a"); assert_relative_eq!(pipe.fluid_density(), 1140.0, epsilon = 1e-6); assert_relative_eq!(pipe.fluid_viscosity(), 0.0002, epsilon = 1e-9); } #[test] fn test_pipe_inlet_outlet_same_fluid() { // Pipe::new and helpers require inlet/outlet same FluidId let geometry = PipeGeometry::smooth(5.0, 0.025).unwrap(); let water_inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let r134a_outlet = Port::new( FluidId::new("R134a"), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(100000.0), ); let result = Pipe::for_incompressible(geometry, water_inlet, r134a_outlet, 998.0, 0.001); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("same fluid")); } }