//! Boundary Condition Components — Source & Sink //! //! This module provides `FlowSource` and `FlowSink` for both incompressible //! (water, glycol, brine) and compressible (refrigerant, CO₂) fluid systems. //! //! ## Design Philosophy (à la Modelica) //! //! - **`FlowSource`** imposes a fixed thermodynamic state (P, h) on its outlet //! edge. It is the entry point of a fluid circuit — it represents an infinite //! reservoir at constant conditions (city water supply, district heating header, //! refrigerant reservoir, etc.). //! //! - **`FlowSink`** absorbs flow at a fixed pressure (back-pressure). It is the //! termination point of a circuit. Optionally, a fixed outlet enthalpy can also //! be imposed (isothermal return, phase separator, etc.). //! //! ## Equations //! //! ### FlowSource — 2 equations //! //! ```text //! r_P = P_edge − P_set = 0 (pressure boundary condition) //! r_h = h_edge − h_set = 0 (enthalpy / temperature BC) //! ``` //! //! ### FlowSink — 1 or 2 equations //! //! ```text //! r_P = P_edge − P_back = 0 (back-pressure boundary condition) //! [optional] r_h = h_edge − h_back = 0 //! ``` //! //! ## Incompressible vs Compressible //! //! Same physics, different construction-time validation. Use: //! - `FlowSource::incompressible` / `FlowSink::incompressible` for water, glycol… //! - `FlowSource::compressible` / `FlowSink::compressible` for refrigerant, CO₂… //! //! ## Example //! //! ```no_run //! use entropyk_components::flow_boundary::{FlowSource, FlowSink}; //! 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)); //! a.connect(b).unwrap().0 //! }; //! //! // City water supply: 3 bar, 15°C (h ≈ 63 kJ/kg) //! let source = FlowSource::incompressible( //! "Water", 3.0e5, 63_000.0, make_port(3.0e5, 63_000.0), //! ).unwrap(); //! //! // Return header: 1.5 bar back-pressure //! let sink = FlowSink::incompressible( //! "Water", 1.5e5, None, make_port(1.5e5, 63_000.0), //! ).unwrap(); //! ``` use crate::{ flow_junction::is_incompressible, flow_junction::FluidKind, Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState, }; // ───────────────────────────────────────────────────────────────────────────── // FlowSource — Fixed P & h boundary condition // ───────────────────────────────────────────────────────────────────────────── /// A boundary source that imposes fixed pressure and enthalpy on its outlet edge. /// /// Represents an ideal infinite reservoir (city water, refrigerant header, steam /// drum, etc.) at constant thermodynamic conditions. /// /// # Equations (always 2) /// /// ```text /// r₀ = P_edge − P_set = 0 /// r₁ = h_edge − h_set = 0 /// ``` #[derive(Debug, Clone)] pub struct FlowSource { /// Fluid kind. kind: FluidKind, /// Fluid name. fluid_id: String, /// Set-point pressure [Pa]. p_set_pa: f64, /// Set-point specific enthalpy [J/kg]. h_set_jkg: f64, /// Connected outlet port (links to first edge in the System). outlet: ConnectedPort, } impl FlowSource { // ── Constructors ───────────────────────────────────────────────────────── /// Creates an **incompressible** source (water, glycol, brine…). /// /// # Arguments /// /// * `fluid` — fluid identifier string (e.g. `"Water"`) /// * `p_set_pa` — set-point pressure in Pascals /// * `h_set_jkg` — set-point specific enthalpy in J/kg /// * `outlet` — connected port linked to the first system edge pub fn incompressible( fluid: impl Into, p_set_pa: f64, h_set_jkg: f64, outlet: ConnectedPort, ) -> Result { let fluid = fluid.into(); if !is_incompressible(&fluid) { return Err(ComponentError::InvalidState(format!( "FlowSource::incompressible: '{}' does not appear incompressible. \ Use FlowSource::compressible for refrigerants.", fluid ))); } Self::new_inner(FluidKind::Incompressible, fluid, p_set_pa, h_set_jkg, outlet) } /// Creates a **compressible** source (R410A, CO₂, steam…). pub fn compressible( fluid: impl Into, p_set_pa: f64, h_set_jkg: f64, outlet: ConnectedPort, ) -> Result { let fluid = fluid.into(); Self::new_inner(FluidKind::Compressible, fluid, p_set_pa, h_set_jkg, outlet) } fn new_inner( kind: FluidKind, fluid: String, p_set_pa: f64, h_set_jkg: f64, outlet: ConnectedPort, ) -> Result { if p_set_pa <= 0.0 { return Err(ComponentError::InvalidState( "FlowSource: set-point pressure must be positive".into(), )); } Ok(Self { kind, fluid_id: fluid, p_set_pa, h_set_jkg, outlet }) } // ── Accessors ──────────────────────────────────────────────────────────── /// Fluid kind. pub fn fluid_kind(&self) -> FluidKind { self.kind } /// Fluid id. pub fn fluid_id(&self) -> &str { &self.fluid_id } /// Set-point pressure [Pa]. pub fn p_set_pa(&self) -> f64 { self.p_set_pa } /// Set-point enthalpy [J/kg]. pub fn h_set_jkg(&self) -> f64 { self.h_set_jkg } /// Reference to the outlet port. pub fn outlet(&self) -> &ConnectedPort { &self.outlet } /// Updates the set-point pressure (useful for parametric studies). pub fn set_pressure(&mut self, p_pa: f64) -> Result<(), ComponentError> { if p_pa <= 0.0 { return Err(ComponentError::InvalidState( "FlowSource: pressure must be positive".into(), )); } self.p_set_pa = p_pa; Ok(()) } /// Updates the set-point enthalpy. pub fn set_enthalpy(&mut self, h_jkg: f64) { self.h_set_jkg = h_jkg; } } impl Component for FlowSource { fn n_equations(&self) -> usize { 2 } fn compute_residuals( &self, _state: &SystemState, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { if residuals.len() < 2 { return Err(ComponentError::InvalidResidualDimensions { expected: 2, actual: residuals.len(), }); } // Pressure residual: P_edge − P_set = 0 residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa; // Enthalpy residual: h_edge − h_set = 0 residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg; Ok(()) } fn jacobian_entries( &self, _state: &SystemState, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Both residuals are linear in the edge state: ∂r/∂x = 1 jacobian.add_entry(0, 0, 1.0); jacobian.add_entry(1, 1, 1.0); Ok(()) } fn get_ports(&self) -> &[ConnectedPort] { &[] } } // ───────────────────────────────────────────────────────────────────────────── // FlowSink — Back-pressure boundary condition // ───────────────────────────────────────────────────────────────────────────── /// A boundary sink that imposes a fixed back-pressure (and optionally enthalpy) /// on its inlet edge. /// /// Represents an infinite low-pressure reservoir (drain, condenser header, /// discharge line, atmospheric vent, etc.). /// /// # Equations (1 or 2) /// /// ```text /// r₀ = P_edge − P_back = 0 [always] /// r₁ = h_edge − h_back = 0 [only if h_back is set] /// ``` #[derive(Debug, Clone)] pub struct FlowSink { /// Fluid kind. kind: FluidKind, /// Fluid name. fluid_id: String, /// Back-pressure [Pa]. p_back_pa: f64, /// Optional fixed outlet enthalpy [J/kg]. h_back_jkg: Option, /// Connected inlet port. inlet: ConnectedPort, } impl FlowSink { // ── Constructors ───────────────────────────────────────────────────────── /// Creates an **incompressible** sink (water, glycol…). /// /// # Arguments /// /// * `fluid` — fluid identifier string /// * `p_back_pa` — back-pressure in Pascals /// * `h_back_jkg` — optional fixed return enthalpy; `None` = free (solver decides) /// * `inlet` — connected port pub fn incompressible( fluid: impl Into, p_back_pa: f64, h_back_jkg: Option, inlet: ConnectedPort, ) -> Result { let fluid = fluid.into(); if !is_incompressible(&fluid) { return Err(ComponentError::InvalidState(format!( "FlowSink::incompressible: '{}' does not appear incompressible. \ Use FlowSink::compressible for refrigerants.", fluid ))); } Self::new_inner(FluidKind::Incompressible, fluid, p_back_pa, h_back_jkg, inlet) } /// Creates a **compressible** sink (R410A, CO₂, steam…). pub fn compressible( fluid: impl Into, p_back_pa: f64, h_back_jkg: Option, inlet: ConnectedPort, ) -> Result { let fluid = fluid.into(); Self::new_inner(FluidKind::Compressible, fluid, p_back_pa, h_back_jkg, inlet) } fn new_inner( kind: FluidKind, fluid: String, p_back_pa: f64, h_back_jkg: Option, inlet: ConnectedPort, ) -> Result { if p_back_pa <= 0.0 { return Err(ComponentError::InvalidState( "FlowSink: back-pressure must be positive".into(), )); } Ok(Self { kind, fluid_id: fluid, p_back_pa, h_back_jkg, inlet }) } // ── Accessors ──────────────────────────────────────────────────────────── /// Fluid kind. pub fn fluid_kind(&self) -> FluidKind { self.kind } /// Fluid id. pub fn fluid_id(&self) -> &str { &self.fluid_id } /// Back-pressure [Pa]. pub fn p_back_pa(&self) -> f64 { self.p_back_pa } /// Optional back-enthalpy [J/kg]. pub fn h_back_jkg(&self) -> Option { self.h_back_jkg } /// Reference to the inlet port. pub fn inlet(&self) -> &ConnectedPort { &self.inlet } /// Updates the back-pressure. pub fn set_pressure(&mut self, p_pa: f64) -> Result<(), ComponentError> { if p_pa <= 0.0 { return Err(ComponentError::InvalidState( "FlowSink: back-pressure must be positive".into(), )); } self.p_back_pa = p_pa; Ok(()) } /// Sets a fixed return enthalpy (activates the second equation). pub fn set_return_enthalpy(&mut self, h_jkg: f64) { self.h_back_jkg = Some(h_jkg); } /// Removes the fixed enthalpy constraint (solver determines enthalpy freely). pub fn clear_return_enthalpy(&mut self) { self.h_back_jkg = None; } } impl Component for FlowSink { fn n_equations(&self) -> usize { if self.h_back_jkg.is_some() { 2 } else { 1 } } fn compute_residuals( &self, _state: &SystemState, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { let n = self.n_equations(); if residuals.len() < n { return Err(ComponentError::InvalidResidualDimensions { expected: n, actual: residuals.len(), }); } // Back-pressure residual residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa; // Optional enthalpy residual if let Some(h_back) = self.h_back_jkg { residuals[1] = self.inlet.enthalpy().to_joules_per_kg() - h_back; } Ok(()) } fn jacobian_entries( &self, _state: &SystemState, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { let n = self.n_equations(); for i in 0..n { jacobian.add_entry(i, i, 1.0); } Ok(()) } fn get_ports(&self) -> &[ConnectedPort] { &[] } } // ───────────────────────────────────────────────────────────────────────────── // Convenience type aliases (à la Modelica) // ───────────────────────────────────────────────────────────────────────────── /// Source for incompressible fluids (water, glycol, brine…). pub type IncompressibleSource = FlowSource; /// Source for compressible fluids (refrigerant, CO₂, steam…). pub type CompressibleSource = FlowSource; /// Sink for incompressible fluids. pub type IncompressibleSink = FlowSink; /// Sink for compressible fluids. pub type CompressibleSink = FlowSink; // ───────────────────────────────────────────────────────────────────────────── // 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 a = Port::new(FluidId::new(fluid), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_jkg)); let b = Port::new(FluidId::new(fluid), Pressure::from_pascals(p_pa), Enthalpy::from_joules_per_kg(h_jkg)); a.connect(b).unwrap().0 } // ── FlowSource ──────────────────────────────────────────────────────────── #[test] fn test_source_incompressible_water() { // City water supply: 3 bar, 15°C (h ≈ 63 kJ/kg) let port = make_port("Water", 3.0e5, 63_000.0); let s = FlowSource::incompressible("Water", 3.0e5, 63_000.0, port).unwrap(); assert_eq!(s.n_equations(), 2); assert_eq!(s.fluid_kind(), FluidKind::Incompressible); assert_eq!(s.p_set_pa(), 3.0e5); assert_eq!(s.h_set_jkg(), 63_000.0); } #[test] fn test_source_compressible_refrigerant() { // R410A high-side: 24 bar, h = 465 kJ/kg (superheated vapour) let port = make_port("R410A", 24.0e5, 465_000.0); let s = FlowSource::compressible("R410A", 24.0e5, 465_000.0, port).unwrap(); assert_eq!(s.n_equations(), 2); assert_eq!(s.fluid_kind(), FluidKind::Compressible); } #[test] fn test_source_rejects_refrigerant_as_incompressible() { let port = make_port("R410A", 24.0e5, 465_000.0); let result = FlowSource::incompressible("R410A", 24.0e5, 465_000.0, port); assert!(result.is_err()); } #[test] fn test_source_rejects_zero_pressure() { let port = make_port("Water", 3.0e5, 63_000.0); let result = FlowSource::incompressible("Water", 0.0, 63_000.0, port); assert!(result.is_err()); } #[test] fn test_source_residuals_zero_at_set_point() { let p = 3.0e5_f64; let h = 63_000.0_f64; let port = make_port("Water", p, h); let s = FlowSource::incompressible("Water", p, h, port).unwrap(); let state = vec![0.0; 4]; let mut res = vec![0.0; 2]; s.compute_residuals(&state, &mut res).unwrap(); assert!(res[0].abs() < 1.0, "P residual = {}", res[0]); assert!(res[1].abs() < 1.0, "h residual = {}", res[1]); } #[test] fn test_source_residuals_nonzero_on_mismatch() { // Port at 2 bar but set-point 3 bar → residual = -1e5 let port = make_port("Water", 2.0e5, 63_000.0); let s = FlowSource::incompressible("Water", 3.0e5, 63_000.0, port).unwrap(); let state = vec![0.0; 4]; let mut res = vec![0.0; 2]; s.compute_residuals(&state, &mut res).unwrap(); assert!((res[0] - (-1.0e5)).abs() < 1.0, "expected -1e5, got {}", res[0]); } #[test] fn test_source_set_pressure() { let port = make_port("Water", 3.0e5, 63_000.0); let mut s = FlowSource::incompressible("Water", 3.0e5, 63_000.0, port).unwrap(); s.set_pressure(5.0e5).unwrap(); assert_eq!(s.p_set_pa(), 5.0e5); assert!(s.set_pressure(0.0).is_err()); } #[test] fn test_source_as_trait_object() { let port = make_port("R410A", 8.5e5, 260_000.0); let src: Box = Box::new(FlowSource::compressible("R410A", 8.5e5, 260_000.0, port).unwrap()); assert_eq!(src.n_equations(), 2); } // ── FlowSink ────────────────────────────────────────────────────────────── #[test] fn test_sink_incompressible_back_pressure_only() { // Return header: 1.5 bar, free enthalpy let port = make_port("Water", 1.5e5, 63_000.0); let s = FlowSink::incompressible("Water", 1.5e5, None, port).unwrap(); assert_eq!(s.n_equations(), 1); assert_eq!(s.fluid_kind(), FluidKind::Incompressible); } #[test] fn test_sink_with_fixed_return_enthalpy() { // Fixed return temperature: 12°C, h ≈ 50.4 kJ/kg let port = make_port("Water", 1.5e5, 50_400.0); let s = FlowSink::incompressible("Water", 1.5e5, Some(50_400.0), port).unwrap(); assert_eq!(s.n_equations(), 2); } #[test] fn test_sink_compressible_refrigerant() { // R410A low-side: 8.5 bar let port = make_port("R410A", 8.5e5, 260_000.0); let s = FlowSink::compressible("R410A", 8.5e5, None, port).unwrap(); assert_eq!(s.n_equations(), 1); assert_eq!(s.fluid_kind(), FluidKind::Compressible); } #[test] fn test_sink_rejects_refrigerant_as_incompressible() { let port = make_port("R410A", 8.5e5, 260_000.0); let result = FlowSink::incompressible("R410A", 8.5e5, None, port); assert!(result.is_err()); } #[test] fn test_sink_rejects_zero_back_pressure() { let port = make_port("Water", 1.5e5, 63_000.0); let result = FlowSink::incompressible("Water", 0.0, None, port); assert!(result.is_err()); } #[test] fn test_sink_residual_zero_at_back_pressure() { let p = 1.5e5_f64; let port = make_port("Water", p, 63_000.0); let s = FlowSink::incompressible("Water", p, None, port).unwrap(); let state = vec![0.0; 4]; let mut res = vec![0.0; 1]; s.compute_residuals(&state, &mut res).unwrap(); assert!(res[0].abs() < 1.0, "P residual = {}", res[0]); } #[test] fn test_sink_residual_with_enthalpy() { let p = 1.5e5_f64; let h = 50_400.0_f64; let port = make_port("Water", p, h); let s = FlowSink::incompressible("Water", p, Some(h), port).unwrap(); let state = vec![0.0; 4]; let mut res = vec![0.0; 2]; s.compute_residuals(&state, &mut res).unwrap(); assert!(res[0].abs() < 1.0, "P residual = {}", res[0]); assert!(res[1].abs() < 1.0, "h residual = {}", res[1]); } #[test] fn test_sink_dynamic_enthalpy_toggle() { let port = make_port("Water", 1.5e5, 63_000.0); let mut s = FlowSink::incompressible("Water", 1.5e5, None, port).unwrap(); assert_eq!(s.n_equations(), 1); s.set_return_enthalpy(50_400.0); assert_eq!(s.n_equations(), 2); s.clear_return_enthalpy(); assert_eq!(s.n_equations(), 1); } #[test] fn test_sink_as_trait_object() { let port = make_port("R410A", 8.5e5, 260_000.0); let sink: Box = Box::new(FlowSink::compressible("R410A", 8.5e5, Some(260_000.0), port).unwrap()); assert_eq!(sink.n_equations(), 2); } }