//! 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, } 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, }) } // ── 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, 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), )) } } // ───────────────────────────────────────────────────────────────────────────── // 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>, } 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, }) } /// 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 ──────────────────────────────────────────────────────── /// 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::() / n as f64 } else { self.inlets .iter() .zip(weights.iter()) .map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w) .sum::() / total_flow } } None => { // Equal weighting self.inlets .iter() .map(|p| p.enthalpy().to_joules_per_kg()) .sum::() / 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, 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), )) } } // ───────────────────────────────────────────────────────────────────────────── // 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 = 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 = 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); } }