Some checks failed
CI / check (push) Has been cancelled
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>
1302 lines
44 KiB
Rust
1302 lines
44 KiB
Rust
//! 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
|
||
/// 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<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 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<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,
|
||
_ => {
|
||
// 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<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"));
|
||
}
|
||
}
|