Fix bugs from 5-2 code review
This commit is contained in:
572
crates/components/src/flow_boundary.rs
Normal file
572
crates/components/src/flow_boundary.rs
Normal file
@@ -0,0 +1,572 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
826
crates/components/src/flow_junction.rs
Normal file
826
crates/components/src/flow_junction.rs
Normal file
@@ -0,0 +1,826 @@
|
||||
//! 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, SystemState,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FluidKind — tag distinguishing the two regimes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether this junction handles compressible or incompressible fluid.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FluidKind {
|
||||
/// Water, glycol, brine, seawater — density ≈ const.
|
||||
Incompressible,
|
||||
/// Refrigerant, CO₂, steam — density varies with P and T.
|
||||
Compressible,
|
||||
}
|
||||
|
||||
/// A set of known incompressible fluid identifiers (case-insensitive prefix match).
|
||||
pub(crate) fn is_incompressible(fluid: &str) -> bool {
|
||||
let f = fluid.to_lowercase();
|
||||
f.starts_with("water")
|
||||
|| f.starts_with("glycol")
|
||||
|| f.starts_with("brine")
|
||||
|| f.starts_with("seawater")
|
||||
|| f.starts_with("ethyleneglycol")
|
||||
|| f.starts_with("propyleneglycol")
|
||||
|| f.starts_with("incompressible")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FlowSplitter — 1 inlet → N outlets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A flow splitter that divides one inlet stream into N outlet branches.
|
||||
///
|
||||
/// # Equations (2N − 1)
|
||||
///
|
||||
/// | Equation | Residual |
|
||||
/// |----------|---------|
|
||||
/// | Mass balance | `ṁ_in − Σṁ_out = 0` |
|
||||
/// | Isobaric (k=1..N-1) | `P_out_k − P_in = 0` |
|
||||
/// | Isenthalpic (k=1..N-1) | `h_out_k − h_in = 0` |
|
||||
///
|
||||
/// ## Note on mass flow
|
||||
///
|
||||
/// The solver represents mass flow **implicitly** through pressure and enthalpy
|
||||
/// on each edge. For the splitter's mass balance residual, we use the
|
||||
/// simplified form that all outlet enthalpies equal the inlet enthalpy
|
||||
/// (isenthalpic split). The mass balance is therefore:
|
||||
///
|
||||
/// `r_mass = (P_in − P_out_N) + (h_in − h_out_N)` as a consistency check.
|
||||
///
|
||||
/// See module docs for details.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowSplitter {
|
||||
/// Fluid kind (compressible / incompressible).
|
||||
kind: FluidKind,
|
||||
/// Fluid identifier (e.g. "Water", "R410A").
|
||||
fluid_id: String,
|
||||
/// Inlet port (the single source).
|
||||
inlet: ConnectedPort,
|
||||
/// Outlet ports (N branches).
|
||||
outlets: Vec<ConnectedPort>,
|
||||
}
|
||||
|
||||
impl FlowSplitter {
|
||||
// ── Constructors ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates an **incompressible** splitter (water, glycol, brine…).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - `outlets` is empty
|
||||
/// - The fluid is known to be compressible
|
||||
/// - Fluids mismatch between inlet and outlets
|
||||
pub fn incompressible(
|
||||
fluid: impl Into<String>,
|
||||
inlet: ConnectedPort,
|
||||
outlets: Vec<ConnectedPort>,
|
||||
) -> Result<Self, ComponentError> {
|
||||
let fluid = fluid.into();
|
||||
if !is_incompressible(&fluid) {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"FlowSplitter::incompressible: '{}' does not appear to be an incompressible fluid. \
|
||||
Use FlowSplitter::compressible for refrigerants.",
|
||||
fluid
|
||||
)));
|
||||
}
|
||||
Self::new_inner(FluidKind::Incompressible, fluid, inlet, outlets)
|
||||
}
|
||||
|
||||
/// Creates a **compressible** splitter (R410A, CO₂, steam…).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `outlets` is empty.
|
||||
pub fn compressible(
|
||||
fluid: impl Into<String>,
|
||||
inlet: ConnectedPort,
|
||||
outlets: Vec<ConnectedPort>,
|
||||
) -> Result<Self, ComponentError> {
|
||||
let fluid = fluid.into();
|
||||
Self::new_inner(FluidKind::Compressible, fluid, inlet, outlets)
|
||||
}
|
||||
|
||||
fn new_inner(
|
||||
kind: FluidKind,
|
||||
fluid: String,
|
||||
inlet: ConnectedPort,
|
||||
outlets: Vec<ConnectedPort>,
|
||||
) -> Result<Self, ComponentError> {
|
||||
if outlets.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowSplitter requires at least one outlet".into(),
|
||||
));
|
||||
}
|
||||
if outlets.len() == 1 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowSplitter with 1 outlet is just a pipe — use a Pipe component instead".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { kind, fluid_id: fluid, inlet, outlets })
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Number of outlet branches.
|
||||
pub fn n_outlets(&self) -> usize {
|
||||
self.outlets.len()
|
||||
}
|
||||
|
||||
/// Fluid kind.
|
||||
pub fn fluid_kind(&self) -> FluidKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Fluid identifier.
|
||||
pub fn fluid_id(&self) -> &str {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Reference to the inlet port.
|
||||
pub fn inlet(&self) -> &ConnectedPort {
|
||||
&self.inlet
|
||||
}
|
||||
|
||||
/// Reference to the outlet ports.
|
||||
pub fn outlets(&self) -> &[ConnectedPort] {
|
||||
&self.outlets
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FlowSplitter {
|
||||
/// `2N − 1` equations:
|
||||
/// - `N−1` pressure equalities
|
||||
/// - `N−1` enthalpy equalities
|
||||
/// - `1` mass balance (represented as N-th outlet consistency)
|
||||
fn n_equations(&self) -> usize {
|
||||
let n = self.outlets.len();
|
||||
2 * n - 1
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
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: &SystemState,
|
||||
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.
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FlowMerger — N inlets → 1 outlet
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A flow merger that combines N inlet branches into one outlet stream.
|
||||
///
|
||||
/// # Equations (N + 1)
|
||||
///
|
||||
/// | Equation | Residual |
|
||||
/// |----------|---------|
|
||||
/// | Pressure equalisation (k=2..N) | `P_in_k − P_in_1 = 0` |
|
||||
/// | Mixing enthalpy | `h_out − (Σ h_in_k) / N = 0` (equal-weight mix) |
|
||||
/// | Mass balance | `P_out − P_in_1 = 0` |
|
||||
///
|
||||
/// ## Mixing enthalpy
|
||||
///
|
||||
/// When mass flow rates are not individually tracked, we use an equal-weight
|
||||
/// average for the outlet enthalpy. This is exact for equal-flow branches and
|
||||
/// approximate for unequal flows. When mass flows per branch are available
|
||||
/// (from a FluidBackend), use [`FlowMerger::with_mass_flows`] for accuracy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlowMerger {
|
||||
/// Fluid kind (compressible / incompressible).
|
||||
kind: FluidKind,
|
||||
/// Fluid identifier.
|
||||
fluid_id: String,
|
||||
/// Inlet ports (N branches).
|
||||
inlets: Vec<ConnectedPort>,
|
||||
/// Outlet port (the single destination).
|
||||
outlet: ConnectedPort,
|
||||
/// Optional mass flow weights per inlet (kg/s). If None, equal weighting.
|
||||
mass_flow_weights: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl FlowMerger {
|
||||
// ── Constructors ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates an **incompressible** merger (water, glycol, brine…).
|
||||
pub fn incompressible(
|
||||
fluid: impl Into<String>,
|
||||
inlets: Vec<ConnectedPort>,
|
||||
outlet: ConnectedPort,
|
||||
) -> Result<Self, ComponentError> {
|
||||
let fluid = fluid.into();
|
||||
if !is_incompressible(&fluid) {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"FlowMerger::incompressible: '{}' does not appear to be an incompressible fluid. \
|
||||
Use FlowMerger::compressible for refrigerants.",
|
||||
fluid
|
||||
)));
|
||||
}
|
||||
Self::new_inner(FluidKind::Incompressible, fluid, inlets, outlet)
|
||||
}
|
||||
|
||||
/// Creates a **compressible** merger (R410A, CO₂, steam…).
|
||||
pub fn compressible(
|
||||
fluid: impl Into<String>,
|
||||
inlets: Vec<ConnectedPort>,
|
||||
outlet: ConnectedPort,
|
||||
) -> Result<Self, ComponentError> {
|
||||
let fluid = fluid.into();
|
||||
Self::new_inner(FluidKind::Compressible, fluid, inlets, outlet)
|
||||
}
|
||||
|
||||
fn new_inner(
|
||||
kind: FluidKind,
|
||||
fluid: String,
|
||||
inlets: Vec<ConnectedPort>,
|
||||
outlet: ConnectedPort,
|
||||
) -> Result<Self, ComponentError> {
|
||||
if inlets.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowMerger requires at least one inlet".into(),
|
||||
));
|
||||
}
|
||||
if inlets.len() == 1 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowMerger with 1 inlet is just a pipe — use a Pipe component instead".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
fluid_id: fluid,
|
||||
inlets,
|
||||
outlet,
|
||||
mass_flow_weights: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assigns known mass flow rates per inlet for weighted enthalpy mixing.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `weights.len() != n_inlets`.
|
||||
pub fn with_mass_flows(mut self, weights: Vec<f64>) -> Result<Self, ComponentError> {
|
||||
if weights.len() != self.inlets.len() {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"FlowMerger::with_mass_flows: expected {} weights, got {}",
|
||||
self.inlets.len(),
|
||||
weights.len()
|
||||
)));
|
||||
}
|
||||
if weights.iter().any(|&w| w < 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowMerger::with_mass_flows: mass flow weights must be non-negative".into(),
|
||||
));
|
||||
}
|
||||
self.mass_flow_weights = Some(weights);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Number of inlet branches.
|
||||
pub fn n_inlets(&self) -> usize {
|
||||
self.inlets.len()
|
||||
}
|
||||
|
||||
/// Fluid kind.
|
||||
pub fn fluid_kind(&self) -> FluidKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Fluid identifier.
|
||||
pub fn fluid_id(&self) -> &str {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Reference to the inlet ports.
|
||||
pub fn inlets(&self) -> &[ConnectedPort] {
|
||||
&self.inlets
|
||||
}
|
||||
|
||||
/// Reference to the outlet port.
|
||||
pub fn outlet(&self) -> &ConnectedPort {
|
||||
&self.outlet
|
||||
}
|
||||
|
||||
// ── Mixing helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes the mixed outlet enthalpy (weighted or equal).
|
||||
fn mixed_enthalpy(&self) -> f64 {
|
||||
let n = self.inlets.len();
|
||||
match &self.mass_flow_weights {
|
||||
Some(weights) => {
|
||||
let total_flow: f64 = weights.iter().sum();
|
||||
if total_flow <= 0.0 {
|
||||
// Fall back to equal weighting
|
||||
self.inlets.iter().map(|p| p.enthalpy().to_joules_per_kg()).sum::<f64>()
|
||||
/ n as f64
|
||||
} else {
|
||||
self.inlets
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w)
|
||||
.sum::<f64>()
|
||||
/ total_flow
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Equal weighting
|
||||
self.inlets.iter().map(|p| p.enthalpy().to_joules_per_kg()).sum::<f64>()
|
||||
/ n as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FlowMerger {
|
||||
/// `N + 1` equations:
|
||||
/// - `N−1` pressure equalisations across inlets
|
||||
/// - `1` mixing enthalpy for the outlet
|
||||
/// - `1` outlet pressure consistency
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inlets.len() + 1
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
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: &SystemState,
|
||||
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] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Convenience type aliases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A flow splitter for incompressible fluids (water, glycol, brine…).
|
||||
///
|
||||
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::incompressible`].
|
||||
pub type IncompressibleSplitter = FlowSplitter;
|
||||
|
||||
/// A flow splitter for compressible fluids (refrigerant, CO₂, steam…).
|
||||
///
|
||||
/// Equivalent to `FlowSplitter` constructed via [`FlowSplitter::compressible`].
|
||||
pub type CompressibleSplitter = FlowSplitter;
|
||||
|
||||
/// A flow merger for incompressible fluids (water, glycol, brine…).
|
||||
///
|
||||
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::incompressible`].
|
||||
pub type IncompressibleMerger = FlowMerger;
|
||||
|
||||
/// A flow merger for compressible fluids (refrigerant, CO₂, steam…).
|
||||
///
|
||||
/// Equivalent to `FlowMerger` constructed via [`FlowMerger::compressible`].
|
||||
pub type CompressibleMerger = FlowMerger;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
fn make_port(fluid: &str, p_pa: f64, h_jkg: f64) -> ConnectedPort {
|
||||
let p1 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_jkg),
|
||||
);
|
||||
let p2 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_jkg),
|
||||
);
|
||||
p1.connect(p2).unwrap().0
|
||||
}
|
||||
|
||||
// ── FlowSplitter ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_splitter_incompressible_creation() {
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
assert_eq!(s.n_outlets(), 2);
|
||||
assert_eq!(s.fluid_kind(), FluidKind::Incompressible);
|
||||
// n_equations = 2*2 - 1 = 3
|
||||
assert_eq!(s.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_compressible_creation() {
|
||||
let inlet = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_a = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_b = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_c = make_port("R410A", 24.0e5, 4.65e5);
|
||||
|
||||
let s = FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b, out_c]).unwrap();
|
||||
assert_eq!(s.n_outlets(), 3);
|
||||
assert_eq!(s.fluid_kind(), FluidKind::Compressible);
|
||||
// n_equations = 2*3 - 1 = 5
|
||||
assert_eq!(s.n_equations(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_rejects_refrigerant_as_incompressible() {
|
||||
let inlet = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_a = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_b = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let result = FlowSplitter::incompressible("R410A", inlet, vec![out_a, out_b]);
|
||||
assert!(result.is_err(), "R410A should not be accepted as incompressible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_rejects_single_outlet() {
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out = make_port("Water", 3.0e5, 2.0e5);
|
||||
let result = FlowSplitter::incompressible("Water", inlet, vec![out]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_residuals_zero_at_consistent_state() {
|
||||
// Consistent state: all pressures and enthalpies equal
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
let state = vec![0.0; 6]; // dummy, not used by current impl
|
||||
let mut res = vec![0.0; s.n_equations()];
|
||||
s.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
for (i, &r) in res.iter().enumerate() {
|
||||
assert!(
|
||||
r.abs() < 1.0,
|
||||
"residual[{}] = {} should be ≈ 0 for consistent state",
|
||||
i, r
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_residuals_nonzero_on_pressure_mismatch() {
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_a = make_port("Water", 2.5e5, 2.0e5); // lower pressure!
|
||||
let out_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
let state = vec![0.0; 6];
|
||||
let mut res = vec![0.0; s.n_equations()];
|
||||
s.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
// r[0] = P_out_a - P_in = 2.5e5 - 3.0e5 = -0.5e5
|
||||
assert!((res[0] - (-0.5e5)).abs() < 1.0, "expected -0.5e5, got {}", res[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_three_branches_n_equations() {
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let outlets: Vec<_> = (0..3).map(|_| make_port("Water", 3.0e5, 2.0e5)).collect();
|
||||
let s = FlowSplitter::incompressible("Water", inlet, outlets).unwrap();
|
||||
// N=3 → 2*3-1 = 5
|
||||
assert_eq!(s.n_equations(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_water_type_aliases() {
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
// IncompressibleSplitter is a type alias for FlowSplitter
|
||||
let _s: IncompressibleSplitter =
|
||||
FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
}
|
||||
|
||||
// ── FlowMerger ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_merger_incompressible_creation() {
|
||||
let in_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let in_b = make_port("Water", 3.0e5, 2.4e5);
|
||||
let outlet = make_port("Water", 3.0e5, 2.2e5);
|
||||
|
||||
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
|
||||
assert_eq!(m.n_inlets(), 2);
|
||||
assert_eq!(m.fluid_kind(), FluidKind::Incompressible);
|
||||
// N=2 → N+1 = 3
|
||||
assert_eq!(m.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_compressible_creation() {
|
||||
let in_a = make_port("R134a", 8.0e5, 4.0e5);
|
||||
let in_b = make_port("R134a", 8.0e5, 4.2e5);
|
||||
let in_c = make_port("R134a", 8.0e5, 3.8e5);
|
||||
let outlet = make_port("R134a", 8.0e5, 4.0e5);
|
||||
|
||||
let m = FlowMerger::compressible("R134a", vec![in_a, in_b, in_c], outlet).unwrap();
|
||||
assert_eq!(m.n_inlets(), 3);
|
||||
assert_eq!(m.fluid_kind(), FluidKind::Compressible);
|
||||
// N=3 → N+1 = 4
|
||||
assert_eq!(m.n_equations(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_rejects_single_inlet() {
|
||||
let in_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let outlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let result = FlowMerger::incompressible("Water", vec![in_a], outlet);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_residuals_zero_at_consistent_state() {
|
||||
// Equal branches → mixed enthalpy = inlet enthalpy
|
||||
let h = 2.0e5_f64;
|
||||
let p = 3.0e5_f64;
|
||||
let in_a = make_port("Water", p, h);
|
||||
let in_b = make_port("Water", p, h);
|
||||
let outlet = make_port("Water", p, h); // h_mixed = (h+h)/2 = h
|
||||
|
||||
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
|
||||
let state = vec![0.0; 6];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
for (i, &r) in res.iter().enumerate() {
|
||||
assert!(r.abs() < 1.0, "residual[{}] = {} should be ≈ 0", i, r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_mixed_enthalpy_equal_branches() {
|
||||
let h_a = 2.0e5_f64;
|
||||
let h_b = 3.0e5_f64;
|
||||
let h_expected = (h_a + h_b) / 2.0; // equal-weight average
|
||||
let p = 3.0e5_f64;
|
||||
|
||||
let in_a = make_port("Water", p, h_a);
|
||||
let in_b = make_port("Water", p, h_b);
|
||||
let outlet = make_port("Water", p, h_expected);
|
||||
|
||||
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
|
||||
let state = vec![0.0; 6];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
// Last residual: h_out - h_mixed should be 0
|
||||
let last = res[m.n_equations() - 1];
|
||||
assert!(last.abs() < 1.0, "h mixing residual = {} should be 0", last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_weighted_enthalpy() {
|
||||
// ṁ_a = 0.3 kg/s, h_a = 2e5 J/kg
|
||||
// ṁ_b = 0.7 kg/s, h_b = 3e5 J/kg
|
||||
// h_mix = (0.3*2e5 + 0.7*3e5) / 1.0 = (6e4 + 21e4) = 2.7e5 J/kg
|
||||
let p = 3.0e5_f64;
|
||||
let in_a = make_port("Water", p, 2.0e5);
|
||||
let in_b = make_port("Water", p, 3.0e5);
|
||||
let outlet = make_port("Water", p, 2.7e5);
|
||||
|
||||
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
|
||||
.unwrap()
|
||||
.with_mass_flows(vec![0.3, 0.7])
|
||||
.unwrap();
|
||||
|
||||
let state = vec![0.0; 6];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
let h_residual = res[m.n_equations() - 1];
|
||||
assert!(
|
||||
h_residual.abs() < 1.0,
|
||||
"weighted h mixing residual = {} should be 0",
|
||||
h_residual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merger_as_trait_object() {
|
||||
let in_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let in_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
let outlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
let merger: Box<dyn Component> = Box::new(
|
||||
FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap()
|
||||
);
|
||||
assert_eq!(merger.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_splitter_as_trait_object() {
|
||||
let inlet = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_a = make_port("R410A", 24.0e5, 4.65e5);
|
||||
let out_b = make_port("R410A", 24.0e5, 4.65e5);
|
||||
|
||||
let splitter: Box<dyn Component> = Box::new(
|
||||
FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b]).unwrap()
|
||||
);
|
||||
assert_eq!(splitter.n_equations(), 3);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub mod compressor;
|
||||
pub mod expansion_valve;
|
||||
pub mod external_model;
|
||||
pub mod fan;
|
||||
pub mod flow_boundary;
|
||||
pub mod flow_junction;
|
||||
pub mod heat_exchanger;
|
||||
pub mod pipe;
|
||||
pub mod polynomials;
|
||||
@@ -84,6 +86,14 @@ pub use polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D, Polynomial2
|
||||
pub use port::{
|
||||
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId, Port,
|
||||
};
|
||||
pub use flow_boundary::{
|
||||
CompressibleSink, CompressibleSource, FlowSink, FlowSource,
|
||||
IncompressibleSink, IncompressibleSource,
|
||||
};
|
||||
pub use flow_junction::{
|
||||
CompressibleMerger, CompressibleSplitter, FlowMerger, FlowSplitter, FluidKind,
|
||||
IncompressibleMerger, IncompressibleSplitter,
|
||||
};
|
||||
pub use pump::{Pump, PumpCurves};
|
||||
pub use state_machine::{
|
||||
CircuitId, OperationalState, StateHistory, StateManageable, StateTransitionError,
|
||||
@@ -499,6 +509,39 @@ pub trait Component {
|
||||
/// assert!(component.get_ports().is_empty());
|
||||
/// ```
|
||||
fn get_ports(&self) -> &[ConnectedPort];
|
||||
|
||||
/// Injects system-level context into a component during topology finalization.
|
||||
///
|
||||
/// Called by [`System::finalize()`] after all edge state indices are computed.
|
||||
/// The default implementation is a no-op; override this in components that need
|
||||
/// to know their position in the global state vector (e.g. `MacroComponent`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state_offset` — The index in the global state vector where this component's
|
||||
/// *internal* state block begins. For ordinary leaf components this is never
|
||||
/// needed; for `MacroComponent` it replaces the manual `set_global_state_offset`
|
||||
/// call.
|
||||
/// * `external_edge_state_indices` — A slice of `(p_idx, h_idx)` pairs for every
|
||||
/// edge incident to this component's node in the parent graph (incoming and
|
||||
/// outgoing), in traversal order. `MacroComponent` uses these to emit
|
||||
/// port-coupling residuals.
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
_external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
// Default: no-op for all ordinary leaf components.
|
||||
}
|
||||
|
||||
/// Returns the number of internal state variables this component maintains.
|
||||
///
|
||||
/// The default implementation returns 0, which is correct for all ordinary
|
||||
/// leaf components. Hierarchical components (like `MacroComponent`) should
|
||||
/// override this to return the size of their internal state block.
|
||||
fn internal_state_len(&self) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user