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>
1161 lines
42 KiB
Rust
1161 lines
42 KiB
Rust
//! 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);
|
||
}
|
||
}
|