1069 lines
39 KiB
Rust
1069 lines
39 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).
|
||
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:
|
||
/// - `N−1` pressure equalities
|
||
/// - `N−1` 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:
|
||
/// - `N−1` 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);
|
||
}
|
||
}
|