//! 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, /// 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, inlet: ConnectedPort, outlets: Vec, ) -> Result { 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, inlet: ConnectedPort, outlets: Vec, ) -> Result { let fluid = fluid.into(); Self::new_inner(FluidKind::Compressible, fluid, inlet, outlets) } fn new_inner( kind: FluidKind, fluid: String, inlet: ConnectedPort, outlets: Vec, ) -> Result { 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, 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, 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, /// Outlet port (the single destination). outlet: ConnectedPort, /// Optional mass flow weights per inlet (kg/s). If None, equal weighting. mass_flow_weights: Option>, /// 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, inlets: Vec, outlet: ConnectedPort, ) -> Result { 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, inlets: Vec, outlet: ConnectedPort, ) -> Result { let fluid = fluid.into(); Self::new_inner(FluidKind::Compressible, fluid, inlets, outlet) } fn new_inner( kind: FluidKind, fluid: String, inlets: Vec, outlet: ConnectedPort, ) -> Result { 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) -> Result { 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 { 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, 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, 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 = 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 = 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); } }