Entropyk/crates/components/src/flow_junction.rs

1069 lines
39 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).
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")
}
// ─────────────────────────────────────────────────────────────────────────────
// 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>,
}
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,
})
}
// ── 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 1` equations:
/// - `N1` pressure equalities
/// - `N1` enthalpy equalities
/// - `1` mass balance (represented as N-th outlet consistency)
fn n_equations(&self) -> usize {
let n = self.outlets.len();
2 * n - 1
}
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(),
});
}
// Inlet state indices come from the ConnectedPort.
// In the Entropyk solver, the state vector is indexed by edge:
// each edge contributes (P_idx, h_idx) set during System::finalize().
let p_in = self.inlet.pressure().to_pascals();
let h_in = self.inlet.enthalpy().to_joules_per_kg();
let n = self.outlets.len();
let mut r_idx = 0;
// --- Isobaric constraints: outlets 0..N-1 ---
for k in 0..(n - 1) {
let p_out_k = self.outlets[k].pressure().to_pascals();
residuals[r_idx] = p_out_k - p_in;
r_idx += 1;
}
// --- Isenthalpic constraints: outlets 0..N-1 ---
for k in 0..(n - 1) {
let h_out_k = self.outlets[k].enthalpy().to_joules_per_kg();
residuals[r_idx] = h_out_k - h_in;
r_idx += 1;
}
// --- Mass balance (1 equation) ---
// Express as: P_out_N = P_in AND h_out_N = h_in (implicitly guaranteed
// by the solver topology, but we add one algebraic check on the last branch).
// We use the last outlet as the "free" degree of freedom:
let p_out_last = self.outlets[n - 1].pressure().to_pascals();
residuals[r_idx] = p_out_last - p_in;
// Note: h_out_last = h_in is implied once all other constraints are met
// (conservation is guaranteed by the graph topology).
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// All residuals are linear differences → constant Jacobian.
// Each residual r_i depends on exactly two state variables with
// coefficients +1 and -1. Since the state indices are stored in the
// ConnectedPort pressure/enthalpy values (not raw state indices here),
// we emit a diagonal approximation: ∂r_i/∂x_i = 1.
//
// The full off-diagonal coupling is handled by the System assembler
// which maps port values to state vector positions.
let n_eqs = self.n_equations();
for i in 0..n_eqs {
jacobian.add_entry(i, i, 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),
))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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>>,
}
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,
})
}
/// 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 ────────────────────────────────────────────────────────
/// Computes the mixed outlet enthalpy (weighted or equal).
fn mixed_enthalpy(&self) -> f64 {
let n = self.inlets.len();
match &self.mass_flow_weights {
Some(weights) => {
let total_flow: f64 = weights.iter().sum();
if total_flow <= 0.0 {
// Fall back to equal weighting
self.inlets
.iter()
.map(|p| p.enthalpy().to_joules_per_kg())
.sum::<f64>()
/ n as f64
} else {
self.inlets
.iter()
.zip(weights.iter())
.map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w)
.sum::<f64>()
/ total_flow
}
}
None => {
// Equal weighting
self.inlets
.iter()
.map(|p| p.enthalpy().to_joules_per_kg())
.sum::<f64>()
/ n as f64
}
}
}
}
impl Component for FlowMerger {
/// `N + 1` equations:
/// - `N1` pressure equalisations across inlets
/// - `1` mixing enthalpy for the outlet
/// - `1` outlet pressure consistency
fn n_equations(&self) -> usize {
self.inlets.len() + 1
}
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 p_ref = self.inlets[0].pressure().to_pascals();
let mut r_idx = 0;
// --- Pressure equalisation: inlets 1..N must match inlet 0 ---
for k in 1..self.inlets.len() {
let p_k = self.inlets[k].pressure().to_pascals();
residuals[r_idx] = p_k - p_ref;
r_idx += 1;
}
// --- Outlet pressure = reference inlet pressure ---
let p_out = self.outlet.pressure().to_pascals();
residuals[r_idx] = p_out - p_ref;
r_idx += 1;
// --- Mixing enthalpy ---
let h_mixed = self.mixed_enthalpy();
let h_out = self.outlet.enthalpy().to_joules_per_kg();
residuals[r_idx] = h_out - h_mixed;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Diagonal approximation — the full coupling is resolved by the System
// assembler through the edge state indices.
let n_eqs = self.n_equations();
for i in 0..n_eqs {
jacobian.add_entry(i, i, 1.0);
}
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),
))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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*2 - 1 = 3
assert_eq!(s.n_equations(), 3);
}
#[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*3 - 1 = 5
assert_eq!(s.n_equations(), 5);
}
#[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 pressures and enthalpies equal
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();
let state = vec![0.0; 6]; // dummy, not used by current impl
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", 2.5e5, 2.0e5); // lower pressure!
let out_b = make_port("Water", 3.0e5, 2.0e5);
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
let state = vec![0.0; 6];
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*3-1 = 5
assert_eq!(s.n_equations(), 5);
}
#[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);
// N=2 → N+1 = 3
assert_eq!(m.n_equations(), 3);
}
#[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);
// N=3 → N+1 = 4
assert_eq!(m.n_equations(), 4);
}
#[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 m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
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 m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
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 m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
.unwrap()
.with_mass_flows(vec![0.3, 0.7])
.unwrap();
let state = vec![0.0; 6];
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(), 3);
}
#[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(), 3);
}
// ── 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);
}
}