Files
Entropyk/crates/components/src/flow_junction.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

1161 lines
42 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.
//! Flow Junction Components — Splitter & Merger
//!
//! This module provides `FlowSplitter` (1 inlet → N outlets) and `FlowMerger`
//! (N inlets → 1 outlet) for both incompressible (water, glycol, brine) and
//! compressible (refrigerant) fluid systems.
//!
//! ## Design Philosophy (à la Modelica)
//!
//! In Modelica, flow junctions apply conservation laws directly on connector
//! variables (pressure, enthalpy, mass flow). We follow the same approach:
//! constraints are algebraic equations on the state vector entries `[P, h]`
//! for each edge in the parent `System`.
//!
//! ## FlowSplitter — 1 inlet → N outlets
//!
//! Equations (2N 1 total):
//! ```text
//! Mass balance : ṁ_in = ṁ_out_1 + ... + ṁ_out_N [1 eq]
//! Isobaric : P_out_k = P_in for k = 1..N-1 [N-1 eqs]
//! Isenthalpic : h_out_k = h_in for k = 1..N-1 [N-1 eqs]
//! ```
//!
//! The N-th outlet pressure and enthalpy equality are implied by the above.
//!
//! ## FlowMerger — N inlets → 1 outlet
//!
//! Equations (N + 1 total):
//! ```text
//! Mass balance : ṁ_out = Σ ṁ_in_k [1 eq]
//! Mixing enthalpy : h_out·ṁ_out = Σ h_in_k·ṁ_in_k [1 eq]
//! Pressure equalisation : P_in_k = P_in_1 for k = 2..N [N-1 eqs]
//! ```
//!
//! ## Incompressible vs Compressible
//!
//! The physics are **identical** — the distinction is purely in construction-time
//! validation (which fluid types are accepted). Use:
//! - [`FlowSplitter::incompressible`] / [`FlowMerger::incompressible`] for water,
//! glycol, brine, seawater circuits.
//! - [`FlowSplitter::compressible`] / [`FlowMerger::compressible`] for refrigerant
//! compressible circuits.
//!
//! ## State vector layout
//!
//! The solver assigns two state variables per edge: `(P_idx, h_idx)`.
//! Splitter/Merger receive the global state slice and use the **inlet/outlet
//! edge state indices** stored in their port list to resolve pressure and
//! specific enthalpy values.
//!
//! ## Example
//!
//! ```no_run
//! use entropyk_components::flow_junction::{FlowSplitter, FlowMerger};
//! use entropyk_components::port::{FluidId, Port};
//! use entropyk_core::{Pressure, Enthalpy};
//!
//! let make_port = |p: f64, h: f64| {
//! let a = Port::new(FluidId::new("Water"), Pressure::from_pascals(p),
//! Enthalpy::from_joules_per_kg(h));
//! let b = Port::new(FluidId::new("Water"), Pressure::from_pascals(p),
//! Enthalpy::from_joules_per_kg(h));
//! let (ca, _cb) = a.connect(b).unwrap();
//! ca
//! };
//!
//! let splitter = FlowSplitter::incompressible(
//! "Water",
//! make_port(3.0e5, 2.0e5), // inlet
//! vec![
//! make_port(3.0e5, 2.0e5), // branch A
//! make_port(3.0e5, 2.0e5), // branch B
//! ],
//! ).unwrap();
//! ```
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
// ─────────────────────────────────────────────────────────────────────────────
// FluidKind — tag distinguishing the two regimes
// ─────────────────────────────────────────────────────────────────────────────
/// Whether this junction handles compressible or incompressible fluid.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FluidKind {
/// Water, glycol, brine, seawater — density ≈ const.
Incompressible,
/// Refrigerant, CO₂, steam — density varies with P and T.
Compressible,
}
/// A set of known incompressible fluid identifiers (case-insensitive prefix match).
///
/// Recognises the fluid names used by CoolProp's incompressible backend, including:
/// - Plain names: `Water`, `Glycol`, `Brine`, `MEG`, `PEG`
/// - CoolProp mixture prefix: `INCOMP::*`
/// - Systematic glycol names: `EthyleneGlycol`, `PropyleneGlycol`
pub(crate) fn is_incompressible(fluid: &str) -> bool {
let f = fluid.to_lowercase();
f.starts_with("water")
|| f.starts_with("glycol")
|| f.starts_with("brine")
|| f.starts_with("seawater")
|| f.starts_with("ethyleneglycol")
|| f.starts_with("propyleneglycol")
|| f.starts_with("incompressible")
|| f.starts_with("meg")
|| f.starts_with("peg")
|| f.starts_with("incomp::")
}
// ─────────────────────────────────────────────────────────────────────────────
// FlowSplitter — 1 inlet → N outlets
// ─────────────────────────────────────────────────────────────────────────────
/// A flow splitter that divides one inlet stream into N outlet branches.
///
/// # Equations (2N 1)
///
/// | Equation | Residual |
/// |----------|---------|
/// | Mass balance | `ṁ_in Σṁ_out = 0` |
/// | Isobaric (k=1..N-1) | `P_out_k P_in = 0` |
/// | Isenthalpic (k=1..N-1) | `h_out_k h_in = 0` |
///
/// ## Note on mass flow
///
/// The solver represents mass flow **implicitly** through pressure and enthalpy
/// on each edge. For the splitter's mass balance residual, we use the
/// simplified form that all outlet enthalpies equal the inlet enthalpy
/// (isenthalpic split). The mass balance is therefore:
///
/// `r_mass = (P_in P_out_N) + (h_in h_out_N)` as a consistency check.
///
/// See module docs for details.
#[derive(Debug, Clone)]
pub struct FlowSplitter {
/// Fluid kind (compressible / incompressible).
kind: FluidKind,
/// Fluid identifier (e.g. "Water", "R410A").
fluid_id: String,
/// Inlet port (the single source).
inlet: ConnectedPort,
/// Outlet ports (N branches).
outlets: Vec<ConnectedPort>,
/// Captured (P,h) global state indices of the inlet edge (incoming).
inlet_idx: Option<(usize, usize)>,
/// Captured (P,h) global state indices of the outlet edges (outgoing).
outlet_idx: Vec<(usize, usize)>,
}
impl FlowSplitter {
// ── Constructors ──────────────────────────────────────────────────────────
/// Creates an **incompressible** splitter (water, glycol, brine…).
///
/// # Errors
///
/// Returns an error if:
/// - `outlets` is empty
/// - The fluid is known to be compressible
/// - Fluids mismatch between inlet and outlets
pub fn incompressible(
fluid: impl Into<String>,
inlet: ConnectedPort,
outlets: Vec<ConnectedPort>,
) -> Result<Self, ComponentError> {
let fluid = fluid.into();
if !is_incompressible(&fluid) {
return Err(ComponentError::InvalidState(format!(
"FlowSplitter::incompressible: '{}' does not appear to be an incompressible fluid. \
Use FlowSplitter::compressible for refrigerants.",
fluid
)));
}
Self::new_inner(FluidKind::Incompressible, fluid, inlet, outlets)
}
/// Creates a **compressible** splitter (R410A, CO₂, steam…).
///
/// # Errors
///
/// Returns an error if `outlets` is empty.
pub fn compressible(
fluid: impl Into<String>,
inlet: ConnectedPort,
outlets: Vec<ConnectedPort>,
) -> Result<Self, ComponentError> {
let fluid = fluid.into();
Self::new_inner(FluidKind::Compressible, fluid, inlet, outlets)
}
fn new_inner(
kind: FluidKind,
fluid: String,
inlet: ConnectedPort,
outlets: Vec<ConnectedPort>,
) -> Result<Self, ComponentError> {
if outlets.is_empty() {
return Err(ComponentError::InvalidState(
"FlowSplitter requires at least one outlet".into(),
));
}
if outlets.len() == 1 {
return Err(ComponentError::InvalidState(
"FlowSplitter with 1 outlet is just a pipe — use a Pipe component instead".into(),
));
}
Ok(Self {
kind,
fluid_id: fluid,
inlet,
outlets,
inlet_idx: None,
outlet_idx: Vec::new(),
})
}
// ── Accessors ─────────────────────────────────────────────────────────────
/// Number of outlet branches.
pub fn n_outlets(&self) -> usize {
self.outlets.len()
}
/// Fluid kind.
pub fn fluid_kind(&self) -> FluidKind {
self.kind
}
/// Fluid identifier.
pub fn fluid_id(&self) -> &str {
&self.fluid_id
}
/// Reference to the inlet port.
pub fn inlet(&self) -> &ConnectedPort {
&self.inlet
}
/// Reference to the outlet ports.
pub fn outlets(&self) -> &[ConnectedPort] {
&self.outlets
}
}
impl Component for FlowSplitter {
/// `2N` equations — the splitter owns its `N` outgoing edges, contributing
/// 2 equations (pressure + enthalpy) per branch:
/// - `P_out_k P_in = 0` for k = 0..N
/// - `h_out_k h_in = 0` for k = 0..N
fn n_equations(&self) -> usize {
2 * self.outlets.len()
}
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// Layout: [0] = incoming inlet edge, [1..] = outgoing outlet edges.
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
if external_edge_state_indices.is_empty() {
return;
}
self.inlet_idx = Some((
external_edge_state_indices[0].1,
external_edge_state_indices[0].2,
));
self.outlet_idx = external_edge_state_indices[1..]
.iter()
.map(|&(_, p, h)| (p, h))
.collect();
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n_eqs = self.n_equations();
if residuals.len() < n_eqs {
return Err(ComponentError::InvalidResidualDimensions {
expected: n_eqs,
actual: residuals.len(),
});
}
let (in_p, in_h) = match self.inlet_idx {
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowSplitter requires one live inlet edge and all live outlet edges"
.to_string(),
));
}
};
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
residuals[2 * k] = state[out_p] - state[in_p];
residuals[2 * k + 1] = state[out_h] - state[in_h];
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let (in_p, in_h) = match self.inlet_idx {
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowSplitter Jacobian requires one live inlet edge and all live outlet edges"
.to_string(),
));
}
};
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
// r[2k] = P_out_k - P_in
jacobian.add_entry(2 * k, out_p, 1.0);
jacobian.add_entry(2 * k, in_p, -1.0);
// r[2k+1] = h_out_k - h_in
jacobian.add_entry(2 * k + 1, out_h, 1.0);
jacobian.add_entry(2 * k + 1, in_h, -1.0);
}
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
// Return all ports so System can discover edges.
// Inlet first, then outlets.
// Note: dynamic allocation here is acceptable (called rarely during setup).
// We return an empty slice since get_ports() is for external port discovery;
// the actual solver coupling is via the System graph edges.
&[]
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
// FlowSplitter: 1 inlet → N outlets
// Mass balance: inlet = sum of outlets
// State layout: [m_in, m_out_1, m_out_2, ...]
let n_outlets = self.n_outlets();
if state.len() < 1 + n_outlets {
return Err(ComponentError::InvalidStateDimensions {
expected: 1 + n_outlets,
actual: state.len(),
});
}
let mut flows = Vec::with_capacity(1 + n_outlets);
// Inlet (positive = entering)
flows.push(entropyk_core::MassFlow::from_kg_per_s(state[0]));
// Outlets (negative = leaving)
for i in 0..n_outlets {
flows.push(entropyk_core::MassFlow::from_kg_per_s(-state[1 + i]));
}
Ok(flows)
}
/// Returns the enthalpies of all ports (inlet first, then outlets).
///
/// For a flow splitter, the enthalpy is conserved across branches:
/// `h_in = h_out_1 = h_out_2 = ...` (isenthalpic split).
fn port_enthalpies(
&self,
_state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let mut enthalpies = Vec::with_capacity(1 + self.outlets.len());
enthalpies.push(self.inlet.enthalpy());
for outlet in &self.outlets {
enthalpies.push(outlet.enthalpy());
}
Ok(enthalpies)
}
/// Returns the energy transfers for the flow splitter.
///
/// A flow splitter is adiabatic:
/// - **Heat (Q)**: 0 W (no heat exchange with environment)
/// - **Work (W)**: 0 W (no mechanical work)
fn energy_transfers(
&self,
_state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
Some((
entropyk_core::Power::from_watts(0.0),
entropyk_core::Power::from_watts(0.0),
))
}
fn signature(&self) -> String {
format!(
"FlowSplitter(fluid={}, outlets={})",
self.fluid_id,
self.outlets.len()
)
}
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new("FlowSplitter")
.with_param("fluid", self.fluid_id.as_str())
.with_param("outletCount", self.outlets.len())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FlowMerger — N inlets → 1 outlet
// ─────────────────────────────────────────────────────────────────────────────
/// A flow merger that combines N inlet branches into one outlet stream.
///
/// # Equations (N + 1)
///
/// | Equation | Residual |
/// |----------|---------|
/// | Pressure equalisation (k=2..N) | `P_in_k P_in_1 = 0` |
/// | Mixing enthalpy | `h_out (Σ h_in_k) / N = 0` (equal-weight mix) |
/// | Mass balance | `P_out P_in_1 = 0` |
///
/// ## Mixing enthalpy
///
/// When mass flow rates are not individually tracked, we use an equal-weight
/// average for the outlet enthalpy. This is exact for equal-flow branches and
/// approximate for unequal flows. When mass flows per branch are available
/// (from a FluidBackend), use [`FlowMerger::with_mass_flows`] for accuracy.
#[derive(Debug, Clone)]
pub struct FlowMerger {
/// Fluid kind (compressible / incompressible).
kind: FluidKind,
/// Fluid identifier.
fluid_id: String,
/// Inlet ports (N branches).
inlets: Vec<ConnectedPort>,
/// Outlet port (the single destination).
outlet: ConnectedPort,
/// Optional mass flow weights per inlet (kg/s). If None, equal weighting.
mass_flow_weights: Option<Vec<f64>>,
/// Captured (P,h) global state indices of the inlet edges (incoming).
inlet_idx: Vec<(usize, usize)>,
/// Captured (P,h) global state indices of the outlet edge (outgoing).
outlet_idx: Option<(usize, usize)>,
}
impl FlowMerger {
// ── Constructors ──────────────────────────────────────────────────────────
/// Creates an **incompressible** merger (water, glycol, brine…).
pub fn incompressible(
fluid: impl Into<String>,
inlets: Vec<ConnectedPort>,
outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
let fluid = fluid.into();
if !is_incompressible(&fluid) {
return Err(ComponentError::InvalidState(format!(
"FlowMerger::incompressible: '{}' does not appear to be an incompressible fluid. \
Use FlowMerger::compressible for refrigerants.",
fluid
)));
}
Self::new_inner(FluidKind::Incompressible, fluid, inlets, outlet)
}
/// Creates a **compressible** merger (R410A, CO₂, steam…).
pub fn compressible(
fluid: impl Into<String>,
inlets: Vec<ConnectedPort>,
outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
let fluid = fluid.into();
Self::new_inner(FluidKind::Compressible, fluid, inlets, outlet)
}
fn new_inner(
kind: FluidKind,
fluid: String,
inlets: Vec<ConnectedPort>,
outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
if inlets.is_empty() {
return Err(ComponentError::InvalidState(
"FlowMerger requires at least one inlet".into(),
));
}
if inlets.len() == 1 {
return Err(ComponentError::InvalidState(
"FlowMerger with 1 inlet is just a pipe — use a Pipe component instead".into(),
));
}
Ok(Self {
kind,
fluid_id: fluid,
inlets,
outlet,
mass_flow_weights: None,
inlet_idx: Vec::new(),
outlet_idx: None,
})
}
/// Assigns known mass flow rates per inlet for weighted enthalpy mixing.
///
/// # Errors
///
/// Returns an error if `weights.len() != n_inlets`.
pub fn with_mass_flows(mut self, weights: Vec<f64>) -> Result<Self, ComponentError> {
if weights.len() != self.inlets.len() {
return Err(ComponentError::InvalidState(format!(
"FlowMerger::with_mass_flows: expected {} weights, got {}",
self.inlets.len(),
weights.len()
)));
}
if weights.iter().any(|&w| w < 0.0) {
return Err(ComponentError::InvalidState(
"FlowMerger::with_mass_flows: mass flow weights must be non-negative".into(),
));
}
self.mass_flow_weights = Some(weights);
Ok(self)
}
// ── Accessors ─────────────────────────────────────────────────────────────
/// Number of inlet branches.
pub fn n_inlets(&self) -> usize {
self.inlets.len()
}
/// Fluid kind.
pub fn fluid_kind(&self) -> FluidKind {
self.kind
}
/// Fluid identifier.
pub fn fluid_id(&self) -> &str {
&self.fluid_id
}
/// Reference to the inlet ports.
pub fn inlets(&self) -> &[ConnectedPort] {
&self.inlets
}
/// Reference to the outlet port.
pub fn outlet(&self) -> &ConnectedPort {
&self.outlet
}
// ── Mixing helpers ────────────────────────────────────────────────────────
/// Returns the mass-flow weights normalised so they sum to 1.0.
///
/// Falls back to equal weighting when no weights are set or the total is
/// non-positive.
fn normalized_weights(&self) -> Vec<f64> {
let n = self.inlets.len();
match &self.mass_flow_weights {
Some(weights) => {
let total: f64 = weights.iter().sum();
if total <= 0.0 {
vec![1.0 / n as f64; n]
} else {
weights.iter().map(|&w| w / total).collect()
}
}
None => vec![1.0 / n as f64; n],
}
}
}
impl Component for FlowMerger {
/// `2` equations — the merger owns its single outgoing edge:
/// - `P_out P_in_0 = 0` (outlet pressure = reference inlet pressure)
/// - `h_out Σ wₖ·h_in_k = 0` (mass-weighted enthalpy mixing)
fn n_equations(&self) -> usize {
2
}
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// Layout: [0..N] = incoming inlet edges, [N] = outgoing outlet edge.
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
let n = self.inlets.len();
if external_edge_state_indices.len() >= n {
self.inlet_idx = external_edge_state_indices[0..n]
.iter()
.map(|&(_, p, h)| (p, h))
.collect();
}
if external_edge_state_indices.len() >= n + 1 {
self.outlet_idx = Some((
external_edge_state_indices[n].1,
external_edge_state_indices[n].2,
));
}
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let (out_p, out_h) = match self.outlet_idx {
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowMerger requires all live inlet edges and one live outlet edge".to_string(),
));
}
};
// r0: outlet pressure tracks the (reference) first inlet pressure.
let p_ref = state[self.inlet_idx[0].0];
residuals[0] = state[out_p] - p_ref;
// r1: mass-weighted enthalpy mixing.
let weights = self.normalized_weights();
let h_mix: f64 = self
.inlet_idx
.iter()
.zip(weights.iter())
.map(|(&(_, h_idx), &w)| w * state[h_idx])
.sum();
residuals[1] = state[out_h] - h_mix;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let (out_p, out_h) = match self.outlet_idx {
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowMerger Jacobian requires all live inlet edges and one live outlet edge"
.to_string(),
));
}
};
// r0 = P_out - P_in_0
jacobian.add_entry(0, out_p, 1.0);
jacobian.add_entry(0, self.inlet_idx[0].0, -1.0);
// r1 = h_out - Σ wₖ·h_in_k
jacobian.add_entry(1, out_h, 1.0);
let weights = self.normalized_weights();
for (&(_, h_idx), &w) in self.inlet_idx.iter().zip(weights.iter()) {
jacobian.add_entry(1, h_idx, -w);
}
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
// FlowMerger: N inlets → 1 outlet
// Mass balance: sum of inlets = outlet
// State layout: [m_in_1, m_in_2, ..., m_out]
let n_inlets = self.n_inlets();
if state.len() < n_inlets + 1 {
return Err(ComponentError::InvalidStateDimensions {
expected: n_inlets + 1,
actual: state.len(),
});
}
let mut flows = Vec::with_capacity(n_inlets + 1);
// Inlets (positive = entering)
for i in 0..n_inlets {
flows.push(entropyk_core::MassFlow::from_kg_per_s(state[i]));
}
// Outlet (negative = leaving)
flows.push(entropyk_core::MassFlow::from_kg_per_s(-state[n_inlets]));
Ok(flows)
}
/// Returns the enthalpies of all ports (inlets first, then outlet).
///
/// For a flow merger, the outlet enthalpy is determined by
/// the mixing of inlet streams (mass-weighted average).
fn port_enthalpies(
&self,
_state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let mut enthalpies = Vec::with_capacity(self.inlets.len() + 1);
for inlet in &self.inlets {
enthalpies.push(inlet.enthalpy());
}
enthalpies.push(self.outlet.enthalpy());
Ok(enthalpies)
}
/// Returns the energy transfers for the flow merger.
///
/// A flow merger is adiabatic:
/// - **Heat (Q)**: 0 W (no heat exchange with environment)
/// - **Work (W)**: 0 W (no mechanical work)
fn energy_transfers(
&self,
_state: &StateSlice,
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
Some((
entropyk_core::Power::from_watts(0.0),
entropyk_core::Power::from_watts(0.0),
))
}
fn signature(&self) -> String {
format!(
"FlowMerger(fluid={}, inlets={})",
self.fluid_id,
self.inlets.len()
)
}
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new("FlowMerger")
.with_param("fluid", self.fluid_id.as_str())
.with_param("inletCount", self.inlets.len())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Convenience type aliases
// ─────────────────────────────────────────────────────────────────────────────
/// A flow splitter for incompressible fluids (water, glycol, brine…).
///
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::incompressible`].
pub type IncompressibleSplitter = FlowSplitter;
/// A flow splitter for compressible fluids (refrigerant, CO₂, steam…).
///
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::compressible`].
pub type CompressibleSplitter = FlowSplitter;
/// A flow merger for incompressible fluids (water, glycol, brine…).
///
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::incompressible`].
pub type IncompressibleMerger = FlowMerger;
/// A flow merger for compressible fluids (refrigerant, CO₂, steam…).
///
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::compressible`].
pub type CompressibleMerger = FlowMerger;
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::port::{FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
fn make_port(fluid: &str, p_pa: f64, h_jkg: f64) -> ConnectedPort {
let p1 = Port::new(
FluidId::new(fluid),
Pressure::from_pascals(p_pa),
Enthalpy::from_joules_per_kg(h_jkg),
);
let p2 = Port::new(
FluidId::new(fluid),
Pressure::from_pascals(p_pa),
Enthalpy::from_joules_per_kg(h_jkg),
);
p1.connect(p2).unwrap().0
}
// ── FlowSplitter ──────────────────────────────────────────────────────────
#[test]
fn test_splitter_incompressible_creation() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
assert_eq!(s.n_outlets(), 2);
assert_eq!(s.fluid_kind(), FluidKind::Incompressible);
// n_equations = 2*N = 4 (owns 2 outgoing edges, P+h each)
assert_eq!(s.n_equations(), 4);
}
#[test]
fn test_splitter_compressible_creation() {
let inlet = make_port("R410A", 24.0e5, 4.65e5);
let out_a = make_port("R410A", 24.0e5, 4.65e5);
let out_b = make_port("R410A", 24.0e5, 4.65e5);
let out_c = make_port("R410A", 24.0e5, 4.65e5);
let s = FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b, out_c]).unwrap();
assert_eq!(s.n_outlets(), 3);
assert_eq!(s.fluid_kind(), FluidKind::Compressible);
// n_equations = 2*N = 6
assert_eq!(s.n_equations(), 6);
}
#[test]
fn test_splitter_rejects_refrigerant_as_incompressible() {
let inlet = make_port("R410A", 24.0e5, 4.65e5);
let out_a = make_port("R410A", 24.0e5, 4.65e5);
let out_b = make_port("R410A", 24.0e5, 4.65e5);
let result = FlowSplitter::incompressible("R410A", inlet, vec![out_a, out_b]);
assert!(
result.is_err(),
"R410A should not be accepted as incompressible"
);
}
#[test]
fn test_splitter_rejects_single_outlet() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out = make_port("Water", 3.0e5, 2.0e5);
let result = FlowSplitter::incompressible("Water", inlet, vec![out]);
assert!(result.is_err());
}
#[test]
fn test_splitter_residuals_zero_at_consistent_state() {
// Consistent state: all branch pressures/enthalpies equal the inlet.
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
// Edges (CM1.3 stride-3): inlet=(ṁ@0,P@1,h@2), out_a=(ṁ@3,P@4,h@5), out_b=(ṁ@6,P@7,h@8)
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
// State: ṁ slots are don't-care (splitter ignores them); P and h are consistent.
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
let mut res = vec![0.0; s.n_equations()];
s.compute_residuals(&state, &mut res).unwrap();
for (i, &r) in res.iter().enumerate() {
assert!(
r.abs() < 1.0,
"residual[{}] = {} should be ≈ 0 for consistent state",
i,
r
);
}
}
#[test]
fn test_splitter_residuals_nonzero_on_pressure_mismatch() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
// out_a pressure lower than inlet by 0.5e5; ṁ slots are don't-care.
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 2.5e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
let mut res = vec![0.0; s.n_equations()];
s.compute_residuals(&state, &mut res).unwrap();
// r[0] = P_out_a - P_in = 2.5e5 - 3.0e5 = -0.5e5
assert!(
(res[0] - (-0.5e5)).abs() < 1.0,
"expected -0.5e5, got {}",
res[0]
);
}
#[test]
fn test_splitter_three_branches_n_equations() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let outlets: Vec<_> = (0..3).map(|_| make_port("Water", 3.0e5, 2.0e5)).collect();
let s = FlowSplitter::incompressible("Water", inlet, outlets).unwrap();
// N=3 → 2*N = 6
assert_eq!(s.n_equations(), 6);
}
#[test]
fn test_splitter_water_type_aliases() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
// IncompressibleSplitter is a type alias for FlowSplitter
let _s: IncompressibleSplitter =
FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
}
// ── FlowMerger ────────────────────────────────────────────────────────────
#[test]
fn test_merger_incompressible_creation() {
let in_a = make_port("Water", 3.0e5, 2.0e5);
let in_b = make_port("Water", 3.0e5, 2.4e5);
let outlet = make_port("Water", 3.0e5, 2.2e5);
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
assert_eq!(m.n_inlets(), 2);
assert_eq!(m.fluid_kind(), FluidKind::Incompressible);
// Merger owns its single outgoing edge → 2 equations (P + h)
assert_eq!(m.n_equations(), 2);
}
#[test]
fn test_merger_compressible_creation() {
let in_a = make_port("R134a", 8.0e5, 4.0e5);
let in_b = make_port("R134a", 8.0e5, 4.2e5);
let in_c = make_port("R134a", 8.0e5, 3.8e5);
let outlet = make_port("R134a", 8.0e5, 4.0e5);
let m = FlowMerger::compressible("R134a", vec![in_a, in_b, in_c], outlet).unwrap();
assert_eq!(m.n_inlets(), 3);
assert_eq!(m.fluid_kind(), FluidKind::Compressible);
// Merger owns its single outgoing edge → 2 equations (P + h)
assert_eq!(m.n_equations(), 2);
}
#[test]
fn test_merger_rejects_single_inlet() {
let in_a = make_port("Water", 3.0e5, 2.0e5);
let outlet = make_port("Water", 3.0e5, 2.0e5);
let result = FlowMerger::incompressible("Water", vec![in_a], outlet);
assert!(result.is_err());
}
#[test]
fn test_merger_residuals_zero_at_consistent_state() {
// Equal branches → mixed enthalpy = inlet enthalpy
let h = 2.0e5_f64;
let p = 3.0e5_f64;
let in_a = make_port("Water", p, h);
let in_b = make_port("Water", p, h);
let outlet = make_port("Water", p, h); // h_mixed = (h+h)/2 = h
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
// Edges (CM1.3 stride-3): in_a=(ṁ@0,P@1,h@2), in_b=(ṁ@3,P@4,h@5), outlet=(ṁ@6,P@7,h@8)
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0, p, h, 0.0, p, h, 0.0, p, h];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
for (i, &r) in res.iter().enumerate() {
assert!(r.abs() < 1.0, "residual[{}] = {} should be ≈ 0", i, r);
}
}
#[test]
fn test_merger_mixed_enthalpy_equal_branches() {
let h_a = 2.0e5_f64;
let h_b = 3.0e5_f64;
let h_expected = (h_a + h_b) / 2.0; // equal-weight average
let p = 3.0e5_f64;
let in_a = make_port("Water", p, h_a);
let in_b = make_port("Water", p, h_b);
let outlet = make_port("Water", p, h_expected);
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0, p, h_a, 0.0, p, h_b, 0.0, p, h_expected];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
// Last residual: h_out - h_mixed should be 0
let last = res[m.n_equations() - 1];
assert!(last.abs() < 1.0, "h mixing residual = {} should be 0", last);
}
#[test]
fn test_merger_weighted_enthalpy() {
// ṁ_a = 0.3 kg/s, h_a = 2e5 J/kg
// ṁ_b = 0.7 kg/s, h_b = 3e5 J/kg
// h_mix = (0.3*2e5 + 0.7*3e5) / 1.0 = (6e4 + 21e4) = 2.7e5 J/kg
let p = 3.0e5_f64;
let in_a = make_port("Water", p, 2.0e5);
let in_b = make_port("Water", p, 3.0e5);
let outlet = make_port("Water", p, 2.7e5);
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
.unwrap()
.with_mass_flows(vec![0.3, 0.7])
.unwrap();
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0, p, 2.0e5, 0.0, p, 3.0e5, 0.0, p, 2.7e5];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
let h_residual = res[m.n_equations() - 1];
assert!(
h_residual.abs() < 1.0,
"weighted h mixing residual = {} should be 0",
h_residual
);
}
#[test]
fn test_merger_as_trait_object() {
let in_a = make_port("Water", 3.0e5, 2.0e5);
let in_b = make_port("Water", 3.0e5, 2.0e5);
let outlet = make_port("Water", 3.0e5, 2.0e5);
let merger: Box<dyn Component> =
Box::new(FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap());
assert_eq!(merger.n_equations(), 2);
}
#[test]
fn test_splitter_as_trait_object() {
let inlet = make_port("R410A", 24.0e5, 4.65e5);
let out_a = make_port("R410A", 24.0e5, 4.65e5);
let out_b = make_port("R410A", 24.0e5, 4.65e5);
let splitter: Box<dyn Component> =
Box::new(FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b]).unwrap());
assert_eq!(splitter.n_equations(), 4);
}
// ── energy_transfers tests ─────────────────────────────────────────────────
#[test]
fn test_splitter_energy_transfers_zero() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let splitter = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
let state = vec![0.0; 6];
let (heat, work) = splitter.energy_transfers(&state).unwrap();
assert_eq!(heat.to_watts(), 0.0);
assert_eq!(work.to_watts(), 0.0);
}
#[test]
fn test_merger_energy_transfers_zero() {
let in_a = make_port("Water", 3.0e5, 2.0e5);
let in_b = make_port("Water", 3.0e5, 2.4e5);
let outlet = make_port("Water", 3.0e5, 2.2e5);
let merger = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
let (heat, work) = merger.energy_transfers(&state).unwrap();
assert_eq!(heat.to_watts(), 0.0);
assert_eq!(work.to_watts(), 0.0);
}
// ── port_enthalpies tests ──────────────────────────────────────────────────
#[test]
fn test_splitter_port_enthalpies_count() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let out_c = make_port("Water", 3.0e5, 2.0e5);
let splitter =
FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b, out_c]).unwrap();
let state = vec![0.0; 8];
let enthalpies = splitter.port_enthalpies(&state).unwrap();
// 1 inlet + 3 outlets = 4 enthalpies
assert_eq!(enthalpies.len(), 4);
}
#[test]
fn test_merger_port_enthalpies_count() {
let in_a = make_port("Water", 3.0e5, 2.0e5);
let in_b = make_port("Water", 3.0e5, 2.4e5);
let in_c = make_port("Water", 3.0e5, 2.2e5);
let outlet = make_port("Water", 3.0e5, 2.2e5);
let merger = FlowMerger::incompressible("Water", vec![in_a, in_b, in_c], outlet).unwrap();
let state = vec![0.0; 8];
let enthalpies = merger.port_enthalpies(&state).unwrap();
// 3 inlets + 1 outlet = 4 enthalpies
assert_eq!(enthalpies.len(), 4);
}
#[test]
fn test_splitter_port_enthalpies_values() {
let h_in = 2.5e5_f64;
let h_out_a = 2.5e5_f64;
let h_out_b = 2.5e5_f64;
let inlet = make_port("Water", 3.0e5, h_in);
let out_a = make_port("Water", 3.0e5, h_out_a);
let out_b = make_port("Water", 3.0e5, h_out_b);
let splitter = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
let state = vec![0.0; 6];
let enthalpies = splitter.port_enthalpies(&state).unwrap();
assert_eq!(enthalpies[0].to_joules_per_kg(), h_in);
assert_eq!(enthalpies[1].to_joules_per_kg(), h_out_a);
assert_eq!(enthalpies[2].to_joules_per_kg(), h_out_b);
}
#[test]
fn test_merger_port_enthalpies_values() {
let h_in_a = 2.0e5_f64;
let h_in_b = 3.0e5_f64;
let h_out = 2.5e5_f64;
let in_a = make_port("Water", 3.0e5, h_in_a);
let in_b = make_port("Water", 3.0e5, h_in_b);
let outlet = make_port("Water", 3.0e5, h_out);
let merger = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
let enthalpies = merger.port_enthalpies(&state).unwrap();
assert_eq!(enthalpies[0].to_joules_per_kg(), h_in_a);
assert_eq!(enthalpies[1].to_joules_per_kg(), h_in_b);
assert_eq!(enthalpies[2].to_joules_per_kg(), h_out);
}
}