Files
Entropyk/crates/components/src/pipe.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

1302 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Self, ComponentError> {
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, ComponentError> {
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, ComponentError> {
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<State> {
/// Pipe geometry
geometry: PipeGeometry,
/// Inlet port
port_inlet: Port<State>,
/// Outlet port
port_outlet: Port<State>,
/// 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
/// DarcyWeisbach 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<usize>,
/// Inlet edge pressure index in the global state vector (edge-coupled mode).
inlet_p_idx: Option<usize>,
/// Inlet edge enthalpy index in the global state vector (edge-coupled mode).
inlet_h_idx: Option<usize>,
/// Outlet edge pressure index in the global state vector (edge-coupled mode).
outlet_p_idx: Option<usize>,
/// Outlet edge enthalpy index in the global state vector (edge-coupled mode).
outlet_h_idx: Option<usize>,
/// Phantom data for type state
_state: PhantomData<State>,
}
impl Pipe<Disconnected> {
/// 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<Disconnected>,
port_outlet: Port<Disconnected>,
fluid_density: f64,
fluid_viscosity: f64,
) -> Result<Self, ComponentError> {
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<Disconnected>,
port_outlet: Port<Disconnected>,
density: f64,
viscosity: f64,
) -> Result<Self, ComponentError> {
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<Disconnected>,
port_outlet: Port<Disconnected>,
density: f64,
viscosity: f64,
) -> Result<Self, ComponentError> {
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<Disconnected>,
outlet: Port<Disconnected>,
) -> Result<Pipe<Connected>, 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<Connected> {
/// 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 DarcyWeisbach 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<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&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<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
}
impl Component for Pipe<Connected> {
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,
_ => {
// DarcyWeisbach 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<Vec<entropyk_core::MassFlow>, 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<Vec<entropyk_core::Enthalpy>, 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<Connected> {
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<Connected> {
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"));
}
}