Entropyk/crates/components/src/flow_boundary.rs
2026-02-21 10:43:55 +01:00

573 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
p_set_pa: f64,
h_set_jkg: f64,
outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
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<String>,
p_set_pa: f64,
h_set_jkg: f64,
outlet: ConnectedPort,
) -> Result<Self, ComponentError> {
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<Self, ComponentError> {
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<f64>,
/// 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<String>,
p_back_pa: f64,
h_back_jkg: Option<f64>,
inlet: ConnectedPort,
) -> Result<Self, ComponentError> {
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<String>,
p_back_pa: f64,
h_back_jkg: Option<f64>,
inlet: ConnectedPort,
) -> Result<Self, ComponentError> {
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<f64>,
inlet: ConnectedPort,
) -> Result<Self, ComponentError> {
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<f64> { 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<dyn Component> =
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<dyn Component> =
Box::new(FlowSink::compressible("R410A", 8.5e5, Some(260_000.0), port).unwrap());
assert_eq!(sink.n_equations(), 2);
}
}