Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -189,6 +189,19 @@ pub struct AirSource {
|
||||
h_set_jkg: f64,
|
||||
/// Connected outlet port
|
||||
outlet: ConnectedPort,
|
||||
/// Outlet edge (ṁ, P, h) state indices, wired by `set_system_context`.
|
||||
/// When present the residuals/Jacobian are state-driven (same semantics as
|
||||
/// [`BrineSource`](crate::BrineSource)); otherwise the legacy port-frozen
|
||||
/// behaviour is kept for standalone unit tests.
|
||||
outlet_m_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Optional imposed air mass flow [kg/s]: adds `r = ṁ_edge − ṁ_set`.
|
||||
m_flow_set_kg_s: Option<f64>,
|
||||
/// When true (default), impose `P_edge = P_set`.
|
||||
impose_pressure: bool,
|
||||
/// When true (default), impose `h_edge = h(T_dry, w)`.
|
||||
impose_temperature: bool,
|
||||
}
|
||||
|
||||
impl AirSource {
|
||||
@@ -227,6 +240,12 @@ impl AirSource {
|
||||
w,
|
||||
h_set_jkg,
|
||||
outlet,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
impose_pressure: true,
|
||||
impose_temperature: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,9 +291,68 @@ impl AirSource {
|
||||
w,
|
||||
h_set_jkg,
|
||||
outlet,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
impose_pressure: true,
|
||||
impose_temperature: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imposes the air loop mass flow at the boundary (+1 equation).
|
||||
///
|
||||
/// Do NOT combine with another flow imposition in the same branch
|
||||
/// (e.g. a `Fan` design flow) or the loop becomes over-determined.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ComponentError::InvalidState`] if the flow is not finite and positive.
|
||||
/// Modelica `MassFlowSource_T`: Fixed ṁ clears pressure imposition (Free P).
|
||||
pub fn with_imposed_mass_flow(mut self, m_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if !(m_flow_kg_s.is_finite() && m_flow_kg_s > 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSource: imposed mass flow must be positive".into(),
|
||||
));
|
||||
}
|
||||
self.m_flow_set_kg_s = Some(m_flow_kg_s);
|
||||
self.impose_pressure = false;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Clears an imposed mass flow (Free ṁ).
|
||||
pub fn clear_imposed_mass_flow(mut self) -> Self {
|
||||
self.m_flow_set_kg_s = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fixed (true, default) or Free pressure at the outlet edge.
|
||||
pub fn with_impose_pressure(mut self, impose: bool) -> Self {
|
||||
self.impose_pressure = impose;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fixed (true, default) or Free temperature/enthalpy at the outlet edge.
|
||||
pub fn with_impose_temperature(mut self, impose: bool) -> Self {
|
||||
self.impose_temperature = impose;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns whether pressure is Fixed.
|
||||
pub fn imposes_pressure(&self) -> bool {
|
||||
self.impose_pressure
|
||||
}
|
||||
|
||||
/// Returns whether temperature/enthalpy is Fixed.
|
||||
pub fn imposes_temperature(&self) -> bool {
|
||||
self.impose_temperature
|
||||
}
|
||||
|
||||
/// Returns the optional imposed mass flow [kg/s].
|
||||
pub fn m_flow_set(&self) -> Option<f64> {
|
||||
self.m_flow_set_kg_s
|
||||
}
|
||||
|
||||
/// Returns the dry-bulb temperature.
|
||||
pub fn t_dry(&self) -> Temperature {
|
||||
Temperature::from_kelvin(self.t_dry_k)
|
||||
@@ -342,22 +420,89 @@ impl AirSource {
|
||||
|
||||
impl Component for AirSource {
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
usize::from(self.impose_pressure)
|
||||
+ usize::from(self.impose_temperature)
|
||||
+ usize::from(self.m_flow_set_kg_s.is_some())
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||
let mut roles = Vec::with_capacity(self.n_equations());
|
||||
if self.impose_pressure {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "P" });
|
||||
}
|
||||
if self.impose_temperature {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "h" });
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" });
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// A source has one incident edge: its outlet (outgoing). Incident
|
||||
// lists put incoming edges first, so take the LAST entry.
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.last() {
|
||||
self.outlet_m_idx = Some(m);
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < 2 {
|
||||
let n = self.n_equations();
|
||||
if residuals.len() < n {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
expected: n,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
let mut row = 0;
|
||||
match (self.outlet_p_idx, self.outlet_h_idx) {
|
||||
(Some(p_idx), Some(h_idx)) if p_idx < state.len() && h_idx < state.len() => {
|
||||
if self.impose_pressure {
|
||||
residuals[row] = state[p_idx] - self.p_set_pa;
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
residuals[row] = state[h_idx] - self.h_set_jkg;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
if self.impose_pressure {
|
||||
residuals[row] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
residuals[row] =
|
||||
self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSource requires a live outlet edge state in solver context".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
let Some(m_idx) = self.outlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSource imposed mass flow requires a live outlet mass-flow index"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
residuals[row] = state[m_idx] - m_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -366,8 +511,29 @@ impl Component for AirSource {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
let (p_col, h_col) = match (self.outlet_p_idx, self.outlet_h_idx) {
|
||||
(Some(p), Some(h)) if p < _state.len() && h < _state.len() => (p, h),
|
||||
_ if _state.is_empty() => return Ok(()),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSource Jacobian requires live outlet pressure/enthalpy indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut row = 0;
|
||||
if self.impose_pressure {
|
||||
jacobian.add_entry(row, p_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
jacobian.add_entry(row, h_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
if let Some(m_col) = self.outlet_m_idx {
|
||||
jacobian.add_entry(row, m_col, 1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -430,6 +596,13 @@ pub struct AirSink {
|
||||
h_back_jkg: Option<f64>,
|
||||
/// Connected inlet port
|
||||
inlet: ConnectedPort,
|
||||
/// Inlet edge (ṁ, P, h) state indices, wired by `set_system_context`
|
||||
/// (state-driven, same semantics as [`BrineSink`](crate::BrineSink)).
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Optional imposed air mass flow [kg/s].
|
||||
m_flow_set_kg_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl AirSink {
|
||||
@@ -456,9 +629,36 @@ impl AirSink {
|
||||
rh_back: None,
|
||||
h_back_jkg: None,
|
||||
inlet,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imposes the air loop mass flow at the boundary (+1 equation).
|
||||
///
|
||||
/// Do NOT combine with another flow imposition in the same branch or the
|
||||
/// loop becomes over-determined.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ComponentError::InvalidState`] if the flow is not finite and positive.
|
||||
pub fn with_imposed_mass_flow(mut self, m_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if !(m_flow_kg_s.is_finite() && m_flow_kg_s > 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSink: imposed mass flow must be positive".into(),
|
||||
));
|
||||
}
|
||||
self.m_flow_set_kg_s = Some(m_flow_kg_s);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Returns the optional imposed mass flow [kg/s].
|
||||
pub fn m_flow_set(&self) -> Option<f64> {
|
||||
self.m_flow_set_kg_s
|
||||
}
|
||||
|
||||
/// Returns the back-pressure.
|
||||
pub fn p_back(&self) -> Pressure {
|
||||
Pressure::from_pascals(self.p_back_pa)
|
||||
@@ -521,16 +721,38 @@ impl AirSink {
|
||||
|
||||
impl Component for AirSink {
|
||||
fn n_equations(&self) -> usize {
|
||||
let base = if self.h_back_jkg.is_some() { 2 } else { 1 };
|
||||
base + usize::from(self.m_flow_set_kg_s.is_some())
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||
let mut roles = Vec::with_capacity(self.n_equations());
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "P" });
|
||||
if self.h_back_jkg.is_some() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "h" });
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" });
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// A sink has one incident edge: its inlet (incoming, listed first).
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
@@ -540,9 +762,36 @@ impl Component for AirSink {
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[1] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
let mut row = 1;
|
||||
match (self.inlet_p_idx, self.inlet_h_idx) {
|
||||
(Some(p_idx), Some(h_idx)) if p_idx < state.len() && h_idx < state.len() => {
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[row] = state[h_idx] - h_back;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[row] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSink requires a live inlet edge state in solver context".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
let Some(m_idx) = self.inlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSink imposed mass flow requires a live inlet mass-flow index".to_string(),
|
||||
));
|
||||
};
|
||||
let m = state[m_idx];
|
||||
residuals[row] = m - m_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -552,9 +801,25 @@ impl Component for AirSink {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
for i in 0..n {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
let (p_col, h_col) = match (self.inlet_p_idx, self.inlet_h_idx) {
|
||||
(Some(p), Some(h)) if p < _state.len() && h < _state.len() => (p, h),
|
||||
_ if _state.is_empty() => return Ok(()),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"AirSink Jacobian requires live inlet pressure/enthalpy indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
jacobian.add_entry(0, p_col, 1.0);
|
||||
let mut row = 1;
|
||||
if self.h_back_jkg.is_some() {
|
||||
jacobian.add_entry(row, h_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
if let Some(m_col) = self.inlet_m_idx {
|
||||
jacobian.add_entry(row, m_col, 1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -588,8 +853,8 @@ impl Component for AirSink {
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
let mut params = crate::ComponentParams::new("AirSink")
|
||||
.with_param("pBackPa", self.p_back_pa);
|
||||
let mut params =
|
||||
crate::ComponentParams::new("AirSink").with_param("pBackPa", self.p_back_pa);
|
||||
if let Some(t_k) = self.t_back_k {
|
||||
params = params.with_param("tBackK", t_k);
|
||||
}
|
||||
|
||||
604
crates/components/src/anchor.rs
Normal file
604
crates/components/src/anchor.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
//! Anchor — inline spec node (BOLT `BoundaryNode.Refrigerant.Node` equivalent).
|
||||
//!
|
||||
//! An `Anchor` is a transparent 2-port node placed on any refrigerant line.
|
||||
//! It always enforces pass-through continuity (2 equations):
|
||||
//!
|
||||
//! ```text
|
||||
//! r0: P_out − P_in = 0
|
||||
//! r1: h_out − h_in = 0
|
||||
//! ```
|
||||
//!
|
||||
//! and can additionally **impose one thermodynamic spec** at its location
|
||||
//! (+1 equation), exactly like BOLT's `x_fixed` / `dTsh_fixed` node options:
|
||||
//!
|
||||
//! | Constraint | Residual | BOLT equivalent |
|
||||
//! |---|---|---|
|
||||
//! | `Superheat(dTsh)` | `T(P,h) − T_sat(P) − dTsh` (dTsh < 0 ⇒ subcooling vs bubble point) | `dTsh_fixed=true, dTsh_set=…` |
|
||||
//! | `Quality(x)` | `h − h(P, x)` | `x_fixed=true, x_set=…` |
|
||||
//! | `Temperature(T)` | `T(P,h) − T_set` | `T_fixed=true` |
|
||||
//! | `Pressure(P)` | `P − P_set` | `p_fixed=true` |
|
||||
//!
|
||||
//! ## Degrees of freedom
|
||||
//!
|
||||
//! A constraint consumes one DoF: pair it with a freed quantity elsewhere
|
||||
//! (an emergent pressure, a free actuator, a boundary left free). An anchor
|
||||
//! with **no constraint is DoF-neutral** (its 2 equations close the 2 unknowns
|
||||
//! of the extra edge it introduces) and acts as a pure inline probe.
|
||||
//!
|
||||
//! ## Jacobian
|
||||
//!
|
||||
//! Continuity and pressure rows are exact units. Property-based rows
|
||||
//! (T, x, dTsh) use central finite differences of the backend property calls
|
||||
//! over the inlet (P, h) columns — the same technique as the solver's
|
||||
//! measurement rows.
|
||||
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, EquationRole, JacobianBuilder, MeasuredOutput,
|
||||
ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Optional thermodynamic spec imposed by an [`Anchor`] (+1 equation).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum AnchorConstraint {
|
||||
/// Signed superheat [K]: `T − T_dew = dTsh` when `dTsh ≥ 0`;
|
||||
/// `T − T_bubble = dTsh` when `dTsh < 0` (i.e. subcooling of `−dTsh`).
|
||||
Superheat(f64),
|
||||
/// Vapor quality `x ∈ [0, 1]`: `h = h(P, x)`.
|
||||
Quality(f64),
|
||||
/// Absolute temperature [K]: `T(P, h) = T_set`.
|
||||
Temperature(f64),
|
||||
/// Absolute pressure [Pa]: `P = P_set`.
|
||||
Pressure(f64),
|
||||
}
|
||||
|
||||
/// Inline spec node with pass-through continuity and one optional imposed
|
||||
/// thermodynamic constraint (see module docs).
|
||||
#[derive(Clone)]
|
||||
pub struct Anchor {
|
||||
name: String,
|
||||
refrigerant_id: String,
|
||||
backend: Option<Arc<dyn FluidBackend>>,
|
||||
constraint: Option<AnchorConstraint>,
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
circuit_id: CircuitId,
|
||||
operational_state: OperationalState,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Anchor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Anchor")
|
||||
.field("name", &self.name)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("constraint", &self.constraint)
|
||||
.field("has_backend", &self.backend.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Anchor {
|
||||
/// Creates a probe-mode anchor (continuity only, DoF-neutral).
|
||||
pub fn new(name: impl Into<String>, refrigerant: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
refrigerant_id: refrigerant.into(),
|
||||
backend: None,
|
||||
constraint: None,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Imposes a thermodynamic spec at this anchor (+1 equation; consumes one
|
||||
/// system DoF — pair with a freed quantity elsewhere).
|
||||
pub fn with_constraint(mut self, constraint: AnchorConstraint) -> Self {
|
||||
self.constraint = Some(constraint);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the fluid backend (required for T / x / dTsh constraints).
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Returns the active constraint, if any.
|
||||
pub fn constraint(&self) -> Option<AnchorConstraint> {
|
||||
self.constraint
|
||||
}
|
||||
|
||||
fn fluid(&self) -> FluidId {
|
||||
FluidId::new(&self.refrigerant_id)
|
||||
}
|
||||
|
||||
/// Temperature at (P, h) [K].
|
||||
fn temperature(&self, p: f64, h: f64) -> Result<f64, ComponentError> {
|
||||
let backend = self.backend.as_ref().ok_or_else(|| {
|
||||
ComponentError::InvalidState("Anchor: constraint requires a fluid backend".into())
|
||||
})?;
|
||||
backend
|
||||
.property(
|
||||
self.fluid(),
|
||||
Property::Temperature,
|
||||
FluidState::PressureEnthalpy(
|
||||
Pressure::from_pascals(p),
|
||||
Enthalpy::from_joules_per_kg(h),
|
||||
),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("Anchor T(P,h): {e}")))
|
||||
}
|
||||
|
||||
/// Saturation temperature at P for the given quality reference [K].
|
||||
fn saturation_temperature(&self, p: f64, x: f64) -> Result<f64, ComponentError> {
|
||||
let backend = self.backend.as_ref().ok_or_else(|| {
|
||||
ComponentError::InvalidState("Anchor: constraint requires a fluid backend".into())
|
||||
})?;
|
||||
backend
|
||||
.property(
|
||||
self.fluid(),
|
||||
Property::Temperature,
|
||||
FluidState::PressureQuality(Pressure::from_pascals(p), Quality(x)),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("Anchor T_sat(P): {e}")))
|
||||
}
|
||||
|
||||
/// Saturation enthalpy at (P, x) [J/kg].
|
||||
fn saturation_enthalpy(&self, p: f64, x: f64) -> Result<f64, ComponentError> {
|
||||
let backend = self.backend.as_ref().ok_or_else(|| {
|
||||
ComponentError::InvalidState("Anchor: constraint requires a fluid backend".into())
|
||||
})?;
|
||||
backend
|
||||
.property(
|
||||
self.fluid(),
|
||||
Property::Enthalpy,
|
||||
FluidState::PressureQuality(Pressure::from_pascals(p), Quality(x)),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("Anchor h_sat(P,x): {e}")))
|
||||
}
|
||||
|
||||
/// Constraint residual value at (P_in, h_in).
|
||||
fn constraint_residual(&self, p: f64, h: f64) -> Result<f64, ComponentError> {
|
||||
match self.constraint {
|
||||
None => Ok(0.0),
|
||||
Some(AnchorConstraint::Pressure(p_set)) => Ok(p - p_set),
|
||||
Some(AnchorConstraint::Temperature(t_set)) => Ok(self.temperature(p, h)? - t_set),
|
||||
Some(AnchorConstraint::Quality(x_set)) => Ok(h - self.saturation_enthalpy(p, x_set)?),
|
||||
Some(AnchorConstraint::Superheat(dtsh)) => {
|
||||
// dTsh ≥ 0 → measure against the dew point (superheat);
|
||||
// dTsh < 0 → against the bubble point (subcooling = −dTsh).
|
||||
let x_ref = if dtsh >= 0.0 { 1.0 } else { 0.0 };
|
||||
let t = self.temperature(p, h)?;
|
||||
let t_sat = self.saturation_temperature(p, x_ref)?;
|
||||
Ok(t - t_sat - dtsh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wired(&self) -> Option<(usize, usize, usize, usize)> {
|
||||
Some((
|
||||
self.inlet_p_idx?,
|
||||
self.inlet_h_idx?,
|
||||
self.outlet_p_idx?,
|
||||
self.outlet_h_idx?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Anchor {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// [0] = incoming edge (inlet), [1] = outgoing edge (outlet).
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
let (m, p, h) = external_edge_state_indices[0];
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
let (_, p, h) = external_edge_state_indices[1];
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2 + usize::from(self.constraint.is_some())
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<EquationRole> {
|
||||
let mut roles = vec![
|
||||
EquationRole::Continuity { quantity: "P" },
|
||||
EquationRole::Continuity { quantity: "h" },
|
||||
];
|
||||
if let Some(c) = self.constraint {
|
||||
let kind = match c {
|
||||
AnchorConstraint::Superheat(_) => "superheat",
|
||||
AnchorConstraint::Quality(_) => "quality",
|
||||
AnchorConstraint::Temperature(_) => "temperature",
|
||||
AnchorConstraint::Pressure(_) => "pressure",
|
||||
};
|
||||
// Spec residual consumes one DoF — pair with a free unknown elsewhere.
|
||||
roles.push(EquationRole::OutletClosure { kind });
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
if residuals.len() < n {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: n,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let Some((in_p, in_h, out_p, out_h)) = self.wired() else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Anchor requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
};
|
||||
let p_in = state[in_p];
|
||||
let h_in = state[in_h];
|
||||
residuals[0] = state[out_p] - p_in;
|
||||
residuals[1] = state[out_h] - h_in;
|
||||
if self.constraint.is_some() {
|
||||
residuals[2] = self.constraint_residual(p_in, h_in)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let Some((in_p, in_h, out_p, out_h)) = self.wired() else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Anchor Jacobian requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
};
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
|
||||
match self.constraint {
|
||||
None => {}
|
||||
Some(AnchorConstraint::Pressure(_)) => {
|
||||
jacobian.add_entry(2, in_p, 1.0);
|
||||
}
|
||||
Some(_) => {
|
||||
// Property-based rows: central finite differences over (P, h),
|
||||
// same technique as the solver's measurement Jacobian rows.
|
||||
let p = state[in_p];
|
||||
let h = state[in_h];
|
||||
let eps_p = p.abs() * 1e-6 + 1e-2;
|
||||
let eps_h = h.abs() * 1e-6 + 1e-2;
|
||||
|
||||
let rp_plus = self.constraint_residual(p + eps_p, h)?;
|
||||
let rp_minus = self.constraint_residual(p - eps_p, h)?;
|
||||
let dp = (rp_plus - rp_minus) / (2.0 * eps_p);
|
||||
if dp.abs() > 1e-14 {
|
||||
jacobian.add_entry(2, in_p, dp);
|
||||
}
|
||||
|
||||
let rh_plus = self.constraint_residual(p, h + eps_h)?;
|
||||
let rh_minus = self.constraint_residual(p, h - eps_h)?;
|
||||
let dh = (rh_plus - rh_minus) / (2.0 * eps_h);
|
||||
if dh.abs() > 1e-14 {
|
||||
jacobian.add_entry(2, in_h, dh);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
let Some(m_idx) = self.inlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Anchor mass-flow reporting requires a live inlet mass-flow index".to_string(),
|
||||
));
|
||||
};
|
||||
let m = state[m_idx];
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(m),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m),
|
||||
])
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"Anchor: not wired to system context".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(vec![
|
||||
Enthalpy::from_joules_per_kg(state[in_h]),
|
||||
Enthalpy::from_joules_per_kg(state[out_h]),
|
||||
])
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
Some((
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
let (in_p, in_h, _, _) = self.wired()?;
|
||||
if in_p >= state.len() || in_h >= state.len() {
|
||||
return None;
|
||||
}
|
||||
let p = state[in_p];
|
||||
let h = state[in_h];
|
||||
match kind {
|
||||
MeasuredOutput::Pressure => Some(p),
|
||||
MeasuredOutput::Temperature => self.temperature(p, h).ok().filter(|t| t.is_finite()),
|
||||
MeasuredOutput::SaturationTemperature => self
|
||||
.saturation_temperature(p, 1.0)
|
||||
.ok()
|
||||
.filter(|t| t.is_finite()),
|
||||
MeasuredOutput::Superheat => {
|
||||
let t = self.temperature(p, h).ok()?;
|
||||
let t_dew = self.saturation_temperature(p, 1.0).ok()?;
|
||||
let sh = t - t_dew;
|
||||
sh.is_finite().then_some(sh)
|
||||
}
|
||||
MeasuredOutput::Subcooling => {
|
||||
let t = self.temperature(p, h).ok()?;
|
||||
let t_bub = self.saturation_temperature(p, 0.0).ok()?;
|
||||
let sc = t_bub - t;
|
||||
sc.is_finite().then_some(sc)
|
||||
}
|
||||
MeasuredOutput::MassFlowRate => {
|
||||
let m_idx = self.inlet_m_idx?;
|
||||
(m_idx < state.len()).then(|| state[m_idx])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
if self.backend.is_none() {
|
||||
self.backend = Some(backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"Anchor(name={}, fluid={}, constraint={:?})",
|
||||
self.name, self.refrigerant_id, self.constraint
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
let mut params = crate::ComponentParams::new("Anchor")
|
||||
.with_param("name", self.name.as_str())
|
||||
.with_param("fluid", self.refrigerant_id.as_str());
|
||||
match self.constraint {
|
||||
Some(AnchorConstraint::Superheat(v)) => params = params.with_param("superheatK", v),
|
||||
Some(AnchorConstraint::Quality(v)) => params = params.with_param("quality", v),
|
||||
Some(AnchorConstraint::Temperature(v)) => params = params.with_param("tK", v),
|
||||
Some(AnchorConstraint::Pressure(v)) => params = params.with_param("pPa", v),
|
||||
None => {}
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Anchor {
|
||||
fn state(&self) -> OperationalState {
|
||||
self.operational_state
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
if self.operational_state.can_transition_to(state) {
|
||||
self.operational_state = state;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ComponentError::InvalidStateTransition {
|
||||
from: self.operational_state,
|
||||
to: state,
|
||||
reason: "Transition not allowed".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||
self.operational_state.can_transition_to(target)
|
||||
}
|
||||
|
||||
fn circuit_id(&self) -> &CircuitId {
|
||||
&self.circuit_id
|
||||
}
|
||||
|
||||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||
self.circuit_id = circuit_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use entropyk_fluids::TestBackend;
|
||||
|
||||
/// Wired anchor over inlet edge (m=0, p=1, h=2) and outlet edge (p=3, h=4).
|
||||
fn wired(constraint: Option<AnchorConstraint>) -> Anchor {
|
||||
let mut a = Anchor::new("a", "R134a").with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
if let Some(c) = constraint {
|
||||
a = a.with_constraint(c);
|
||||
}
|
||||
a.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_probe_mode_is_dof_neutral_continuity() {
|
||||
let a = wired(None);
|
||||
assert_eq!(a.n_equations(), 2);
|
||||
// Continuity satisfied ⇒ zero residuals.
|
||||
let state = vec![0.1, 1.0e6, 400_000.0, 1.0e6, 400_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
a.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[0].abs() < 1e-12 && r[1].abs() < 1e-12);
|
||||
// Discontinuity detected.
|
||||
let state2 = vec![0.1, 1.0e6, 400_000.0, 1.1e6, 410_000.0];
|
||||
a.compute_residuals(&state2, &mut r).unwrap();
|
||||
assert!((r[0] - 1.0e5).abs() < 1e-9 && (r[1] - 10_000.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pressure_constraint_exact() {
|
||||
let a = wired(Some(AnchorConstraint::Pressure(9.0e5)));
|
||||
assert_eq!(a.n_equations(), 3);
|
||||
let state = vec![0.1, 9.0e5, 400_000.0, 9.0e5, 400_000.0];
|
||||
let mut r = vec![0.0; 3];
|
||||
a.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[2].abs() < 1e-12);
|
||||
let state2 = vec![0.1, 9.5e5, 400_000.0, 9.5e5, 400_000.0];
|
||||
a.compute_residuals(&state2, &mut r).unwrap();
|
||||
assert!((r[2] - 5.0e4).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quality_constraint_zero_at_saturation() {
|
||||
let backend = TestBackend::new();
|
||||
let p = 1.0e6;
|
||||
let h_sat = backend
|
||||
.property(
|
||||
FluidId::new("R134a"),
|
||||
Property::Enthalpy,
|
||||
FluidState::PressureQuality(Pressure::from_pascals(p), Quality(1.0)),
|
||||
)
|
||||
.unwrap();
|
||||
let a = wired(Some(AnchorConstraint::Quality(1.0)));
|
||||
let state = vec![0.1, p, h_sat, p, h_sat];
|
||||
let mut r = vec![0.0; 3];
|
||||
a.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[2].abs() < 1e-9, "r2 = {}", r[2]);
|
||||
// 10 kJ/kg above saturation → residual = +10 kJ/kg.
|
||||
let state2 = vec![0.1, p, h_sat + 10_000.0, p, h_sat + 10_000.0];
|
||||
a.compute_residuals(&state2, &mut r).unwrap();
|
||||
assert!((r[2] - 10_000.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_superheat_constraint_semantics() {
|
||||
let backend = TestBackend::new();
|
||||
let p = 1.0e6;
|
||||
let fluid = FluidId::new("R134a");
|
||||
let t_dew = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::PressureQuality(Pressure::from_pascals(p), Quality(1.0)),
|
||||
)
|
||||
.unwrap();
|
||||
// Find h giving T = t_dew + 5 via the backend itself.
|
||||
let h_sh5 = {
|
||||
// TestBackend is monotone in h; bisect for T(P,h) = t_dew + 5.
|
||||
let t_target = t_dew + 5.0;
|
||||
let (mut lo, mut hi) = (200_000.0, 600_000.0);
|
||||
for _ in 0..80 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let t = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::PressureEnthalpy(
|
||||
Pressure::from_pascals(p),
|
||||
Enthalpy::from_joules_per_kg(mid),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
if t < t_target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
let a = wired(Some(AnchorConstraint::Superheat(5.0)));
|
||||
let state = vec![0.1, p, h_sh5, p, h_sh5];
|
||||
let mut r = vec![0.0; 3];
|
||||
a.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[2].abs() < 1e-3, "SH=5 spec must close: r2 = {}", r[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constraint_jacobian_matches_fd_of_residual() {
|
||||
let a = wired(Some(AnchorConstraint::Quality(1.0)));
|
||||
let state = vec![0.1, 1.0e6, 410_000.0, 1.0e6, 410_000.0];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
a.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
for col in [1usize, 2] {
|
||||
let eps = state[col].abs() * 1e-5 + 1e-3;
|
||||
let mut sp = state.clone();
|
||||
let mut sm = state.clone();
|
||||
sp[col] += eps;
|
||||
sm[col] -= eps;
|
||||
let (mut rp, mut rm) = (vec![0.0; 3], vec![0.0; 3]);
|
||||
a.compute_residuals(&sp, &mut rp).unwrap();
|
||||
a.compute_residuals(&sm, &mut rm).unwrap();
|
||||
let fd = (rp[2] - rm[2]) / (2.0 * eps);
|
||||
let analytic: f64 = jac
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|(r, c, _)| *r == 2 && *c == col)
|
||||
.map(|(_, _, v)| *v)
|
||||
.sum();
|
||||
assert!(
|
||||
(fd - analytic).abs() < 1e-4 * (1.0 + fd.abs()),
|
||||
"col {col}: fd {fd} vs analytic {analytic}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constraint_without_backend_errors() {
|
||||
let mut a = Anchor::new("a", "R134a").with_constraint(AnchorConstraint::Temperature(300.0));
|
||||
a.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
let state = vec![0.1, 1.0e6, 400_000.0, 1.0e6, 400_000.0];
|
||||
let mut r = vec![0.0; 3];
|
||||
assert!(a.compute_residuals(&state, &mut r).is_err());
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@
|
||||
//!
|
||||
//! ## Equations
|
||||
//!
|
||||
//! **BrineSource** (always 2):
|
||||
//! **BrineSource** (Modelica-style Fixed/Free, dynamic equation count):
|
||||
//! ```text
|
||||
//! r₀ = P_edge − P_set = 0
|
||||
//! r₁ = h_edge − h(P_set, T_set, c) = 0
|
||||
//! r = P_edge − P_set = 0 (when pressure is Fixed)
|
||||
//! r = h_edge − h(P_set, T_set, c) = 0 (when temperature is Fixed)
|
||||
//! r = ṁ_edge − ṁ_set = 0 (when mass flow is Fixed)
|
||||
//! ```
|
||||
//! Defaults match `Boundary_pT` + optional flow: Fixed P, Fixed T, optional Fixed ṁ.
|
||||
//! Free ṁ + Fixed sink T_out is the usual ΔT-rating pattern (ṁ emerges from energy).
|
||||
//!
|
||||
//! **BrineSink** (1 or 2 depending on whether temperature is set):
|
||||
//! ```text
|
||||
@@ -94,12 +97,11 @@ fn pt_concentration_to_enthalpy(
|
||||
})
|
||||
}
|
||||
|
||||
/// A boundary source that imposes fixed pressure, temperature and glycol concentration
|
||||
/// on its outlet edge.
|
||||
/// A boundary source that imposes pressure, temperature and glycol concentration
|
||||
/// on its outlet edge (each quantity optionally Fixed, Modelica-style).
|
||||
///
|
||||
/// Contributes **2 equations** to the system:
|
||||
/// - `r₀ = P_edge − P_set = 0`
|
||||
/// - `r₁ = h_edge − h(P_set, T_set, c) = 0`
|
||||
/// Default equation count is **2** (Fixed P + Fixed T/h). Optional Fixed ṁ adds a
|
||||
/// third equation. Freeing P or T drops the corresponding residual.
|
||||
///
|
||||
/// Only accepts incompressible fluids (MEG, PEG, Water, Glycol, Brine).
|
||||
/// Use [`RefrigerantSource`](crate::RefrigerantSource) for refrigerant circuits.
|
||||
@@ -111,6 +113,21 @@ pub struct BrineSource {
|
||||
h_set_jkg: f64,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
outlet: ConnectedPort,
|
||||
/// Outlet edge (P, h) state indices, wired by `set_system_context`.
|
||||
/// When present the residuals/Jacobian are state-driven (BOLT
|
||||
/// `BoundaryNode.Coolant.Source` semantics); otherwise the legacy
|
||||
/// port-frozen behaviour is kept for standalone unit tests.
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Outlet edge ṁ state index (for the optional flow constraint).
|
||||
outlet_m_idx: Option<usize>,
|
||||
/// Optional imposed mass flow [kg/s] (BOLT `Vd_fixed=true` equivalent):
|
||||
/// adds `r = ṁ_edge − ṁ_set` (+1 equation).
|
||||
m_flow_set_kg_s: Option<f64>,
|
||||
/// When true (default), impose `P_edge = P_set`.
|
||||
impose_pressure: bool,
|
||||
/// When true (default), impose `h_edge = h(P_set, T_set, c)`.
|
||||
impose_temperature: bool,
|
||||
}
|
||||
|
||||
impl BrineSource {
|
||||
@@ -166,9 +183,70 @@ impl BrineSource {
|
||||
h_set_jkg: h_set.to_joules_per_kg(),
|
||||
backend,
|
||||
outlet,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
impose_pressure: true,
|
||||
impose_temperature: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imposes the loop mass flow at the boundary (+1 equation,
|
||||
/// Modelica `MassFlowSource_T` / BOLT `Vd_fixed=true`).
|
||||
///
|
||||
/// Also clears pressure imposition (Free P): a MassFlowSource must not
|
||||
/// prescribe both ṁ and P. Pair with a sink `Boundary_pT` and an isobaric
|
||||
/// (or frictional) secondary path on the HX. Call
|
||||
/// [`with_impose_pressure`](Self::with_impose_pressure) afterward only for
|
||||
/// an explicit non-Modelica override.
|
||||
///
|
||||
/// Do NOT combine with another flow imposition in the same branch (e.g. a
|
||||
/// `ThermalLoad`/`Pump` design flow) or the loop becomes over-determined.
|
||||
pub fn with_imposed_mass_flow(mut self, m_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if !(m_flow_kg_s.is_finite() && m_flow_kg_s > 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSource: imposed mass flow must be positive".into(),
|
||||
));
|
||||
}
|
||||
self.m_flow_set_kg_s = Some(m_flow_kg_s);
|
||||
self.impose_pressure = false;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Clears an imposed mass flow (Free ṁ — typical for ΔT rating).
|
||||
pub fn clear_imposed_mass_flow(mut self) -> Self {
|
||||
self.m_flow_set_kg_s = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fixed (true, default) or Free pressure at the outlet edge.
|
||||
pub fn with_impose_pressure(mut self, impose: bool) -> Self {
|
||||
self.impose_pressure = impose;
|
||||
self
|
||||
}
|
||||
|
||||
/// Fixed (true, default) or Free temperature/enthalpy at the outlet edge.
|
||||
pub fn with_impose_temperature(mut self, impose: bool) -> Self {
|
||||
self.impose_temperature = impose;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns whether pressure is Fixed.
|
||||
pub fn imposes_pressure(&self) -> bool {
|
||||
self.impose_pressure
|
||||
}
|
||||
|
||||
/// Returns whether temperature/enthalpy is Fixed.
|
||||
pub fn imposes_temperature(&self) -> bool {
|
||||
self.impose_temperature
|
||||
}
|
||||
|
||||
/// Returns the optional imposed mass flow [kg/s].
|
||||
pub fn m_flow_set(&self) -> Option<f64> {
|
||||
self.m_flow_set_kg_s
|
||||
}
|
||||
|
||||
/// Returns the fluid identifier string (e.g. `"MEG"`, `"Water"`).
|
||||
pub fn fluid_id(&self) -> &str {
|
||||
&self.fluid_id
|
||||
@@ -254,22 +332,89 @@ impl BrineSource {
|
||||
|
||||
impl Component for BrineSource {
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
usize::from(self.impose_pressure)
|
||||
+ usize::from(self.impose_temperature)
|
||||
+ usize::from(self.m_flow_set_kg_s.is_some())
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||
let mut roles = Vec::with_capacity(self.n_equations());
|
||||
if self.impose_pressure {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "P" });
|
||||
}
|
||||
if self.impose_temperature {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "h" });
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" });
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// A source has one incident edge: its outlet (outgoing). Incident
|
||||
// lists put incoming edges first, so take the LAST entry.
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.last() {
|
||||
self.outlet_m_idx = Some(m);
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < 2 {
|
||||
let n = self.n_equations();
|
||||
if residuals.len() < n {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
expected: n,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
let mut row = 0;
|
||||
match (self.outlet_p_idx, self.outlet_h_idx) {
|
||||
(Some(p_idx), Some(h_idx)) if p_idx < state.len() && h_idx < state.len() => {
|
||||
if self.impose_pressure {
|
||||
residuals[row] = state[p_idx] - self.p_set_pa;
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
residuals[row] = state[h_idx] - self.h_set_jkg;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
if self.impose_pressure {
|
||||
residuals[row] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
residuals[row] =
|
||||
self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSource requires a live outlet edge state in solver context".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
let Some(m_idx) = self.outlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSource imposed mass flow requires a live outlet mass-flow index"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
residuals[row] = state[m_idx] - m_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -278,8 +423,31 @@ impl Component for BrineSource {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
// Exact unit entries on the live edge columns for each Fixed quantity.
|
||||
let (p_col, h_col) = match (self.outlet_p_idx, self.outlet_h_idx) {
|
||||
(Some(p), Some(h)) if p < _state.len() && h < _state.len() => (p, h),
|
||||
_ if _state.is_empty() => return Ok(()),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSource Jacobian requires live outlet pressure/enthalpy indices"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut row = 0;
|
||||
if self.impose_pressure {
|
||||
jacobian.add_entry(row, p_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.impose_temperature {
|
||||
jacobian.add_entry(row, h_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
if let Some(m_col) = self.outlet_m_idx {
|
||||
jacobian.add_entry(row, m_col, 1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -339,6 +507,14 @@ pub struct BrineSink {
|
||||
h_back_jkg: Option<f64>,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
inlet: ConnectedPort,
|
||||
/// Inlet edge (P, h) state indices, wired by `set_system_context`
|
||||
/// (state-driven BOLT `BoundaryNode.Coolant.Sink` semantics).
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Inlet edge ṁ state index (for the optional flow constraint).
|
||||
inlet_m_idx: Option<usize>,
|
||||
/// Optional imposed mass flow [kg/s] (BOLT `Vd_fixed=true` equivalent).
|
||||
m_flow_set_kg_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl BrineSink {
|
||||
@@ -405,9 +581,31 @@ impl BrineSink {
|
||||
h_back_jkg,
|
||||
backend,
|
||||
inlet,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
inlet_m_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imposes the loop mass flow at the boundary (+1 equation,
|
||||
/// BOLT `Vd_fixed=true` equivalent). Do NOT combine with another flow
|
||||
/// imposition in the same branch or the loop becomes over-determined.
|
||||
pub fn with_imposed_mass_flow(mut self, m_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if !(m_flow_kg_s.is_finite() && m_flow_kg_s > 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSink: imposed mass flow must be positive".into(),
|
||||
));
|
||||
}
|
||||
self.m_flow_set_kg_s = Some(m_flow_kg_s);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Returns the optional imposed mass flow [kg/s].
|
||||
pub fn m_flow_set(&self) -> Option<f64> {
|
||||
self.m_flow_set_kg_s
|
||||
}
|
||||
|
||||
/// Returns the fluid identifier string (e.g. `"MEG"`, `"Water"`).
|
||||
pub fn fluid_id(&self) -> &str {
|
||||
&self.fluid_id
|
||||
@@ -504,16 +702,38 @@ impl BrineSink {
|
||||
|
||||
impl Component for BrineSink {
|
||||
fn n_equations(&self) -> usize {
|
||||
let base = if self.h_back_jkg.is_some() { 2 } else { 1 };
|
||||
base + usize::from(self.m_flow_set_kg_s.is_some())
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||
let mut roles = vec![crate::EquationRole::BoundaryDirichlet { quantity: "P" }];
|
||||
if self.h_back_jkg.is_some() {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
// Optional T/h fix — consumes DoF (do not also free h).
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "h" });
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" });
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// A sink has one incident edge: its inlet (incoming, listed first).
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
@@ -523,9 +743,36 @@ impl Component for BrineSink {
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[1] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
let mut row = 1;
|
||||
match (self.inlet_p_idx, self.inlet_h_idx) {
|
||||
(Some(p_idx), Some(h_idx)) if p_idx < state.len() && h_idx < state.len() => {
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[row] = state[h_idx] - h_back;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[row] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSink requires a live inlet edge state in solver context".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
let Some(m_idx) = self.inlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSink imposed mass flow requires a live inlet mass-flow index".to_string(),
|
||||
));
|
||||
};
|
||||
let m = state[m_idx];
|
||||
residuals[row] = m - m_set;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -535,9 +782,25 @@ impl Component for BrineSink {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
for i in 0..n {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
let (p_col, h_col) = match (self.inlet_p_idx, self.inlet_h_idx) {
|
||||
(Some(p), Some(h)) if p < _state.len() && h < _state.len() => (p, h),
|
||||
_ if _state.is_empty() => return Ok(()),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BrineSink Jacobian requires live inlet pressure/enthalpy indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
jacobian.add_entry(0, p_col, 1.0);
|
||||
let mut row = 1;
|
||||
if self.h_back_jkg.is_some() {
|
||||
jacobian.add_entry(row, h_col, 1.0);
|
||||
row += 1;
|
||||
}
|
||||
if self.m_flow_set_kg_s.is_some() {
|
||||
if let Some(m_col) = self.inlet_m_idx {
|
||||
jacobian.add_entry(row, m_col, 1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -591,6 +854,8 @@ impl Component for BrineSink {
|
||||
self.backend = backend;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::{FluidId, Port};
|
||||
@@ -766,6 +1031,137 @@ mod tests {
|
||||
assert_eq!(sink.n_equations(), 1);
|
||||
}
|
||||
|
||||
/// Modelica MassFlowSource_T: Fixed ṁ + T, Free P (not both ṁ and P).
|
||||
#[test]
|
||||
fn test_brine_source_imposed_mass_flow() {
|
||||
let backend = Arc::new(MockBrineBackend::new());
|
||||
let port = make_port("Glycol", 2.0e5, 60_000.0);
|
||||
let mut source = BrineSource::new(
|
||||
"Glycol",
|
||||
Pressure::from_pascals(2.0e5),
|
||||
Temperature::from_celsius(15.0),
|
||||
Concentration::from_percent(30.0),
|
||||
backend,
|
||||
port,
|
||||
)
|
||||
.unwrap()
|
||||
.with_imposed_mass_flow(0.8)
|
||||
.unwrap();
|
||||
assert!(!source.imposes_pressure());
|
||||
assert_eq!(source.n_equations(), 2); // h + m
|
||||
|
||||
// Wire the outlet edge at (m=0, p=1, h=2).
|
||||
source.set_system_context(0, &[(0, 1, 2)]);
|
||||
let h_set = 3500.0 * 15.0;
|
||||
let state = vec![0.8, 2.0e5, h_set];
|
||||
let mut r = vec![0.0; 2];
|
||||
source.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[1].abs() < 1e-12, "flow residual at set-point: {}", r[1]);
|
||||
|
||||
let state2 = vec![0.5, 2.0e5, h_set];
|
||||
source.compute_residuals(&state2, &mut r).unwrap();
|
||||
assert!((r[1] + 0.3).abs() < 1e-12, "flow residual: {}", r[1]);
|
||||
|
||||
// Jacobian: unit entry on the ṁ column (row 1 after h).
|
||||
let mut jac = JacobianBuilder::new();
|
||||
source.jacobian_entries(&state, &mut jac).unwrap();
|
||||
assert!(jac
|
||||
.entries()
|
||||
.iter()
|
||||
.any(|&(row, col, v)| row == 1 && col == 0 && (v - 1.0).abs() < 1e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brine_boundary_rejects_invalid_imposed_flow() {
|
||||
let make_source = || {
|
||||
BrineSource::new(
|
||||
"Glycol",
|
||||
Pressure::from_pascals(2.0e5),
|
||||
Temperature::from_celsius(15.0),
|
||||
Concentration::from_percent(30.0),
|
||||
Arc::new(MockBrineBackend::new()),
|
||||
make_port("Glycol", 2.0e5, 60_000.0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(make_source().with_imposed_mass_flow(0.0).is_err());
|
||||
assert!(make_source().with_imposed_mass_flow(f64::NAN).is_err());
|
||||
}
|
||||
|
||||
/// Modelica Boundary_pT: Fixed P+T, Free ṁ (ΔT rating via sink T_out).
|
||||
#[test]
|
||||
fn test_brine_source_free_mass_flow_and_pressure() {
|
||||
let backend = Arc::new(MockBrineBackend::new());
|
||||
let port = make_port("Glycol", 2.0e5, 60_000.0);
|
||||
let mut source = BrineSource::new(
|
||||
"Glycol",
|
||||
Pressure::from_pascals(2.0e5),
|
||||
Temperature::from_celsius(15.0),
|
||||
Concentration::from_percent(30.0),
|
||||
backend,
|
||||
port,
|
||||
)
|
||||
.unwrap()
|
||||
.with_impose_pressure(false);
|
||||
// Free P, Fixed T → 1 equation
|
||||
assert_eq!(source.n_equations(), 1);
|
||||
assert!(!source.imposes_pressure());
|
||||
assert!(source.imposes_temperature());
|
||||
|
||||
source.set_system_context(0, &[(0, 1, 2)]);
|
||||
let h_set = 3500.0 * 15.0;
|
||||
let state = vec![0.5, 1.5e5, h_set];
|
||||
let mut r = vec![0.0; 1];
|
||||
source.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[0].abs() < 1e-12, "only h residual expected: {}", r[0]);
|
||||
|
||||
// Boundary_pT + free flow stays at 2 eqs
|
||||
let source_pt = BrineSource::new(
|
||||
"Glycol",
|
||||
Pressure::from_pascals(2.0e5),
|
||||
Temperature::from_celsius(15.0),
|
||||
Concentration::from_percent(30.0),
|
||||
Arc::new(MockBrineBackend::new()),
|
||||
make_port("Glycol", 2.0e5, 60_000.0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source_pt.n_equations(), 2);
|
||||
assert!(source_pt.m_flow_set().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brine_sink_imposed_mass_flow_free_temperature() {
|
||||
let backend = Arc::new(MockBrineBackend::new());
|
||||
let port = make_port("Glycol", 2.0e5, 60_000.0);
|
||||
let mut sink = BrineSink::new(
|
||||
"Glycol",
|
||||
Pressure::from_pascals(2.0e5),
|
||||
None,
|
||||
None,
|
||||
backend,
|
||||
port,
|
||||
)
|
||||
.unwrap()
|
||||
.with_imposed_mass_flow(0.8)
|
||||
.unwrap();
|
||||
// 1 (back-pressure) + 1 (flow); temperature stays free.
|
||||
assert_eq!(sink.n_equations(), 2);
|
||||
|
||||
sink.set_system_context(0, &[(0, 1, 2)]);
|
||||
let state = vec![0.8, 2.0e5, 60_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
sink.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[0].abs() < 1e-12 && r[1].abs() < 1e-12, "{r:?}");
|
||||
|
||||
// Flow row occupies the row AFTER the optional h row: here row 1.
|
||||
let mut jac = JacobianBuilder::new();
|
||||
sink.jacobian_entries(&state, &mut jac).unwrap();
|
||||
assert!(jac
|
||||
.entries()
|
||||
.iter()
|
||||
.any(|&(row, col, v)| row == 1 && col == 0 && (v - 1.0).abs() < 1e-12));
|
||||
}
|
||||
|
||||
// Task 4.3 — Residual validation: residuals must be zero at set-point.
|
||||
#[test]
|
||||
fn test_brine_source_residuals_zero_at_setpoint() {
|
||||
|
||||
@@ -48,6 +48,17 @@ pub struct BypassValve {
|
||||
setpoint: f64,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
/// When true, the valve participates in the (P,h) graph solver as a
|
||||
/// 2-port throttling element on its outgoing edge.
|
||||
edge_coupled: bool,
|
||||
/// Global state index of the inlet edge pressure (set by the solver).
|
||||
inlet_p_idx: Option<usize>,
|
||||
/// Global state index of the inlet edge enthalpy (set by the solver).
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Global state index of the outlet edge pressure (set by the solver).
|
||||
outlet_p_idx: Option<usize>,
|
||||
/// Global state index of the outlet edge enthalpy (set by the solver).
|
||||
outlet_h_idx: Option<usize>,
|
||||
}
|
||||
|
||||
/// Bypass valve control mode
|
||||
@@ -73,9 +84,37 @@ impl BypassValve {
|
||||
control_mode: BypassValveControlMode::Manual,
|
||||
setpoint: 0.0,
|
||||
operational_state: OperationalState::Off,
|
||||
edge_coupled: false,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables the edge-coupled (P,h) solver model, so the valve participates in
|
||||
/// the refrigeration/hydronic graph as a 2-port throttling element.
|
||||
///
|
||||
/// The imposed pressure drop scales with the valve opening:
|
||||
/// `ΔP = nominal_pressure_drop_pa * (cv_full_open / effective_cv)²`, and the
|
||||
/// process is adiabatic (`h_out = h_in`).
|
||||
pub fn with_edge_coupling(mut self) -> Self {
|
||||
self.edge_coupled = true;
|
||||
if self.operational_state == OperationalState::Off {
|
||||
self.operational_state = OperationalState::On;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the imposed pressure drop (Pa) for the current opening.
|
||||
fn imposed_pressure_drop_pa(&self) -> f64 {
|
||||
let cv_full = self.config.cv.max(1e-9);
|
||||
// effective_cv falls to ~0 when closed; clamp to keep ΔP finite.
|
||||
let cv_eff = self.effective_cv().max(cv_full * 1e-2);
|
||||
let ratio = cv_full / cv_eff;
|
||||
self.config.nominal_pressure_drop_pa * ratio * ratio
|
||||
}
|
||||
|
||||
/// Sets the valve position
|
||||
pub fn set_position(&mut self, position: f64) -> Result<(), ComponentError> {
|
||||
let clamped = position.clamp(self.config.min_position, self.config.max_position);
|
||||
@@ -184,21 +223,72 @@ impl Component for BypassValve {
|
||||
2 // Mass balance + energy balance
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge, [1] = outgoing edge.
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_residuals: &mut crate::ResidualVector,
|
||||
state: &[f64],
|
||||
residuals: &mut crate::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement residual calculations
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let dp = self.imposed_pressure_drop_pa();
|
||||
residuals[0] = state[out_p] - (state[in_p] - dp);
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BypassValve edge-coupled model requires live inlet/outlet edge state indices"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_jacobian: &mut crate::JacobianBuilder,
|
||||
jacobian: &mut crate::JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement Jacobian entries
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -214,7 +304,10 @@ impl Component for BypassValve {
|
||||
crate::ComponentParams::new("BypassValve")
|
||||
.with_param("id", self.id.as_str())
|
||||
.with_param("position", self.position)
|
||||
.with_param("config", serde_json::to_value(&self.config).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"config",
|
||||
serde_json::to_value(&self.config).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.with_param("controlMode", format!("{:?}", self.control_mode))
|
||||
.with_param("setpoint", self.setpoint)
|
||||
}
|
||||
|
||||
248
crates/components/src/capillary_tube.rs
Normal file
248
crates/components/src/capillary_tube.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! Adiabatic capillary tube (1D segmented Bansal-style model).
|
||||
//!
|
||||
//! Integrates frictional ΔP along the tube with single-phase then two-phase
|
||||
//! Müller-Steinhagen-Heck friction. Process is isenthalpic overall.
|
||||
|
||||
use crate::heat_exchanger::two_phase_dp::{msh_gradient, FriedelInput, TwoPhaseDpCorrelation};
|
||||
use crate::port::{Connected, Disconnected, Port};
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, StateSlice,
|
||||
};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Capillary geometry and discretization.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CapillaryGeometry {
|
||||
/// Inner diameter [m].
|
||||
pub diameter_m: f64,
|
||||
/// Tube length [m].
|
||||
pub length_m: f64,
|
||||
/// Number of axial segments (≥ 2).
|
||||
pub n_segments: usize,
|
||||
}
|
||||
|
||||
impl Default for CapillaryGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
diameter_m: 0.001,
|
||||
length_m: 2.0,
|
||||
n_segments: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capillary tube component (2-port, isenthalpic).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapillaryTube<State> {
|
||||
geom: CapillaryGeometry,
|
||||
dp_correlation: TwoPhaseDpCorrelation,
|
||||
port_inlet: Port<State>,
|
||||
port_outlet: Port<State>,
|
||||
operational_state: OperationalState,
|
||||
circuit_id: CircuitId,
|
||||
/// Last computed ΔP [Pa].
|
||||
last_dp_pa: f64,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
impl CapillaryTube<Disconnected> {
|
||||
/// Creates a disconnected capillary.
|
||||
pub fn new(
|
||||
geom: CapillaryGeometry,
|
||||
port_inlet: Port<Disconnected>,
|
||||
port_outlet: Port<Disconnected>,
|
||||
) -> Result<Self, ComponentError> {
|
||||
if geom.diameter_m <= 0.0 || geom.length_m <= 0.0 || geom.n_segments < 2 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"invalid capillary geometry".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
geom,
|
||||
dp_correlation: TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
|
||||
port_inlet,
|
||||
port_outlet,
|
||||
operational_state: OperationalState::On,
|
||||
circuit_id: CircuitId::default(),
|
||||
last_dp_pa: 0.0,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Connects ports.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<CapillaryTube<Connected>, ComponentError> {
|
||||
let (p_in, _) = self
|
||||
.port_inlet
|
||||
.connect(inlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_out, _) = self
|
||||
.port_outlet
|
||||
.connect(outlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
Ok(CapillaryTube {
|
||||
geom: self.geom,
|
||||
dp_correlation: self.dp_correlation,
|
||||
port_inlet: p_in,
|
||||
port_outlet: p_out,
|
||||
operational_state: self.operational_state,
|
||||
circuit_id: self.circuit_id,
|
||||
last_dp_pa: 0.0,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CapillaryTube<Connected> {
|
||||
/// Integrates frictional pressure drop for given mass flow and quality path.
|
||||
///
|
||||
/// Quality is linearly ramped from `x_in` to `x_out` along the tube (flashing).
|
||||
pub fn integrate_dp(
|
||||
&self,
|
||||
mass_flow_kg_s: f64,
|
||||
x_in: f64,
|
||||
x_out: f64,
|
||||
rho_l: f64,
|
||||
rho_v: f64,
|
||||
mu_l: f64,
|
||||
mu_v: f64,
|
||||
sigma: f64,
|
||||
) -> f64 {
|
||||
let area = std::f64::consts::PI * (self.geom.diameter_m * 0.5).powi(2);
|
||||
let g = mass_flow_kg_s.abs() / area.max(1e-12);
|
||||
let dz = self.geom.length_m / self.geom.n_segments as f64;
|
||||
let mut dp = 0.0;
|
||||
for i in 0..self.geom.n_segments {
|
||||
let t = (i as f64 + 0.5) / self.geom.n_segments as f64;
|
||||
let x = x_in + (x_out - x_in) * t;
|
||||
let input = FriedelInput {
|
||||
mass_flux: g,
|
||||
diameter: self.geom.diameter_m,
|
||||
quality: x.clamp(0.0, 1.0),
|
||||
rho_liquid: rho_l,
|
||||
rho_vapor: rho_v,
|
||||
mu_liquid: mu_l,
|
||||
mu_vapor: mu_v,
|
||||
sigma,
|
||||
};
|
||||
let grad = match self.dp_correlation {
|
||||
TwoPhaseDpCorrelation::Friedel1979 => {
|
||||
crate::heat_exchanger::two_phase_dp::friedel_gradient(&input)
|
||||
}
|
||||
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986 => msh_gradient(&input),
|
||||
};
|
||||
dp += grad * dz;
|
||||
}
|
||||
dp
|
||||
}
|
||||
|
||||
/// Last ΔP [Pa].
|
||||
pub fn last_dp_pa(&self) -> f64 {
|
||||
self.last_dp_pa
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CapillaryTube<Connected> {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
// state: [m, P_in, h_in, P_out] (simplified) or [m_in, m_out, h_in, h_out]
|
||||
let m = state[0];
|
||||
residuals[0] = state[1] - m; // mass continuity if state[1]=m_out
|
||||
// isenthalpic: h_out - h_in = 0
|
||||
if state.len() >= 4 {
|
||||
residuals[1] = state[3] - state[2];
|
||||
} else {
|
||||
residuals[1] = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, -1.0);
|
||||
jacobian.add_entry(0, 1, 1.0);
|
||||
jacobian.add_entry(1, 2, -1.0);
|
||||
jacobian.add_entry(1, 3, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"CapillaryTube(D={:.2}mm, L={:.2}m, N={})",
|
||||
self.geom.diameter_m * 1000.0,
|
||||
self.geom.length_m,
|
||||
self.geom.n_segments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::FluidId;
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
#[test]
|
||||
fn dp_increases_with_mass_flow() {
|
||||
let geom = CapillaryGeometry::default();
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(250_000.0),
|
||||
);
|
||||
let outlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(3.0),
|
||||
Enthalpy::from_joules_per_kg(250_000.0),
|
||||
);
|
||||
let cap = CapillaryTube::new(geom, inlet, outlet)
|
||||
.unwrap()
|
||||
.connect(
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(250_000.0),
|
||||
),
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(3.0),
|
||||
Enthalpy::from_joules_per_kg(250_000.0),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let dp_low = cap.integrate_dp(0.005, 0.0, 0.3, 1200.0, 20.0, 2e-4, 1.2e-5, 0.01);
|
||||
let dp_high = cap.integrate_dp(0.015, 0.0, 0.3, 1200.0, 20.0, 2e-4, 1.2e-5, 0.01);
|
||||
assert!(dp_high > dp_low);
|
||||
assert!(dp_low > 0.0);
|
||||
}
|
||||
}
|
||||
350
crates/components/src/centrifugal_compressor.rs
Normal file
350
crates/components/src/centrifugal_compressor.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! Centrifugal compressor with a normalized polytropic performance map.
|
||||
//!
|
||||
//! Independent variables: flow coefficient `φ = Q/(N D³)` and machine Mach
|
||||
//! number `M_u = U/√(γ R T)`. Dependent: polytropic head coefficient `μ_p`
|
||||
//! and polytropic efficiency `η_p`. VFD operation re-evaluates the map at the
|
||||
//! new tip speed / Mach number.
|
||||
|
||||
use crate::port::{Connected, Disconnected, Port};
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::Power;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// Single map point `(φ, M_u) → (μ_p, η_p)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CentrifugalMapPoint {
|
||||
/// Flow coefficient φ [-].
|
||||
pub phi: f64,
|
||||
/// Machine Mach number M_u [-].
|
||||
pub mach: f64,
|
||||
/// Polytropic head coefficient μ_p [-].
|
||||
pub mu_p: f64,
|
||||
/// Polytropic efficiency η_p [-].
|
||||
pub eta_p: f64,
|
||||
}
|
||||
|
||||
/// Bilinear performance map on a structured `(φ, M_u)` grid.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CentrifugalMap {
|
||||
points: Vec<CentrifugalMapPoint>,
|
||||
}
|
||||
|
||||
impl CentrifugalMap {
|
||||
/// Creates a map from unsorted points (must cover a rectangle in φ–Mach).
|
||||
pub fn new(points: Vec<CentrifugalMapPoint>) -> Result<Self, ComponentError> {
|
||||
if points.len() < 4 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"CentrifugalMap needs at least 4 points".into(),
|
||||
));
|
||||
}
|
||||
if points
|
||||
.iter()
|
||||
.any(|p| !p.phi.is_finite() || !p.mach.is_finite() || p.eta_p <= 0.0)
|
||||
{
|
||||
return Err(ComponentError::InvalidState(
|
||||
"CentrifugalMap points must be finite with positive eta".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { points })
|
||||
}
|
||||
|
||||
/// Default demo map around φ∈[0.02,0.08], M_u∈[0.6,1.2].
|
||||
pub fn default_chiller_map() -> Self {
|
||||
Self::new(vec![
|
||||
CentrifugalMapPoint {
|
||||
phi: 0.02,
|
||||
mach: 0.6,
|
||||
mu_p: 0.55,
|
||||
eta_p: 0.78,
|
||||
},
|
||||
CentrifugalMapPoint {
|
||||
phi: 0.08,
|
||||
mach: 0.6,
|
||||
mu_p: 0.48,
|
||||
eta_p: 0.80,
|
||||
},
|
||||
CentrifugalMapPoint {
|
||||
phi: 0.02,
|
||||
mach: 1.2,
|
||||
mu_p: 0.62,
|
||||
eta_p: 0.76,
|
||||
},
|
||||
CentrifugalMapPoint {
|
||||
phi: 0.08,
|
||||
mach: 1.2,
|
||||
mu_p: 0.52,
|
||||
eta_p: 0.79,
|
||||
},
|
||||
])
|
||||
.expect("default map")
|
||||
}
|
||||
|
||||
/// Inverse-distance weighted interpolation of (μ_p, η_p).
|
||||
pub fn interpolate(&self, phi: f64, mach: f64) -> (f64, f64) {
|
||||
let mut w_sum = 0.0;
|
||||
let mut mu = 0.0;
|
||||
let mut eta = 0.0;
|
||||
for p in &self.points {
|
||||
let d2 = (p.phi - phi).powi(2) + (p.mach - mach).powi(2);
|
||||
let w = 1.0 / d2.max(1e-12);
|
||||
w_sum += w;
|
||||
mu += w * p.mu_p;
|
||||
eta += w * p.eta_p;
|
||||
}
|
||||
(mu / w_sum, (eta / w_sum).clamp(0.2, 0.95))
|
||||
}
|
||||
}
|
||||
|
||||
/// Centrifugal compressor component (2-port).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CentrifugalCompressor<State> {
|
||||
map: CentrifugalMap,
|
||||
/// Impeller tip diameter [m].
|
||||
diameter_m: f64,
|
||||
/// Rotational speed [rpm].
|
||||
speed_rpm: f64,
|
||||
/// Nominal speed [rpm] for VFD ratio.
|
||||
nominal_speed_rpm: f64,
|
||||
/// Specific gas constant R [J/(kg·K)].
|
||||
gas_constant: f64,
|
||||
/// Heat capacity ratio γ [-].
|
||||
gamma: f64,
|
||||
port_inlet: Port<State>,
|
||||
port_outlet: Port<State>,
|
||||
operational_state: OperationalState,
|
||||
circuit_id: CircuitId,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
impl CentrifugalCompressor<Disconnected> {
|
||||
/// Creates a disconnected centrifugal compressor.
|
||||
pub fn new(
|
||||
map: CentrifugalMap,
|
||||
diameter_m: f64,
|
||||
speed_rpm: f64,
|
||||
port_inlet: Port<Disconnected>,
|
||||
port_outlet: Port<Disconnected>,
|
||||
) -> Result<Self, ComponentError> {
|
||||
if diameter_m <= 0.0 || speed_rpm <= 0.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"diameter and speed must be positive".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
map,
|
||||
diameter_m,
|
||||
speed_rpm,
|
||||
nominal_speed_rpm: speed_rpm,
|
||||
gas_constant: 188.9, // R134a approx
|
||||
gamma: 1.12,
|
||||
port_inlet,
|
||||
port_outlet,
|
||||
operational_state: OperationalState::On,
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets gas properties for Mach / head evaluation.
|
||||
pub fn with_gas(mut self, r_j_kg_k: f64, gamma: f64) -> Self {
|
||||
self.gas_constant = r_j_kg_k.max(50.0);
|
||||
self.gamma = gamma.clamp(1.05, 1.4);
|
||||
self
|
||||
}
|
||||
|
||||
/// Connects ports.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<CentrifugalCompressor<Connected>, ComponentError> {
|
||||
let (p_in, _) = self
|
||||
.port_inlet
|
||||
.connect(inlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_out, _) = self
|
||||
.port_outlet
|
||||
.connect(outlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
Ok(CentrifugalCompressor {
|
||||
map: self.map,
|
||||
diameter_m: self.diameter_m,
|
||||
speed_rpm: self.speed_rpm,
|
||||
nominal_speed_rpm: self.nominal_speed_rpm,
|
||||
gas_constant: self.gas_constant,
|
||||
gamma: self.gamma,
|
||||
port_inlet: p_in,
|
||||
port_outlet: p_out,
|
||||
operational_state: self.operational_state,
|
||||
circuit_id: self.circuit_id,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CentrifugalCompressor<Connected> {
|
||||
/// Tip speed U = π N D [m/s] (N in rev/s).
|
||||
pub fn tip_speed(&self) -> f64 {
|
||||
let n_rps = self.speed_rpm / 60.0;
|
||||
std::f64::consts::PI * n_rps * self.diameter_m
|
||||
}
|
||||
|
||||
/// Sets VFD speed [rpm].
|
||||
pub fn set_speed_rpm(&mut self, rpm: f64) -> Result<(), ComponentError> {
|
||||
if rpm <= 0.0 {
|
||||
return Err(ComponentError::InvalidState("speed must be positive".into()));
|
||||
}
|
||||
self.speed_rpm = rpm;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluates map at suction conditions; returns (head [J/kg], η_p, power [W]).
|
||||
pub fn rate(
|
||||
&self,
|
||||
t_suction_k: f64,
|
||||
rho_suction: f64,
|
||||
volume_flow_m3_s: f64,
|
||||
) -> Result<(f64, f64, f64), ComponentError> {
|
||||
let u = self.tip_speed();
|
||||
let a = (self.gamma * self.gas_constant * t_suction_k.max(200.0)).sqrt();
|
||||
let mach = u / a.max(1.0);
|
||||
let n_rps = self.speed_rpm / 60.0;
|
||||
let phi = volume_flow_m3_s / (n_rps * self.diameter_m.powi(3)).max(1e-12);
|
||||
let (mu_p, eta_p) = self.map.interpolate(phi, mach);
|
||||
let head = mu_p * u * u; // J/kg
|
||||
let m_dot = volume_flow_m3_s * rho_suction.max(0.1);
|
||||
let power = m_dot * head / eta_p.max(0.2);
|
||||
Ok((head, eta_p, power))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for CentrifugalCompressor<Connected> {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if state.len() < 2 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 2,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
// r0: mass continuity ṁ_out − ṁ_in = 0
|
||||
// r1: isentropic-like enthalpy rise placeholder using map head
|
||||
let m_in = state[0];
|
||||
let m_out = state[1];
|
||||
residuals[0] = m_out - m_in;
|
||||
let rho = 20.0; // fallback when edge density unavailable
|
||||
let vol = m_in.abs() / rho;
|
||||
let (_, _, power) = self.rate(280.0, rho, vol)?;
|
||||
let dh = if m_in.abs() > 1e-9 {
|
||||
power / m_in.abs()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Enthalpy rise residual uses port enthalpies when available via state[2]/3]
|
||||
if state.len() >= 4 {
|
||||
residuals[1] = (state[3] - state[2]) - dh;
|
||||
} else {
|
||||
residuals[1] = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, -1.0);
|
||||
jacobian.add_entry(0, 1, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"CentrifugalCompressor(D={:.3}m, N={:.0}rpm)",
|
||||
self.diameter_m, self.speed_rpm
|
||||
)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
let m = state.first().copied().unwrap_or(0.0);
|
||||
let vol = m.abs() / 20.0;
|
||||
let power = self.rate(280.0, 20.0, vol).map(|(_, _, p)| p).unwrap_or(0.0);
|
||||
Some((Power::from_watts(0.0), Power::from_watts(-power)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::FluidId;
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
#[test]
|
||||
fn map_interpolates_interior() {
|
||||
let map = CentrifugalMap::default_chiller_map();
|
||||
let (mu, eta) = map.interpolate(0.05, 0.9);
|
||||
assert!(mu > 0.4 && mu < 0.7);
|
||||
assert!(eta > 0.7 && eta < 0.85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_increases_with_speed() {
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(3.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let outlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(430_000.0),
|
||||
);
|
||||
let c = CentrifugalCompressor::new(
|
||||
CentrifugalMap::default_chiller_map(),
|
||||
0.25,
|
||||
9000.0,
|
||||
inlet,
|
||||
outlet,
|
||||
)
|
||||
.unwrap();
|
||||
let connected = c.connect(
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(3.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
),
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(430_000.0),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, _, p_low) = connected.rate(280.0, 20.0, 0.05).unwrap();
|
||||
let mut fast = connected;
|
||||
fast.set_speed_rpm(12_000.0).unwrap();
|
||||
let (_, _, p_high) = fast.rate(280.0, 20.0, 0.05).unwrap();
|
||||
assert!(p_high > p_low);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,19 @@ pub struct Compressor<State> {
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state: On, Off, or Bypass (FR6-FR8)
|
||||
operational_state: OperationalState,
|
||||
/// State-vector index: suction mass flow (incoming edge, CM1.3)
|
||||
suction_m_idx: Option<usize>,
|
||||
/// State-vector index: suction enthalpy (incoming edge, CM1.3)
|
||||
suction_h_idx: Option<usize>,
|
||||
/// State-vector index: discharge mass flow (outgoing edge, CM1.3)
|
||||
discharge_m_idx: Option<usize>,
|
||||
/// State-vector index: discharge enthalpy (outgoing edge, CM1.3)
|
||||
discharge_h_idx: Option<usize>,
|
||||
/// True when suction and discharge share the same ṁ state index (same
|
||||
/// series branch). In this case the mass-conservation residual
|
||||
/// `ṁ_dis − ṁ_suc = 0` is trivially satisfied and must be dropped to
|
||||
/// keep the system square (CM1.4).
|
||||
same_branch_m: bool,
|
||||
/// Phantom data for type state
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
@@ -560,6 +573,11 @@ impl Compressor<Disconnected> {
|
||||
|
||||
circuit_id: CircuitId::default(), // Default circuit
|
||||
operational_state: OperationalState::default(), // Default to On
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -682,6 +700,11 @@ impl Compressor<Disconnected> {
|
||||
fluid_id: self.fluid_id,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -785,10 +808,15 @@ impl Compressor<Connected> {
|
||||
let mass_flow_kg_per_s = match &self.model {
|
||||
CompressorModel::Ahri540(coeffs) => {
|
||||
// Calculate volumetric efficiency using inverse pressure ratio
|
||||
// η_vol = 1 - (P_suction/P_discharge)^(1/M2)
|
||||
// η_vol = f_etav × (1 - (P_suction/P_discharge)^(1/M2))
|
||||
// f_etav is read dynamically from the state vector when wired
|
||||
// as a calibration unknown (Story 5.5), like f_m / f_power.
|
||||
let f_etav = state
|
||||
.and_then(|st| self.calib_indices.z_etav.map(|idx| st[idx]))
|
||||
.unwrap_or(self.calib.z_etav);
|
||||
let inverse_pressure_ratio = p_suction / p_discharge;
|
||||
let volumetric_efficiency = (1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2))
|
||||
* self.calib.f_etav;
|
||||
let volumetric_efficiency =
|
||||
(1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2)) * f_etav;
|
||||
|
||||
if volumetric_efficiency < 0.0 {
|
||||
return Err(ComponentError::NumericalError(
|
||||
@@ -816,11 +844,11 @@ impl Compressor<Connected> {
|
||||
// Apply calibration: ṁ_eff = f_m × ṁ_nominal
|
||||
let f_m = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.f_m
|
||||
.z_flow
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.f_m)
|
||||
.unwrap_or(self.calib.z_flow)
|
||||
} else {
|
||||
self.calib.f_m
|
||||
self.calib.z_flow
|
||||
};
|
||||
Ok(MassFlow::from_kg_per_s(mass_flow_kg_per_s * f_m))
|
||||
}
|
||||
@@ -861,11 +889,11 @@ impl Compressor<Connected> {
|
||||
// Ẇ_eff = f_power × Ẇ_nominal
|
||||
let f_power = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.f_power
|
||||
.z_power
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.f_power)
|
||||
.unwrap_or(self.calib.z_power)
|
||||
} else {
|
||||
self.calib.f_power
|
||||
self.calib.z_power
|
||||
};
|
||||
power_nominal * f_power
|
||||
}
|
||||
@@ -907,11 +935,11 @@ impl Compressor<Connected> {
|
||||
// Ẇ_eff = f_power × Ẇ_nominal
|
||||
let f_power = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.f_power
|
||||
.z_power
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.f_power)
|
||||
.unwrap_or(self.calib.z_power)
|
||||
} else {
|
||||
self.calib.f_power
|
||||
self.calib.z_power
|
||||
};
|
||||
power_nominal * f_power
|
||||
}
|
||||
@@ -1061,6 +1089,28 @@ impl Compressor<Connected> {
|
||||
}
|
||||
|
||||
impl Component for Compressor<Connected> {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming suction edge, [1] = outgoing discharge edge.
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.suction_m_idx = Some(external_edge_state_indices[0].0);
|
||||
self.suction_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.discharge_m_idx = Some(external_edge_state_indices[1].0);
|
||||
self.discharge_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
// CM1.4: detect same-branch topology → conservation residual becomes trivial.
|
||||
self.same_branch_m = matches!(
|
||||
(self.suction_m_idx, self.discharge_m_idx),
|
||||
(Some(suc), Some(dis)) if suc == dis
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
@@ -1074,17 +1124,32 @@ impl Component for Compressor<Connected> {
|
||||
});
|
||||
}
|
||||
|
||||
let (suc_m_idx, suc_h_idx, dis_m_idx, dis_h_idx) = match (
|
||||
self.suction_m_idx,
|
||||
self.suction_h_idx,
|
||||
self.discharge_m_idx,
|
||||
self.discharge_h_idx,
|
||||
) {
|
||||
(Some(sm), Some(sh), Some(dm), Some(dh)) => (sm, sh, dm, dh),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Compressor requires live suction/discharge mass-flow and enthalpy indices"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle operational states (FR6-FR8)
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
// In Off state, mass flow is zero (FR7)
|
||||
residuals[0] = state[0]; // ṁ = 0
|
||||
residuals[0] = state[suc_m_idx]; // ṁ_suction = 0
|
||||
residuals[1] = 0.0; // No energy transfer
|
||||
residuals[2] = state[dis_m_idx] - state[suc_m_idx]; // mass conservation
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::Bypass => {
|
||||
// In Bypass state, behaves as adiabatic pipe (FR8)
|
||||
// P_in = P_out and h_in = h_out
|
||||
let p_suction = self.port_suction.pressure().to_pascals();
|
||||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||||
let h_suction = self.port_suction.enthalpy().to_joules_per_kg();
|
||||
@@ -1092,6 +1157,7 @@ impl Component for Compressor<Connected> {
|
||||
|
||||
residuals[0] = p_suction - p_discharge; // Pressure continuity
|
||||
residuals[1] = h_suction - h_discharge; // Enthalpy continuity (adiabatic)
|
||||
residuals[2] = state[dis_m_idx] - state[suc_m_idx]; // mass conservation
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::On => {
|
||||
@@ -1099,20 +1165,10 @@ impl Component for Compressor<Connected> {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate state vector has minimum required dimensions
|
||||
// We need at least 4 values: mass_flow, h_suction, h_discharge, power
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract state variables
|
||||
let mass_flow_state = state[0]; // kg/s
|
||||
let h_suction = state[1]; // J/kg
|
||||
let h_discharge = state[2]; // J/kg
|
||||
let _power_state = state[3]; // W
|
||||
// Extract state variables using stored edge indices (CM1.3)
|
||||
let mass_flow_state = state[suc_m_idx]; // kg/s — ṁ at suction edge
|
||||
let h_suction = state[suc_h_idx]; // J/kg
|
||||
let h_discharge = state[dis_h_idx]; // J/kg
|
||||
|
||||
// Get port values
|
||||
let p_suction = self.port_suction.pressure().to_pascals();
|
||||
@@ -1154,6 +1210,13 @@ impl Component for Compressor<Connected> {
|
||||
|
||||
residuals[1] = power_calc - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
|
||||
|
||||
// r2: ṁ_discharge − ṁ_suction = 0 (mass conservation, CM1.3)
|
||||
// CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index),
|
||||
// so the residual would be trivially 0 and is excluded from n_equations().
|
||||
if !self.same_branch_m {
|
||||
residuals[2] = state[dis_m_idx] - state[suc_m_idx];
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1162,139 +1225,160 @@ impl Component for Compressor<Connected> {
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Validate state vector
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
let (suc_m_idx, suc_h_idx, dis_m_idx, dis_h_idx) = match (
|
||||
self.suction_m_idx,
|
||||
self.suction_h_idx,
|
||||
self.discharge_m_idx,
|
||||
self.discharge_h_idx,
|
||||
) {
|
||||
(Some(sm), Some(sh), Some(dm), Some(dh)) => (sm, sh, dm, dh),
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Compressor Jacobian requires live suction/discharge mass-flow and enthalpy indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Extract state variables
|
||||
let mass_flow_state = state[0];
|
||||
let h_suction = state[1];
|
||||
let h_discharge = state[2];
|
||||
let mass_flow_state = state[suc_m_idx];
|
||||
let h_suction = state[suc_h_idx];
|
||||
let h_discharge = state[dis_h_idx];
|
||||
|
||||
// Get port values
|
||||
let p_suction = self.port_suction.pressure().to_pascals();
|
||||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||||
|
||||
// Calculate temperatures for SST/SDT model
|
||||
let _t_suction_k =
|
||||
estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction).unwrap_or(273.15);
|
||||
let t_discharge_k = estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)
|
||||
.unwrap_or(273.15);
|
||||
let t_suction_k = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||||
let t_discharge_k = estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)?;
|
||||
|
||||
// Row 0: Mass flow residual
|
||||
// ∂r₀/∂ṁ = -1
|
||||
jacobian.add_entry(0, 0, -1.0);
|
||||
// Row 0: Mass flow residual r0 = ṁ_calc − ṁ_suction
|
||||
// ∂r0/∂ṁ_suction = -1 (exact, CM1.3)
|
||||
jacobian.add_entry(0, suc_m_idx, -1.0);
|
||||
|
||||
// ∂r₀/∂h_suction - requires density derivative
|
||||
// For now, use finite difference approximation
|
||||
let dr0_dh_suction = approximate_derivative(
|
||||
// ∂r0/∂h_suction — numerical (density depends on h)
|
||||
let dr0_dh_suction = approximate_derivative_result(
|
||||
|h| {
|
||||
let density = estimate_density(self.fluid_id.as_str(), p_suction, h).unwrap_or(1.0);
|
||||
let t_k =
|
||||
estimate_temperature(self.fluid_id.as_str(), p_suction, h).unwrap_or(273.15);
|
||||
let density = estimate_density(self.fluid_id.as_str(), p_suction, h)?;
|
||||
let t_k = estimate_temperature(self.fluid_id.as_str(), p_suction, h)?;
|
||||
self.mass_flow_rate(density, t_k, t_discharge_k, Some(state))
|
||||
.map(|m| m.to_kg_per_s())
|
||||
.unwrap_or(0.0)
|
||||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
|
||||
},
|
||||
h_suction,
|
||||
1.0,
|
||||
);
|
||||
jacobian.add_entry(0, 1, dr0_dh_suction);
|
||||
)?;
|
||||
jacobian.add_entry(0, suc_h_idx, dr0_dh_suction);
|
||||
|
||||
// ∂r₀/∂h_discharge = 0 (mass flow doesn't depend on discharge enthalpy)
|
||||
jacobian.add_entry(0, 2, 0.0);
|
||||
|
||||
// ∂r₀/∂Power = 0
|
||||
jacobian.add_entry(0, 3, 0.0);
|
||||
|
||||
// Row 1: Energy residual
|
||||
// ∂r₁/∂ṁ = -(h_discharge - h_suction) / η_mech
|
||||
// Row 1: Energy residual r1 = power_calc − ṁ × Δh / η_mech
|
||||
// ∂r1/∂ṁ_suction = −(h_discharge − h_suction) / η_mech
|
||||
let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency;
|
||||
jacobian.add_entry(1, 0, dr1_dm);
|
||||
jacobian.add_entry(1, suc_m_idx, dr1_dm);
|
||||
|
||||
// ∂r₁/∂h_suction - includes power derivative and mass flow term
|
||||
let dr1_dh_suction = approximate_derivative(
|
||||
// ∂r1/∂h_suction — numerical
|
||||
let dr1_dh_suction = approximate_derivative_result(
|
||||
|h| {
|
||||
let t = estimate_temperature(self.fluid_id.as_str(), p_suction, h).unwrap_or(300.0);
|
||||
let t = estimate_temperature(self.fluid_id.as_str(), p_suction, h)?;
|
||||
let t_discharge =
|
||||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)
|
||||
.unwrap_or(350.0);
|
||||
self.power_consumption_cooling(
|
||||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)?;
|
||||
Ok(self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(t),
|
||||
Temperature::from_kelvin(t_discharge),
|
||||
None,
|
||||
)
|
||||
))
|
||||
},
|
||||
h_suction,
|
||||
1.0,
|
||||
) + mass_flow_state / self.mechanical_efficiency;
|
||||
jacobian.add_entry(1, 1, dr1_dh_suction);
|
||||
)? + mass_flow_state / self.mechanical_efficiency;
|
||||
jacobian.add_entry(1, suc_h_idx, dr1_dh_suction);
|
||||
|
||||
// ∂r₁/∂h_discharge - includes power derivative and mass flow term
|
||||
let dr1_dh_discharge = approximate_derivative(
|
||||
// ∂r1/∂h_discharge — numerical
|
||||
let dr1_dh_discharge = approximate_derivative_result(
|
||||
|h| {
|
||||
let t_suction = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)
|
||||
.unwrap_or(300.0);
|
||||
let t =
|
||||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h).unwrap_or(350.0);
|
||||
self.power_consumption_cooling(
|
||||
let t_suction = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||||
let t = estimate_temperature(self.fluid_id.as_str(), p_discharge, h)?;
|
||||
Ok(self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(t_suction),
|
||||
Temperature::from_kelvin(t),
|
||||
None,
|
||||
)
|
||||
))
|
||||
},
|
||||
h_discharge,
|
||||
1.0,
|
||||
) - mass_flow_state / self.mechanical_efficiency;
|
||||
jacobian.add_entry(1, 2, dr1_dh_discharge);
|
||||
)? - mass_flow_state / self.mechanical_efficiency;
|
||||
jacobian.add_entry(1, dis_h_idx, dr1_dh_discharge);
|
||||
|
||||
// ∂r₁/∂Power = -1
|
||||
jacobian.add_entry(1, 3, -1.0);
|
||||
|
||||
// Calibration derivatives (Story 5.5)
|
||||
if let Some(f_m_idx) = self.calib_indices.f_m {
|
||||
// ∂r₀/∂f_m = ṁ_nominal
|
||||
let density_suction =
|
||||
estimate_density(self.fluid_id.as_str(), p_suction, h_suction).unwrap_or(1.0);
|
||||
let m_nominal = self
|
||||
.mass_flow_rate(density_suction, _t_suction_k, t_discharge_k, None)
|
||||
.map(|m| m.to_kg_per_s())
|
||||
.unwrap_or(0.0);
|
||||
jacobian.add_entry(0, f_m_idx, m_nominal);
|
||||
// Row 2: Mass conservation r2 = ṁ_discharge − ṁ_suction (exact, CM1.3)
|
||||
// CM1.4: omit when same_branch_m (trivial equation removed from n_equations).
|
||||
if !self.same_branch_m {
|
||||
jacobian.add_entry(2, dis_m_idx, 1.0);
|
||||
jacobian.add_entry(2, suc_m_idx, -1.0);
|
||||
}
|
||||
|
||||
if let Some(f_power_idx) = self.calib_indices.f_power {
|
||||
// ∂r₁/∂f_power = Power_nominal
|
||||
// Calibration derivatives (Story 5.5)
|
||||
if let Some(z_flow_idx) = self.calib_indices.z_flow {
|
||||
let density_suction = estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||||
let m_nominal = self
|
||||
.mass_flow_rate(density_suction, t_suction_k, t_discharge_k, None)
|
||||
.map(|m| m.to_kg_per_s())
|
||||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
|
||||
jacobian.add_entry(0, z_flow_idx, m_nominal);
|
||||
}
|
||||
|
||||
if let Some(z_power_idx) = self.calib_indices.z_power {
|
||||
let p_nominal = self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(_t_suction_k),
|
||||
Temperature::from_kelvin(t_suction_k),
|
||||
Temperature::from_kelvin(t_discharge_k),
|
||||
None,
|
||||
);
|
||||
jacobian.add_entry(1, f_power_idx, p_nominal);
|
||||
jacobian.add_entry(1, z_power_idx, p_nominal);
|
||||
}
|
||||
|
||||
// ∂r0/∂f_etav (AHRI 540 only): ṁ_calc = f_m · f_etav · base with
|
||||
// base = M1 · (1 − (P_suc/P_dis)^(1/M2)) · ρ_suc · V_disp · N/60,
|
||||
// so the derivative is exactly f_m · base.
|
||||
if let Some(z_etav_idx) = self.calib_indices.z_etav {
|
||||
if let CompressorModel::Ahri540(coeffs) = &self.model {
|
||||
let density_suction =
|
||||
estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||||
let inverse_pressure_ratio = p_suction / p_discharge;
|
||||
let base = coeffs.m1
|
||||
* (1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2))
|
||||
* density_suction
|
||||
* self.displacement_m3_per_rev
|
||||
* (self.speed_rpm / 60.0);
|
||||
let f_m = self
|
||||
.calib_indices
|
||||
.z_flow
|
||||
.map(|idx| state[idx])
|
||||
.unwrap_or(self.calib.z_flow);
|
||||
jacobian.add_entry(0, z_etav_idx, f_m * base);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2 // Mass flow residual and energy residual
|
||||
// CM1.4: when suction and discharge share the same ṁ state index (same
|
||||
// series branch), the conservation residual r[2] = ṁ_dis − ṁ_suc is
|
||||
// trivially 0 and must be dropped to keep the system square.
|
||||
if self.same_branch_m {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
let suc_m_idx = self.suction_m_idx.ok_or_else(|| {
|
||||
ComponentError::InvalidState(
|
||||
"Compressor mass-flow reporting requires a live suction mass-flow index"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[suc_m_idx]);
|
||||
// Suction (inlet), Discharge (outlet), Oil (no flow modeled yet)
|
||||
Ok(vec![
|
||||
m,
|
||||
@@ -1391,7 +1475,10 @@ impl Component for Compressor<Connected> {
|
||||
.with_param("speedRpm", self.speed_rpm)
|
||||
.with_param("displacementM3PerRev", self.displacement_m3_per_rev)
|
||||
.with_param("mechanicalEfficiency", self.mechanical_efficiency)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null));
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
match &self.model {
|
||||
CompressorModel::Ahri540(c) => {
|
||||
params = params
|
||||
@@ -1414,7 +1501,10 @@ impl Component for Compressor<Connected> {
|
||||
"massFlowCurve",
|
||||
serde_json::to_value(&c.mass_flow_curve).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.with_param("powerCurve", serde_json::to_value(&c.power_curve).unwrap_or(serde_json::Value::Null));
|
||||
.with_param(
|
||||
"powerCurve",
|
||||
serde_json::to_value(&c.power_curve).unwrap_or(serde_json::Value::Null),
|
||||
);
|
||||
}
|
||||
}
|
||||
params
|
||||
@@ -1429,6 +1519,48 @@ impl Component for Compressor<Connected> {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
// Without this override the solver's finalize() wiring was silently
|
||||
// dropped (trait default is a no-op): dynamic calibration factors
|
||||
// (f_m, f_power, f_etav) registered as bounded variables never reached
|
||||
// compute_residuals/jacobian_entries, which already read
|
||||
// `self.calib_indices` (mass_flow_rate / power_consumption_cooling).
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
use crate::MeasuredOutput;
|
||||
match kind {
|
||||
// Discharge gas temperature (DGT) so a controls[] loop can hold a
|
||||
// maximum-DGT limit on the AHRI-540 compressor (like the
|
||||
// IsentropicCompressor's liquid-injection loop).
|
||||
MeasuredOutput::Temperature => {
|
||||
let dis_h_idx = self.discharge_h_idx?;
|
||||
if dis_h_idx >= state.len() {
|
||||
return None;
|
||||
}
|
||||
let h_discharge = state[dis_h_idx];
|
||||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||||
if !h_discharge.is_finite() || p_discharge <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)
|
||||
.ok()
|
||||
.filter(|t| t.is_finite())
|
||||
}
|
||||
// Refrigerant mass flow rate at the suction edge.
|
||||
MeasuredOutput::MassFlowRate => {
|
||||
let m_idx = self.suction_m_idx.or(self.discharge_m_idx)?;
|
||||
if m_idx >= state.len() {
|
||||
return None;
|
||||
}
|
||||
let m = state[m_idx];
|
||||
m.is_finite().then_some(m)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state_machine::StateManageable;
|
||||
@@ -1603,6 +1735,7 @@ fn estimate_temperature(
|
||||
///
|
||||
/// Uses a central difference approximation:
|
||||
/// f'(x) ≈ (f(x + h) - f(x - h)) / (2h)
|
||||
#[cfg(test)]
|
||||
fn approximate_derivative<F>(f: F, x: f64, h: f64) -> f64
|
||||
where
|
||||
F: Fn(f64) -> f64,
|
||||
@@ -1610,6 +1743,13 @@ where
|
||||
(f(x + h) - f(x - h)) / (2.0 * h)
|
||||
}
|
||||
|
||||
fn approximate_derivative_result<F>(f: F, x: f64, h: f64) -> Result<f64, ComponentError>
|
||||
where
|
||||
F: Fn(f64) -> Result<f64, ComponentError>,
|
||||
{
|
||||
Ok((f(x + h)? - f(x - h)?) / (2.0 * h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1664,6 +1804,11 @@ mod tests {
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -1847,7 +1992,7 @@ mod tests {
|
||||
.to_kg_per_s();
|
||||
|
||||
compressor.set_calib(Calib {
|
||||
f_m: 1.1,
|
||||
z_flow: 1.1,
|
||||
..Calib::default()
|
||||
});
|
||||
let m_calib = compressor
|
||||
@@ -1865,7 +2010,7 @@ mod tests {
|
||||
let p_default = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
|
||||
compressor.set_calib(Calib {
|
||||
f_power: 1.1,
|
||||
z_power: 1.1,
|
||||
..Calib::default()
|
||||
});
|
||||
let p_calib = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
@@ -1884,7 +2029,7 @@ mod tests {
|
||||
.to_kg_per_s();
|
||||
|
||||
compressor.set_calib(Calib {
|
||||
f_etav: 0.9,
|
||||
z_etav: 0.9,
|
||||
..Calib::default()
|
||||
});
|
||||
let m_calib = compressor
|
||||
@@ -1935,6 +2080,11 @@ mod tests {
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
@@ -2035,28 +2185,32 @@ mod tests {
|
||||
#[test]
|
||||
fn test_component_n_equations() {
|
||||
let compressor = create_test_compressor();
|
||||
assert_eq!(compressor.n_equations(), 2);
|
||||
// CM1.3: 2 thermo + 1 mass-flow conservation = 3 equations
|
||||
assert_eq!(compressor.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_compute_residuals() {
|
||||
let compressor = create_test_compressor();
|
||||
let mut compressor = create_test_compressor();
|
||||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = compressor.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "jacobian error: {:?}", result);
|
||||
|
||||
// Verify residuals are calculated (actual values depend on fluid properties)
|
||||
assert!(!residuals[0].is_nan());
|
||||
assert!(!residuals[1].is_nan());
|
||||
assert!(!residuals[2].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_compute_residuals_wrong_size() {
|
||||
let compressor = create_test_compressor();
|
||||
let mut compressor = create_test_compressor();
|
||||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||||
let mut residuals = vec![0.0; 3]; // Wrong size
|
||||
let mut residuals = vec![0.0; 2]; // Wrong size (n_equations is now 3)
|
||||
|
||||
let result = compressor.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_err());
|
||||
@@ -2064,12 +2218,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_component_jacobian_entries() {
|
||||
let compressor = create_test_compressor();
|
||||
let mut compressor = create_test_compressor();
|
||||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||||
let mut jacobian = JacobianBuilder::new();
|
||||
|
||||
let result = compressor.jacobian_entries(&state, &mut jacobian);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "jacobian error: {:?}", result);
|
||||
|
||||
// Should have at least some entries
|
||||
assert!(jacobian.len() > 0);
|
||||
@@ -2136,6 +2291,11 @@ mod tests {
|
||||
fluid_id: FluidId::new("R410A"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
@@ -2185,6 +2345,11 @@ mod tests {
|
||||
fluid_id: FluidId::new("R454B"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
@@ -2238,6 +2403,11 @@ mod tests {
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
suction_m_idx: None,
|
||||
suction_h_idx: None,
|
||||
discharge_m_idx: None,
|
||||
discharge_h_idx: None,
|
||||
same_branch_m: false,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! validity checking. Used for vendor performance data and heat transfer
|
||||
//! correlations evaluated uniformly across all components.
|
||||
|
||||
use crate::ComponentError;
|
||||
use crate::polynomials::{Polynomial1D, Polynomial2D};
|
||||
use crate::ComponentError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -87,7 +87,9 @@ pub struct PolynomialCurve {
|
||||
impl PolynomialCurve {
|
||||
/// Creates a new polynomial curve from coefficients `[c0, c1, c2, ...]`.
|
||||
pub fn new(coefficients: Vec<f64>) -> Self {
|
||||
Self { inner: Polynomial1D::new(coefficients) }
|
||||
Self {
|
||||
inner: Polynomial1D::new(coefficients),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +102,9 @@ impl Serialize for PolynomialCurve {
|
||||
impl<'de> Deserialize<'de> for PolynomialCurve {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let coeffs = Vec::<f64>::deserialize(deserializer)?;
|
||||
Ok(Self { inner: Polynomial1D::new(coeffs) })
|
||||
Ok(Self {
|
||||
inner: Polynomial1D::new(coeffs),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +159,10 @@ pub struct LogarithmicCurve {
|
||||
impl CurveEval for LogarithmicCurve {
|
||||
fn evaluate(&self, x: f64) -> f64 {
|
||||
if x <= 0.0 {
|
||||
tracing::warn!(x, "LogarithmicCurve evaluate at non-positive x, returning NaN");
|
||||
tracing::warn!(
|
||||
x,
|
||||
"LogarithmicCurve evaluate at non-positive x, returning NaN"
|
||||
);
|
||||
f64::NAN
|
||||
} else {
|
||||
self.a * x.ln() + self.b
|
||||
@@ -164,7 +171,10 @@ impl CurveEval for LogarithmicCurve {
|
||||
|
||||
fn derivative(&self, x: f64) -> f64 {
|
||||
if x <= 0.0 {
|
||||
tracing::warn!(x, "LogarithmicCurve derivative at non-positive x, returning NaN");
|
||||
tracing::warn!(
|
||||
x,
|
||||
"LogarithmicCurve derivative at non-positive x, returning NaN"
|
||||
);
|
||||
f64::NAN
|
||||
} else {
|
||||
self.a / x
|
||||
@@ -346,7 +356,10 @@ pub struct BivariatePolyCurve {
|
||||
impl BivariatePolyCurve {
|
||||
/// Creates a new bivariate polynomial from a coefficient matrix.
|
||||
pub fn new(coefficients: Vec<Vec<f64>>) -> Self {
|
||||
Self { inner: Polynomial2D::new(coefficients), y: 0.0 }
|
||||
Self {
|
||||
inner: Polynomial2D::new(coefficients),
|
||||
y: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates the bivariate polynomial at `(x, y)` (bivariate).
|
||||
@@ -374,7 +387,10 @@ impl<'de> Deserialize<'de> for BivariatePolyCurve {
|
||||
y: f64,
|
||||
}
|
||||
let h = Helper::deserialize(deserializer)?;
|
||||
Ok(Self { inner: Polynomial2D::new(h.coefficients), y: h.y })
|
||||
Ok(Self {
|
||||
inner: Polynomial2D::new(h.coefficients),
|
||||
y: h.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,8 +571,14 @@ impl BoundedCurve {
|
||||
///
|
||||
/// Panics if `min > max` or if either bound is NaN.
|
||||
pub fn with_bounds(mut self, min: f64, max: f64) -> Self {
|
||||
assert!(min <= max, "BoundedCurve::with_bounds: min ({min}) must be <= max ({max})");
|
||||
assert!(!min.is_nan() && !max.is_nan(), "BoundedCurve::with_bounds: bounds must not be NaN");
|
||||
assert!(
|
||||
min <= max,
|
||||
"BoundedCurve::with_bounds: min ({min}) must be <= max ({max})"
|
||||
);
|
||||
assert!(
|
||||
!min.is_nan() && !max.is_nan(),
|
||||
"BoundedCurve::with_bounds: bounds must not be NaN"
|
||||
);
|
||||
self.bounds = Some((min, max));
|
||||
self
|
||||
}
|
||||
@@ -710,23 +732,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_polynomial_validate() {
|
||||
assert!(PolynomialCurve::new(vec![1.0, 2.0]).validate_coefficients().is_ok());
|
||||
assert!(PolynomialCurve::new(vec![f64::NAN]).validate_coefficients().is_err());
|
||||
assert!(PolynomialCurve::new(vec![f64::INFINITY]).validate_coefficients().is_err());
|
||||
assert!(PolynomialCurve::new(vec![1.0, 2.0])
|
||||
.validate_coefficients()
|
||||
.is_ok());
|
||||
assert!(PolynomialCurve::new(vec![f64::NAN])
|
||||
.validate_coefficients()
|
||||
.is_err());
|
||||
assert!(PolynomialCurve::new(vec![f64::INFINITY])
|
||||
.validate_coefficients()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// === ExponentialCurve ===
|
||||
|
||||
#[test]
|
||||
fn test_exponential_evaluate() {
|
||||
let c = ExponentialCurve { a: 2.0, b: 0.5, c: 1.0 }; // y = 2*exp(0.5x) + 1
|
||||
let c = ExponentialCurve {
|
||||
a: 2.0,
|
||||
b: 0.5,
|
||||
c: 1.0,
|
||||
}; // y = 2*exp(0.5x) + 1
|
||||
assert_relative_eq!(c.evaluate(0.0), 3.0); // 2*1 + 1
|
||||
assert_relative_eq!(c.evaluate(1.0), 2.0 * (0.5_f64).exp() + 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_derivative() {
|
||||
let c = ExponentialCurve { a: 2.0, b: 0.5, c: 1.0 };
|
||||
let c = ExponentialCurve {
|
||||
a: 2.0,
|
||||
b: 0.5,
|
||||
c: 1.0,
|
||||
};
|
||||
// dy/dx = 2*0.5*exp(0.5x) = exp(0.5x)
|
||||
assert_relative_eq!(c.derivative(0.0), 1.0);
|
||||
assert_relative_eq!(c.derivative(1.0), (0.5_f64).exp());
|
||||
@@ -734,8 +770,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_exponential_validate() {
|
||||
assert!(ExponentialCurve { a: 1.0, b: 2.0, c: 0.0 }.validate_coefficients().is_ok());
|
||||
assert!(ExponentialCurve { a: f64::NAN, b: 1.0, c: 0.0 }.validate_coefficients().is_err());
|
||||
assert!(ExponentialCurve {
|
||||
a: 1.0,
|
||||
b: 2.0,
|
||||
c: 0.0
|
||||
}
|
||||
.validate_coefficients()
|
||||
.is_ok());
|
||||
assert!(ExponentialCurve {
|
||||
a: f64::NAN,
|
||||
b: 1.0,
|
||||
c: 0.0
|
||||
}
|
||||
.validate_coefficients()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// === LogarithmicCurve ===
|
||||
@@ -765,14 +813,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_inverse_evaluate() {
|
||||
let c = InverseCurve { a: 6.0, b: 2.0, c: 1.0 }; // y = 6/(x+2) + 1
|
||||
let c = InverseCurve {
|
||||
a: 6.0,
|
||||
b: 2.0,
|
||||
c: 1.0,
|
||||
}; // y = 6/(x+2) + 1
|
||||
assert_relative_eq!(c.evaluate(0.0), 4.0); // 6/2 + 1
|
||||
assert_relative_eq!(c.evaluate(4.0), 2.0); // 6/6 + 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inverse_derivative() {
|
||||
let c = InverseCurve { a: 6.0, b: 2.0, c: 1.0 }; // dy/dx = -6/(x+2)²
|
||||
let c = InverseCurve {
|
||||
a: 6.0,
|
||||
b: 2.0,
|
||||
c: 1.0,
|
||||
}; // dy/dx = -6/(x+2)²
|
||||
assert_relative_eq!(c.derivative(0.0), -1.5); // -6/4
|
||||
assert_relative_eq!(c.derivative(4.0), -0.166_666_666_666_666_66); // -6/36
|
||||
}
|
||||
@@ -912,8 +968,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bounded_within_bounds() {
|
||||
let curve = BoundedCurve::new("test", CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0])))
|
||||
.with_bounds(0.0, 100.0);
|
||||
let curve = BoundedCurve::new(
|
||||
"test",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0])),
|
||||
)
|
||||
.with_bounds(0.0, 100.0);
|
||||
let result = curve.evaluate(50.0);
|
||||
assert_relative_eq!(result.value, 101.0);
|
||||
assert_relative_eq!(result.derivative, 2.0);
|
||||
@@ -922,8 +981,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bounded_below_min() {
|
||||
let curve = BoundedCurve::new("test", CurveEngine::Polynomial(PolynomialCurve::new(vec![0.0, 1.0])))
|
||||
.with_bounds(10.0, 100.0);
|
||||
let curve = BoundedCurve::new(
|
||||
"test",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![0.0, 1.0])),
|
||||
)
|
||||
.with_bounds(10.0, 100.0);
|
||||
let result = curve.evaluate(5.0);
|
||||
assert_relative_eq!(result.value, 5.0); // Still evaluates
|
||||
assert_eq!(result.warning, CurveWarning::BelowMinBound);
|
||||
@@ -931,8 +993,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bounded_above_max() {
|
||||
let curve = BoundedCurve::new("test", CurveEngine::Polynomial(PolynomialCurve::new(vec![0.0, 1.0])))
|
||||
.with_bounds(0.0, 100.0);
|
||||
let curve = BoundedCurve::new(
|
||||
"test",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![0.0, 1.0])),
|
||||
)
|
||||
.with_bounds(0.0, 100.0);
|
||||
let result = curve.evaluate(150.0);
|
||||
assert_relative_eq!(result.value, 150.0);
|
||||
assert_eq!(result.warning, CurveWarning::AboveMaxBound);
|
||||
@@ -940,7 +1005,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bounded_no_bounds() {
|
||||
let curve = BoundedCurve::new("test", CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])));
|
||||
let curve = BoundedCurve::new(
|
||||
"test",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])),
|
||||
);
|
||||
let result = curve.evaluate(9999.0);
|
||||
assert_eq!(result.warning, CurveWarning::WithinBounds);
|
||||
}
|
||||
@@ -956,8 +1024,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_curve_engine_type_name() {
|
||||
assert_eq!(CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])).curve_type_name(), "Polynomial");
|
||||
assert_eq!(CurveEngine::Exponential(ExponentialCurve { a: 1.0, b: 1.0, c: 0.0 }).curve_type_name(), "Exponential");
|
||||
assert_eq!(
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])).curve_type_name(),
|
||||
"Polynomial"
|
||||
);
|
||||
assert_eq!(
|
||||
CurveEngine::Exponential(ExponentialCurve {
|
||||
a: 1.0,
|
||||
b: 1.0,
|
||||
c: 0.0
|
||||
})
|
||||
.curve_type_name(),
|
||||
"Exponential"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -980,7 +1059,11 @@ mod tests {
|
||||
let valid = CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0]));
|
||||
assert!(valid.validate_coefficients().is_ok());
|
||||
|
||||
let invalid = CurveEngine::Exponential(ExponentialCurve { a: f64::NAN, b: 1.0, c: 0.0 });
|
||||
let invalid = CurveEngine::Exponential(ExponentialCurve {
|
||||
a: f64::NAN,
|
||||
b: 1.0,
|
||||
c: 0.0,
|
||||
});
|
||||
assert!(invalid.validate_coefficients().is_err());
|
||||
}
|
||||
|
||||
@@ -997,7 +1080,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_json_roundtrip_exponential() {
|
||||
let engine = CurveEngine::Exponential(ExponentialCurve { a: 1.5, b: -0.02, c: 0.1 });
|
||||
let engine = CurveEngine::Exponential(ExponentialCurve {
|
||||
a: 1.5,
|
||||
b: -0.02,
|
||||
c: 0.1,
|
||||
});
|
||||
let json = serde_json::to_string(&engine).unwrap();
|
||||
assert!(json.contains("\"type\":\"Exponential\""));
|
||||
let restored: CurveEngine = serde_json::from_str(&json).unwrap();
|
||||
@@ -1014,7 +1101,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_json_roundtrip_inverse() {
|
||||
let engine = CurveEngine::Inverse(InverseCurve { a: 10.0, b: 5.0, c: -2.0 });
|
||||
let engine = CurveEngine::Inverse(InverseCurve {
|
||||
a: 10.0,
|
||||
b: 5.0,
|
||||
c: -2.0,
|
||||
});
|
||||
let json = serde_json::to_string(&engine).unwrap();
|
||||
let restored: CurveEngine = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(engine, restored);
|
||||
@@ -1068,9 +1159,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_json_roundtrip_bounded_curve() {
|
||||
let curve = BoundedCurve::new("capacity", CurveEngine::Exponential(ExponentialCurve { a: 1.0, b: 0.5, c: 0.0 }))
|
||||
.with_bounds(-10.0, 50.0)
|
||||
.with_description("Compressor capacity curve");
|
||||
let curve = BoundedCurve::new(
|
||||
"capacity",
|
||||
CurveEngine::Exponential(ExponentialCurve {
|
||||
a: 1.0,
|
||||
b: 0.5,
|
||||
c: 0.0,
|
||||
}),
|
||||
)
|
||||
.with_bounds(-10.0, 50.0)
|
||||
.with_description("Compressor capacity curve");
|
||||
let json = serde_json::to_string(&curve).unwrap();
|
||||
let restored: BoundedCurve = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(curve.name, restored.name);
|
||||
@@ -1082,8 +1180,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_json_roundtrip_curve_set() {
|
||||
let mut set = CurveSet::new();
|
||||
set.insert(BoundedCurve::new("head", CurveEngine::Polynomial(PolynomialCurve::new(vec![50.0, -0.1, -0.001]))));
|
||||
set.insert(BoundedCurve::new("efficiency", CurveEngine::Polynomial(PolynomialCurve::new(vec![0.4, 0.02, -0.0001]))).with_bounds(0.0, 200.0));
|
||||
set.insert(BoundedCurve::new(
|
||||
"head",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![50.0, -0.1, -0.001])),
|
||||
));
|
||||
set.insert(
|
||||
BoundedCurve::new(
|
||||
"efficiency",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![0.4, 0.02, -0.0001])),
|
||||
)
|
||||
.with_bounds(0.0, 200.0),
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&set).unwrap();
|
||||
let restored: CurveSet = serde_json::from_str(&json).unwrap();
|
||||
@@ -1099,7 +1206,10 @@ mod tests {
|
||||
let mut set = CurveSet::new();
|
||||
assert!(set.is_empty());
|
||||
|
||||
set.insert(BoundedCurve::new("cap", CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0]))));
|
||||
set.insert(BoundedCurve::new(
|
||||
"cap",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0])),
|
||||
));
|
||||
assert_eq!(set.len(), 1);
|
||||
|
||||
let result = set.evaluate("cap", 3.0).unwrap();
|
||||
@@ -1111,11 +1221,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_curve_set_validate() {
|
||||
let mut set = CurveSet::new();
|
||||
set.insert(BoundedCurve::new("good", CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0]))));
|
||||
set.insert(BoundedCurve::new(
|
||||
"good",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0, 2.0])),
|
||||
));
|
||||
assert!(set.validate().is_ok());
|
||||
|
||||
let mut bad_set = CurveSet::new();
|
||||
bad_set.insert(BoundedCurve::new("bad", CurveEngine::Polynomial(PolynomialCurve::new(vec![f64::NAN]))));
|
||||
bad_set.insert(BoundedCurve::new(
|
||||
"bad",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![f64::NAN])),
|
||||
));
|
||||
assert!(bad_set.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -1143,15 +1259,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_inverse_zero_denominator() {
|
||||
let c = InverseCurve { a: 1.0, b: -5.0, c: 0.0 }; // y = 1/(x-5)
|
||||
// At x=5, denominator is 0 → infinity
|
||||
let c = InverseCurve {
|
||||
a: 1.0,
|
||||
b: -5.0,
|
||||
c: 0.0,
|
||||
}; // y = 1/(x-5)
|
||||
// At x=5, denominator is 0 → infinity
|
||||
let result = c.evaluate(5.0);
|
||||
assert!(result.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounded_curve_no_description_serialized() {
|
||||
let curve = BoundedCurve::new("test", CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])));
|
||||
let curve = BoundedCurve::new(
|
||||
"test",
|
||||
CurveEngine::Polynomial(PolynomialCurve::new(vec![1.0])),
|
||||
);
|
||||
let json = serde_json::to_string(&curve).unwrap();
|
||||
assert!(!json.contains("description")); // skip_serializing_if
|
||||
}
|
||||
|
||||
113
crates/components/src/dof.rs
Normal file
113
crates/components/src/dof.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Degrees-of-freedom labels declared by components.
|
||||
//!
|
||||
//! Each residual equation contributed by a [`crate::Component`] should eventually
|
||||
//! carry a semantic [`EquationRole`]. The solver aggregates these roles into a
|
||||
//! system-wide DoF ledger and enforces:
|
||||
//!
|
||||
//! ```text
|
||||
//! n_equations == n_unknowns
|
||||
//! ```
|
||||
//!
|
||||
//! # Fix / Free rule
|
||||
//!
|
||||
//! Adding an equation (a *fix*) without freeing an unknown (or dropping another
|
||||
//! residual) makes the system over-constrained. Outlet closures such as quality
|
||||
//! or superheat are typical DoF consumers and must be paired with a free
|
||||
//! actuator (EXV opening, compressor speed, level control, …).
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// What a residual equation is responsible for closing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EquationRole {
|
||||
/// Mass conservation on a named stream (`"refrigerant"`, `"secondary"`, …).
|
||||
MassConservation {
|
||||
/// Stream label.
|
||||
stream: &'static str,
|
||||
},
|
||||
/// Momentum / pressure-drop relation (ΔP or P_out − P_in − f(ṁ)).
|
||||
MomentumOrPressureDrop {
|
||||
/// Stream label.
|
||||
stream: &'static str,
|
||||
},
|
||||
/// Energy balance on a stream (`ṁ Δh ± Q = 0`).
|
||||
EnergyBalance {
|
||||
/// Stream label.
|
||||
stream: &'static str,
|
||||
},
|
||||
/// Outlet thermo closure that pins a state (SH, SC, quality, level…).
|
||||
OutletClosure {
|
||||
/// Closure kind label (`"superheat"`, `"subcooling"`, `"quality"`, …).
|
||||
kind: &'static str,
|
||||
},
|
||||
/// Boundary Dirichlet residual (source/sink imposed P, h, or ṁ).
|
||||
BoundaryDirichlet {
|
||||
/// Quantity label (`"P"`, `"h"`, `"m"`).
|
||||
quantity: &'static str,
|
||||
},
|
||||
/// Closing residual for a free actuator owned by the component.
|
||||
ActuatorClosure {
|
||||
/// Actuator name.
|
||||
name: &'static str,
|
||||
},
|
||||
/// Thermal-coupling duty residual at system level.
|
||||
CouplingDuty,
|
||||
/// Inverse-control tracking residual `measure − target`.
|
||||
ControlTracking {
|
||||
/// Constraint / control name.
|
||||
name: String,
|
||||
},
|
||||
/// Saturated-PI residual pair entry.
|
||||
SaturatedControl {
|
||||
/// Role within the pair (`"actuator_law"` or `"tracking"`).
|
||||
role: &'static str,
|
||||
},
|
||||
/// Pass-through continuity (Anchor, reversing valve inline, …).
|
||||
Continuity {
|
||||
/// Quantity label (`"P"` or `"h"`).
|
||||
quantity: &'static str,
|
||||
},
|
||||
/// Legacy placeholder when a component has not declared roles yet.
|
||||
Unspecified {
|
||||
/// Local residual index inside the component block.
|
||||
local_index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for EquationRole {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MassConservation { stream } => write!(f, "mass[{stream}]"),
|
||||
Self::MomentumOrPressureDrop { stream } => write!(f, "momentum[{stream}]"),
|
||||
Self::EnergyBalance { stream } => write!(f, "energy[{stream}]"),
|
||||
Self::OutletClosure { kind } => write!(f, "outlet_closure[{kind}]"),
|
||||
Self::BoundaryDirichlet { quantity } => write!(f, "boundary[{quantity}]"),
|
||||
Self::ActuatorClosure { name } => write!(f, "actuator[{name}]"),
|
||||
Self::CouplingDuty => write!(f, "coupling_duty"),
|
||||
Self::ControlTracking { name } => write!(f, "control[{name}]"),
|
||||
Self::SaturatedControl { role } => write!(f, "saturated[{role}]"),
|
||||
Self::Continuity { quantity } => write!(f, "continuity[{quantity}]"),
|
||||
Self::Unspecified { local_index } => write!(f, "unspecified[{local_index}]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds unspecified roles when a component has not migrated yet.
|
||||
pub fn unspecified_roles(n: usize) -> Vec<EquationRole> {
|
||||
(0..n)
|
||||
.map(|local_index| EquationRole::Unspecified { local_index })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_energy() {
|
||||
let r = EquationRole::EnergyBalance {
|
||||
stream: "secondary",
|
||||
};
|
||||
assert_eq!(r.to_string(), "energy[secondary]");
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,19 @@ pub struct Drum {
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
|
||||
/// When true, the drum is wired into the (P,h) graph solver and anchors its
|
||||
/// two outgoing edges (liquid + vapor) to the design saturation state.
|
||||
edge_coupled: bool,
|
||||
/// Design saturation temperature (K) used to anchor the drum pressure when
|
||||
/// edge-coupled. Mirrors the fixed-saturation philosophy of the EXV.
|
||||
t_sat_k: f64,
|
||||
/// Liquid outlet edge state indices (set by `set_system_context`).
|
||||
liq_p_idx: Option<usize>,
|
||||
liq_h_idx: Option<usize>,
|
||||
/// Vapor outlet edge state indices (set by `set_system_context`).
|
||||
vap_p_idx: Option<usize>,
|
||||
vap_h_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Drum {
|
||||
@@ -130,6 +143,12 @@ impl Drum {
|
||||
fluid_backend: backend,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
edge_coupled: false,
|
||||
t_sat_k: 0.0,
|
||||
liq_p_idx: None,
|
||||
liq_h_idx: None,
|
||||
vap_p_idx: None,
|
||||
vap_h_idx: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,6 +198,16 @@ impl Drum {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Enables (P,h) graph coupling and sets the design saturation temperature (K).
|
||||
///
|
||||
/// When enabled, the drum anchors both outgoing edges (liquid + vapor) to the
|
||||
/// saturation state at `t_sat_k`, contributing 4 equations (2 per outgoing edge).
|
||||
pub fn with_edge_coupling(mut self, t_sat_k: f64) -> Self {
|
||||
self.edge_coupled = true;
|
||||
self.t_sat_k = t_sat_k;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the feed inlet port.
|
||||
pub fn feed_inlet(&self) -> &ConnectedPort {
|
||||
&self.feed_inlet
|
||||
@@ -268,19 +297,45 @@ impl Clone for Drum {
|
||||
fluid_backend: Arc::clone(&self.fluid_backend),
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
edge_coupled: self.edge_coupled,
|
||||
t_sat_k: self.t_sat_k,
|
||||
liq_p_idx: self.liq_p_idx,
|
||||
liq_h_idx: self.liq_h_idx,
|
||||
vap_p_idx: self.vap_p_idx,
|
||||
vap_h_idx: self.vap_h_idx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Drum {
|
||||
/// Returns 8 equations:
|
||||
/// - 1 mass balance
|
||||
/// - 1 energy balance
|
||||
/// - 2 pressure equalities
|
||||
/// - 2 saturation constraints
|
||||
/// - 2 fluid continuity (implicit)
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: incoming edges first (feed, evaporator_return), then outgoing
|
||||
// edges (liquid, vapor) in declaration order. The drum owns only its two
|
||||
// outgoing edges → take the last two entries.
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
let n = external_edge_state_indices.len();
|
||||
if n >= 2 {
|
||||
let (_liq_m, liq_p, liq_h) = external_edge_state_indices[n - 2];
|
||||
let (_vap_m, vap_p, vap_h) = external_edge_state_indices[n - 1];
|
||||
self.liq_p_idx = Some(liq_p);
|
||||
self.liq_h_idx = Some(liq_h);
|
||||
self.vap_p_idx = Some(vap_p);
|
||||
self.vap_h_idx = Some(vap_h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy mass-flow model: 8 equations.
|
||||
/// Edge-coupled (P,h) model: 4 equations (2 per outgoing edge: liquid + vapor).
|
||||
fn n_equations(&self) -> usize {
|
||||
8
|
||||
if self.edge_coupled {
|
||||
4
|
||||
} else {
|
||||
8
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
@@ -296,6 +351,39 @@ impl Component for Drum {
|
||||
});
|
||||
}
|
||||
|
||||
// Edge-coupled (P,h) model: anchor both outgoing edges to the design
|
||||
// saturation state. The drum acts as a pressure anchor (like the EXV),
|
||||
// separating into saturated liquid (x=0) and saturated vapor (x=1).
|
||||
if self.edge_coupled {
|
||||
if let (Some(liq_p), Some(liq_h), Some(vap_p), Some(vap_h)) = (
|
||||
self.liq_p_idx,
|
||||
self.liq_h_idx,
|
||||
self.vap_p_idx,
|
||||
self.vap_h_idx,
|
||||
) {
|
||||
let fluid = FluidId::new(&self.fluid_id);
|
||||
let p_sat = self
|
||||
.fluid_backend
|
||||
.saturation_pressure_t(fluid, self.t_sat_k)
|
||||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
|
||||
|
||||
let h_sat_l = self.saturated_liquid_enthalpy(p_sat)?;
|
||||
let h_sat_v = self.saturated_vapor_enthalpy(p_sat)?;
|
||||
|
||||
// Liquid outlet (x=0)
|
||||
residuals[0] = state[liq_p] - p_sat;
|
||||
residuals[1] = state[liq_h] - h_sat_l;
|
||||
// Vapor outlet (x=1)
|
||||
residuals[2] = state[vap_p] - p_sat;
|
||||
residuals[3] = state[vap_h] - h_sat_v;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Drum edge-coupled model requires live liquid and vapor outlet edge indices"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if state.len() < 4 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 4,
|
||||
@@ -377,6 +465,21 @@ impl Component for Drum {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
if self.edge_coupled {
|
||||
if let (Some(liq_p), Some(liq_h), Some(vap_p), Some(vap_h)) = (
|
||||
self.liq_p_idx,
|
||||
self.liq_h_idx,
|
||||
self.vap_p_idx,
|
||||
self.vap_h_idx,
|
||||
) {
|
||||
// Saturation state is fixed by t_sat_k → derivatives are diagonal.
|
||||
jacobian.add_entry(0, liq_p, 1.0);
|
||||
jacobian.add_entry(1, liq_h, 1.0);
|
||||
jacobian.add_entry(2, vap_p, 1.0);
|
||||
jacobian.add_entry(3, vap_h, 1.0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let n_eqs = self.n_equations();
|
||||
for i in 0..n_eqs {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
@@ -554,11 +657,7 @@ mod tests {
|
||||
result
|
||||
);
|
||||
for (i, &r) in residuals.iter().enumerate() {
|
||||
assert!(
|
||||
r.is_finite(),
|
||||
"residual[{}] should be finite, got {}",
|
||||
i, r
|
||||
);
|
||||
assert!(r.is_finite(), "residual[{}] should be finite, got {}", i, r);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::port::{Connected, Disconnected, FluidId, Port};
|
||||
use crate::valve_flow::{valve_mass_flow, ValveFlowInput, ValveFlowModel};
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, StateSlice,
|
||||
@@ -104,6 +105,10 @@ pub struct ExpansionValve<State> {
|
||||
pub calib_indices: entropyk_core::CalibIndices,
|
||||
operational_state: OperationalState,
|
||||
opening: Option<f64>,
|
||||
/// Physical mass-flow capacity model (orifice / EXV / TXV Eames).
|
||||
flow_model: ValveFlowModel,
|
||||
/// TXV bulb pressure [Pa] (used when `flow_model` is [`ValveFlowModel::TxvEames`]).
|
||||
bulb_pressure_pa: f64,
|
||||
fluid_id: FluidId,
|
||||
circuit_id: CircuitId,
|
||||
_state: PhantomData<State>,
|
||||
@@ -158,12 +163,20 @@ impl ExpansionValve<Disconnected> {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
opening,
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id,
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the physical flow model (orifice / EXV CdA / TXV Eames).
|
||||
pub fn with_flow_model(mut self, model: ValveFlowModel) -> Self {
|
||||
self.flow_model = model;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the fluid identifier.
|
||||
pub fn fluid_id(&self) -> &FluidId {
|
||||
&self.fluid_id
|
||||
@@ -247,6 +260,8 @@ impl ExpansionValve<Disconnected> {
|
||||
calib_indices: self.calib_indices,
|
||||
operational_state: self.operational_state,
|
||||
opening: self.opening,
|
||||
flow_model: self.flow_model,
|
||||
bulb_pressure_pa: self.bulb_pressure_pa,
|
||||
fluid_id: self.fluid_id,
|
||||
circuit_id: self.circuit_id,
|
||||
_state: PhantomData,
|
||||
@@ -391,6 +406,34 @@ impl ExpansionValve<Connected> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the physical flow model (orifice / EXV CdA / TXV Eames).
|
||||
pub fn set_flow_model(&mut self, model: ValveFlowModel) {
|
||||
self.flow_model = model;
|
||||
}
|
||||
|
||||
/// Sets TXV bulb pressure [Pa].
|
||||
pub fn set_bulb_pressure(&mut self, p_pa: f64) {
|
||||
self.bulb_pressure_pa = p_pa;
|
||||
}
|
||||
|
||||
/// Rated mass-flow capacity from the selected physical flow model [kg/s].
|
||||
pub fn capacity_mass_flow(
|
||||
&self,
|
||||
density_kg_m3: f64,
|
||||
p_up_pa: f64,
|
||||
p_down_pa: f64,
|
||||
) -> Result<f64, ComponentError> {
|
||||
let input = ValveFlowInput {
|
||||
density_kg_m3,
|
||||
p_upstream_pa: p_up_pa,
|
||||
p_downstream_pa: p_down_pa,
|
||||
opening: self.opening.unwrap_or(1.0),
|
||||
p_bulb_pa: self.bulb_pressure_pa,
|
||||
};
|
||||
valve_mass_flow(&self.flow_model, &input)
|
||||
.map_err(ComponentError::InvalidState)
|
||||
}
|
||||
|
||||
/// Returns both ports as an array for solver topology.
|
||||
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
|
||||
[&self.port_inlet, &self.port_outlet]
|
||||
@@ -597,9 +640,9 @@ impl Component for ExpansionValve<Connected> {
|
||||
let mass_flow_out = state[1];
|
||||
let f_m = self
|
||||
.calib_indices
|
||||
.f_m
|
||||
.z_flow
|
||||
.map(|idx| state[idx])
|
||||
.unwrap_or(self.calib.f_m);
|
||||
.unwrap_or(self.calib.z_flow);
|
||||
residuals[1] = mass_flow_out - f_m * mass_flow_in;
|
||||
|
||||
Ok(())
|
||||
@@ -629,15 +672,15 @@ impl Component for ExpansionValve<Connected> {
|
||||
|
||||
let f_m = self
|
||||
.calib_indices
|
||||
.f_m
|
||||
.z_flow
|
||||
.map(|idx| _state[idx])
|
||||
.unwrap_or(self.calib.f_m);
|
||||
.unwrap_or(self.calib.z_flow);
|
||||
jacobian.add_entry(0, 0, 0.0);
|
||||
jacobian.add_entry(0, 1, 0.0);
|
||||
jacobian.add_entry(1, 0, -f_m);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
|
||||
if let Some(idx) = self.calib_indices.f_m {
|
||||
if let Some(idx) = self.calib_indices.z_flow {
|
||||
// d(R2)/d(f_m) = -mass_flow_in
|
||||
// We need mass_flow_in here, which is _state[0]
|
||||
let mass_flow_in = _state[0];
|
||||
@@ -726,7 +769,10 @@ impl Component for ExpansionValve<Connected> {
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("opening", self.opening)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -803,6 +849,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -987,6 +1035,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::Bypass,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1024,6 +1074,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(0.005),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1054,6 +1106,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(0.5),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1213,6 +1267,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1242,6 +1298,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1273,6 +1331,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::Bypass,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1373,6 +1433,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1406,6 +1468,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1440,6 +1504,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1473,6 +1539,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1506,6 +1574,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1539,6 +1609,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1572,6 +1644,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1634,6 +1708,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::Bypass,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
@@ -1692,6 +1768,8 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
operational_state: OperationalState::On,
|
||||
opening: Some(1.0),
|
||||
flow_model: ValveFlowModel::default(),
|
||||
bulb_pressure_pa: 0.0,
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
_state: PhantomData,
|
||||
|
||||
@@ -163,10 +163,29 @@ pub struct Fan<State> {
|
||||
air_density_kg_per_m3: f64,
|
||||
/// Speed ratio (0.0 to 1.0)
|
||||
speed_ratio: f64,
|
||||
/// VFD part-load efficiency curve (quadratic in speed ratio), default ≈0.97.
|
||||
vfd_eff_coeffs: [f64; 3],
|
||||
/// Motor part-load efficiency curve (quadratic in speed ratio), default ≈0.92.
|
||||
motor_eff_coeffs: [f64; 3],
|
||||
/// When true, `fan_power` returns wire-to-air electrical power.
|
||||
use_drive_chain: bool,
|
||||
/// Circuit identifier
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
/// When true, the fan participates in the (P,h) graph solver as a 2-port
|
||||
/// element imposing a design-point static pressure rise.
|
||||
edge_coupled: bool,
|
||||
/// Design volumetric flow (m³/s) at which the curve pressure rise is read.
|
||||
design_flow_m3_s: f64,
|
||||
/// Captured (m,P,h) global state indices of the inlet edge (incoming).
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Captured (m,P,h) global state indices of the outlet edge (outgoing).
|
||||
outlet_m_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Phantom data for type state
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
@@ -204,12 +223,41 @@ impl Fan<Disconnected> {
|
||||
port_outlet,
|
||||
air_density_kg_per_m3: air_density,
|
||||
speed_ratio: 1.0,
|
||||
vfd_eff_coeffs: [0.97, 0.0, 0.0],
|
||||
motor_eff_coeffs: [0.92, 0.0, 0.0],
|
||||
use_drive_chain: false,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
edge_coupled: false,
|
||||
design_flow_m3_s: 0.0,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables Bernier–Bourret wire-to-air drive chain (η_VFD × η_motor × η_fan).
|
||||
pub fn with_drive_chain(mut self, enabled: bool) -> Self {
|
||||
self.use_drive_chain = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets quadratic VFD efficiency coefficients η = a0 + a1·N* + a2·N*².
|
||||
pub fn with_vfd_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self {
|
||||
self.vfd_eff_coeffs = [a0, a1, a2];
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets quadratic motor efficiency coefficients η = a0 + a1·N* + a2·N*².
|
||||
pub fn with_motor_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self {
|
||||
self.motor_eff_coeffs = [a0, a1, a2];
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the fluid identifier.
|
||||
pub fn fluid_id(&self) -> &FluidId {
|
||||
self.port_inlet.fluid_id()
|
||||
@@ -235,6 +283,35 @@ impl Fan<Disconnected> {
|
||||
self.speed_ratio = ratio;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connects the fan to inlet and outlet ports, transitioning the type-state
|
||||
/// from `Disconnected` to `Connected` at compile time.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<Fan<Connected>, ComponentError> {
|
||||
let (p_in, _) = self
|
||||
.port_inlet
|
||||
.connect(inlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
let (p_out, _) = self
|
||||
.port_outlet
|
||||
.connect(outlet)
|
||||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||||
|
||||
let mut fan = Fan::<Connected>::from_connected_parts(
|
||||
self.curves,
|
||||
p_in,
|
||||
p_out,
|
||||
self.air_density_kg_per_m3,
|
||||
)?;
|
||||
fan.set_speed_ratio(self.speed_ratio)?;
|
||||
fan.vfd_eff_coeffs = self.vfd_eff_coeffs;
|
||||
fan.motor_eff_coeffs = self.motor_eff_coeffs;
|
||||
fan.use_drive_chain = self.use_drive_chain;
|
||||
Ok(fan)
|
||||
}
|
||||
}
|
||||
|
||||
impl Fan<Connected> {
|
||||
@@ -256,12 +333,36 @@ impl Fan<Connected> {
|
||||
port_outlet,
|
||||
air_density_kg_per_m3: air_density,
|
||||
speed_ratio: 1.0,
|
||||
vfd_eff_coeffs: [0.97, 0.0, 0.0],
|
||||
motor_eff_coeffs: [0.92, 0.0, 0.0],
|
||||
use_drive_chain: false,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
edge_coupled: false,
|
||||
design_flow_m3_s: 0.0,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Enables the edge-coupled (P,h) solver model, imposing a design-point
|
||||
/// static pressure rise read from the fan curve at `design_flow_m3_s`
|
||||
/// (and the current speed ratio). Shaft power is added to the air stream as
|
||||
/// an enthalpy rise so the coupled model satisfies the First Law.
|
||||
pub fn with_edge_coupling(mut self, design_flow_m3_s: f64) -> Self {
|
||||
self.design_flow_m3_s = design_flow_m3_s.max(0.0);
|
||||
self.edge_coupled = true;
|
||||
if self.operational_state == OperationalState::Off {
|
||||
self.operational_state = OperationalState::On;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the inlet port.
|
||||
pub fn port_inlet(&self) -> &Port<Connected> {
|
||||
&self.port_inlet
|
||||
@@ -341,10 +442,8 @@ impl Fan<Connected> {
|
||||
self.curves.efficiency_at_flow(equivalent_flow)
|
||||
}
|
||||
|
||||
/// Calculates the fan power consumption.
|
||||
///
|
||||
/// P_fan = Q × P_s / η
|
||||
pub fn fan_power(&self, flow_m3_per_s: f64) -> Power {
|
||||
/// Shaft aerodynamic power `Q × ΔP / η_fan` [W].
|
||||
pub fn shaft_power(&self, flow_m3_per_s: f64) -> Power {
|
||||
if flow_m3_per_s <= 0.0 || self.speed_ratio <= 0.0 {
|
||||
return Power::from_watts(0.0);
|
||||
}
|
||||
@@ -360,6 +459,64 @@ impl Fan<Connected> {
|
||||
Power::from_watts(power_w)
|
||||
}
|
||||
|
||||
/// Drive-chain efficiency η_VFD(N*) × η_motor(N*) at the current speed ratio.
|
||||
pub fn drive_chain_efficiency(&self) -> f64 {
|
||||
let n = self.speed_ratio.clamp(0.0, 1.0);
|
||||
let eta_vfd = (self.vfd_eff_coeffs[0]
|
||||
+ self.vfd_eff_coeffs[1] * n
|
||||
+ self.vfd_eff_coeffs[2] * n * n)
|
||||
.clamp(0.05, 1.0);
|
||||
let eta_motor = (self.motor_eff_coeffs[0]
|
||||
+ self.motor_eff_coeffs[1] * n
|
||||
+ self.motor_eff_coeffs[2] * n * n)
|
||||
.clamp(0.05, 1.0);
|
||||
eta_vfd * eta_motor
|
||||
}
|
||||
|
||||
/// Fan power consumption.
|
||||
///
|
||||
/// Shaft power by default; electrical wire-to-air power when drive chain is enabled:
|
||||
/// `P_elec = P_shaft / (η_VFD · η_motor)`.
|
||||
pub fn fan_power(&self, flow_m3_per_s: f64) -> Power {
|
||||
let shaft = self.shaft_power(flow_m3_per_s);
|
||||
if !self.use_drive_chain {
|
||||
return shaft;
|
||||
}
|
||||
let eta_chain = self.drive_chain_efficiency();
|
||||
if eta_chain <= 0.0 {
|
||||
return Power::from_watts(0.0);
|
||||
}
|
||||
Power::from_watts(shaft.to_watts() / eta_chain)
|
||||
}
|
||||
|
||||
/// Enables or disables the wire-to-air drive chain.
|
||||
pub fn set_drive_chain(&mut self, enabled: bool) {
|
||||
self.use_drive_chain = enabled;
|
||||
}
|
||||
|
||||
fn edge_coupled_flow_and_power(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
inlet_m_idx: usize,
|
||||
) -> Result<(f64, f64, f64), ComponentError> {
|
||||
let mass_flow_kg_s = state.get(inlet_m_idx).copied().ok_or_else(|| {
|
||||
ComponentError::InvalidState(format!(
|
||||
"Fan edge-coupled inlet mass-flow index {inlet_m_idx} is outside the state vector"
|
||||
))
|
||||
})?;
|
||||
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
|
||||
let power_w = match self.operational_state {
|
||||
OperationalState::Off | OperationalState::Bypass => 0.0,
|
||||
OperationalState::On => self.fan_power(flow_m3_s).to_watts(),
|
||||
};
|
||||
let enthalpy_rise_j_kg = if mass_flow_kg_s.abs() > 1e-12 {
|
||||
power_w / mass_flow_kg_s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok((flow_m3_s, power_w, enthalpy_rise_j_kg))
|
||||
}
|
||||
|
||||
/// Calculates mass flow from volumetric flow.
|
||||
pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow {
|
||||
MassFlow::from_kg_per_s(flow_m3_per_s * self.air_density_kg_per_m3)
|
||||
@@ -398,97 +555,99 @@ impl Fan<Connected> {
|
||||
}
|
||||
|
||||
impl Component for Fan<Connected> {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge, [1] = outgoing edge.
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_m_idx = Some(external_edge_state_indices[1].0);
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() != self.n_equations() {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: self.n_equations(),
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
residuals[0] = state[0];
|
||||
residuals[1] = 0.0;
|
||||
// Edge-coupled (P,h) model: impose a design-point static pressure rise.
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_m_idx,
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let dp = match self.operational_state {
|
||||
OperationalState::Off => 0.0,
|
||||
_ => {
|
||||
let (flow_m3_s, _, _) = self.edge_coupled_flow_and_power(state, in_m)?;
|
||||
self.static_pressure_rise(flow_m3_s)
|
||||
}
|
||||
};
|
||||
let (_, _, enthalpy_rise_j_kg) = self.edge_coupled_flow_and_power(state, in_m)?;
|
||||
// r0: imposed static pressure rise (fan adds pressure)
|
||||
residuals[0] = state[out_p] - (state[in_p] + dp);
|
||||
// r1: adiabatic fan casing, shaft power heats the air stream
|
||||
residuals[1] = state[out_h] - (state[in_h] + enthalpy_rise_j_kg);
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::Bypass => {
|
||||
let p_in = self.port_inlet.pressure().to_pascals();
|
||||
let p_out = self.port_outlet.pressure().to_pascals();
|
||||
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||||
|
||||
residuals[0] = p_in - p_out;
|
||||
residuals[1] = h_in - h_out;
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::On => {}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Fan edge-coupled model requires inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if state.len() < 2 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 2,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mass_flow_kg_s = state[0];
|
||||
let _power_w = state[1];
|
||||
|
||||
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
|
||||
let delta_p_calc = self.static_pressure_rise(flow_m3_s);
|
||||
|
||||
let p_in = self.port_inlet.pressure().to_pascals();
|
||||
let p_out = self.port_outlet.pressure().to_pascals();
|
||||
let delta_p_actual = p_out - p_in;
|
||||
|
||||
residuals[0] = delta_p_calc - delta_p_actual;
|
||||
|
||||
let power_calc = self.fan_power(flow_m3_s).to_watts();
|
||||
residuals[1] = power_calc - _power_w;
|
||||
|
||||
Ok(())
|
||||
Err(ComponentError::InvalidState(
|
||||
"Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
if state.len() < 2 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 2,
|
||||
actual: state.len(),
|
||||
});
|
||||
// Edge-coupled (P,h) model.
|
||||
if self.edge_coupled {
|
||||
if let (Some(_in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_m_idx,
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
// r0 = P_out - (P_in + dp)
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
// r1 = h_out - h_in
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Fan edge-coupled model requires inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mass_flow_kg_s = state[0];
|
||||
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
|
||||
|
||||
let h = 0.001;
|
||||
let p_plus = self.static_pressure_rise(flow_m3_s + h / self.air_density_kg_per_m3);
|
||||
let p_minus = self.static_pressure_rise(flow_m3_s - h / self.air_density_kg_per_m3);
|
||||
let dp_dm = (p_plus - p_minus) / (2.0 * h);
|
||||
|
||||
jacobian.add_entry(0, 0, dp_dm);
|
||||
jacobian.add_entry(0, 1, 0.0);
|
||||
|
||||
let pow_plus = self
|
||||
.fan_power(flow_m3_s + h / self.air_density_kg_per_m3)
|
||||
.to_watts();
|
||||
let pow_minus = self
|
||||
.fan_power(flow_m3_s - h / self.air_density_kg_per_m3)
|
||||
.to_watts();
|
||||
let dpow_dm = (pow_plus - pow_minus) / (2.0 * h);
|
||||
|
||||
jacobian.add_entry(1, 0, dpow_dm);
|
||||
jacobian.add_entry(1, 1, -1.0);
|
||||
|
||||
Ok(())
|
||||
Err(ComponentError::InvalidState(
|
||||
"Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
@@ -503,30 +662,57 @@ impl Component for Fan<Connected> {
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.len() < 1 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
if self.edge_coupled {
|
||||
let (Some(in_m), Some(out_m)) = (self.inlet_m_idx, self.outlet_m_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Fan edge-coupled model requires inlet and outlet mass-flow indices"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let max_idx = in_m.max(out_m);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
return Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(state[in_m]),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-state[out_m]),
|
||||
]);
|
||||
}
|
||||
// Fan has inlet and outlet with same mass flow (air is incompressible for HVAC applications)
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
// Inlet (positive = entering), Outlet (negative = leaving)
|
||||
Ok(vec![
|
||||
m,
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()),
|
||||
])
|
||||
|
||||
Err(ComponentError::InvalidState(
|
||||
"Fan mass-flow reporting requires edge-coupled inlet/outlet indices".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
// Fan uses internally simulated enthalpies
|
||||
Ok(vec![
|
||||
self.port_inlet.enthalpy(),
|
||||
self.port_outlet.enthalpy(),
|
||||
])
|
||||
if self.edge_coupled {
|
||||
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Fan edge-coupled model requires inlet and outlet enthalpy indices".to_string(),
|
||||
));
|
||||
};
|
||||
let max_idx = in_h.max(out_h);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
return Ok(vec![
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[in_h]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[out_h]),
|
||||
]);
|
||||
}
|
||||
|
||||
Err(ComponentError::InvalidState(
|
||||
"Fan enthalpy reporting requires edge-coupled inlet/outlet indices".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
@@ -539,10 +725,21 @@ impl Component for Fan<Connected> {
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
)),
|
||||
OperationalState::On => {
|
||||
if state.is_empty() {
|
||||
return None;
|
||||
if self.edge_coupled {
|
||||
let Some(in_m) = self.inlet_m_idx else {
|
||||
return None;
|
||||
};
|
||||
let Ok((_, power_w, _)) = self.edge_coupled_flow_and_power(state, in_m) else {
|
||||
return None;
|
||||
};
|
||||
return Some((
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
entropyk_core::Power::from_watts(-power_w),
|
||||
));
|
||||
}
|
||||
let mass_flow_kg_s = state[0];
|
||||
|
||||
let in_m = self.inlet_m_idx?;
|
||||
let mass_flow_kg_s = *state.get(in_m)?;
|
||||
let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3;
|
||||
let power_calc = self.fan_power(flow_m3_s).to_watts();
|
||||
Some((
|
||||
@@ -632,12 +829,33 @@ mod tests {
|
||||
port_outlet: outlet_conn,
|
||||
air_density_kg_per_m3: 1.2,
|
||||
speed_ratio: 1.0,
|
||||
vfd_eff_coeffs: [0.97, 0.0, 0.0],
|
||||
motor_eff_coeffs: [0.92, 0.0, 0.0],
|
||||
use_drive_chain: false,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
edge_coupled: false,
|
||||
design_flow_m3_s: 0.0,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wire_to_air_drive_chain_increases_power() {
|
||||
let mut fan = create_test_fan_connected();
|
||||
let shaft = fan.shaft_power(0.5).to_watts();
|
||||
fan.set_drive_chain(true);
|
||||
let elec = fan.fan_power(0.5).to_watts();
|
||||
assert!(elec > shaft);
|
||||
assert!((elec / shaft - 1.0 / (0.97 * 0.92)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fan_curves_creation() {
|
||||
let curves = create_test_curves();
|
||||
|
||||
@@ -144,6 +144,10 @@ pub struct FlowSplitter {
|
||||
inlet: ConnectedPort,
|
||||
/// Outlet ports (N branches).
|
||||
outlets: Vec<ConnectedPort>,
|
||||
/// Captured (P,h) global state indices of the inlet edge (incoming).
|
||||
inlet_idx: Option<(usize, usize)>,
|
||||
/// Captured (P,h) global state indices of the outlet edges (outgoing).
|
||||
outlet_idx: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl FlowSplitter {
|
||||
@@ -208,6 +212,8 @@ impl FlowSplitter {
|
||||
fluid_id: fluid,
|
||||
inlet,
|
||||
outlets,
|
||||
inlet_idx: None,
|
||||
outlet_idx: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,18 +246,37 @@ impl FlowSplitter {
|
||||
}
|
||||
|
||||
impl Component for FlowSplitter {
|
||||
/// `2N − 1` equations:
|
||||
/// - `N−1` pressure equalities
|
||||
/// - `N−1` enthalpy equalities
|
||||
/// - `1` mass balance (represented as N-th outlet consistency)
|
||||
/// `2N` equations — the splitter owns its `N` outgoing edges, contributing
|
||||
/// 2 equations (pressure + enthalpy) per branch:
|
||||
/// - `P_out_k − P_in = 0` for k = 0..N
|
||||
/// - `h_out_k − h_in = 0` for k = 0..N
|
||||
fn n_equations(&self) -> usize {
|
||||
let n = self.outlets.len();
|
||||
2 * n - 1
|
||||
2 * self.outlets.len()
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming inlet edge, [1..] = outgoing outlet edges.
|
||||
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
|
||||
if external_edge_state_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.inlet_idx = Some((
|
||||
external_edge_state_indices[0].1,
|
||||
external_edge_state_indices[0].2,
|
||||
));
|
||||
self.outlet_idx = external_edge_state_indices[1..]
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_eqs = self.n_equations();
|
||||
@@ -262,38 +287,21 @@ impl Component for FlowSplitter {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 (in_p, in_h) = match self.inlet_idx {
|
||||
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowSplitter requires one live inlet edge and all live outlet edges"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
|
||||
residuals[2 * k] = state[out_p] - state[in_p];
|
||||
residuals[2 * k + 1] = state[out_h] - state[in_h];
|
||||
}
|
||||
|
||||
// --- 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(())
|
||||
}
|
||||
|
||||
@@ -302,17 +310,23 @@ impl Component for FlowSplitter {
|
||||
_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);
|
||||
let (in_p, in_h) = match self.inlet_idx {
|
||||
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowSplitter Jacobian requires one live inlet edge and all live outlet edges"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
|
||||
// r[2k] = P_out_k - P_in
|
||||
jacobian.add_entry(2 * k, out_p, 1.0);
|
||||
jacobian.add_entry(2 * k, in_p, -1.0);
|
||||
// r[2k+1] = h_out_k - h_in
|
||||
jacobian.add_entry(2 * k + 1, out_h, 1.0);
|
||||
jacobian.add_entry(2 * k + 1, in_h, -1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -386,7 +400,11 @@ impl Component for FlowSplitter {
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("FlowSplitter(fluid={}, outlets={})", self.fluid_id, self.outlets.len())
|
||||
format!(
|
||||
"FlowSplitter(fluid={}, outlets={})",
|
||||
self.fluid_id,
|
||||
self.outlets.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
@@ -428,6 +446,10 @@ pub struct FlowMerger {
|
||||
outlet: ConnectedPort,
|
||||
/// Optional mass flow weights per inlet (kg/s). If None, equal weighting.
|
||||
mass_flow_weights: Option<Vec<f64>>,
|
||||
/// Captured (P,h) global state indices of the inlet edges (incoming).
|
||||
inlet_idx: Vec<(usize, usize)>,
|
||||
/// Captured (P,h) global state indices of the outlet edge (outgoing).
|
||||
outlet_idx: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl FlowMerger {
|
||||
@@ -482,6 +504,8 @@ impl FlowMerger {
|
||||
inlets,
|
||||
outlet,
|
||||
mass_flow_weights: None,
|
||||
inlet_idx: Vec::new(),
|
||||
outlet_idx: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -536,81 +560,90 @@ impl FlowMerger {
|
||||
|
||||
// ── Mixing helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes the mixed outlet enthalpy (weighted or equal).
|
||||
fn mixed_enthalpy(&self) -> f64 {
|
||||
/// Returns the mass-flow weights normalised so they sum to 1.0.
|
||||
///
|
||||
/// Falls back to equal weighting when no weights are set or the total is
|
||||
/// non-positive.
|
||||
fn normalized_weights(&self) -> Vec<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
|
||||
let total: f64 = weights.iter().sum();
|
||||
if total <= 0.0 {
|
||||
vec![1.0 / n as f64; n]
|
||||
} else {
|
||||
self.inlets
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w)
|
||||
.sum::<f64>()
|
||||
/ total_flow
|
||||
weights.iter().map(|&w| w / total).collect()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Equal weighting
|
||||
self.inlets
|
||||
.iter()
|
||||
.map(|p| p.enthalpy().to_joules_per_kg())
|
||||
.sum::<f64>()
|
||||
/ n as f64
|
||||
}
|
||||
None => vec![1.0 / n as f64; n],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FlowMerger {
|
||||
/// `N + 1` equations:
|
||||
/// - `N−1` pressure equalisations across inlets
|
||||
/// - `1` mixing enthalpy for the outlet
|
||||
/// - `1` outlet pressure consistency
|
||||
/// `2` equations — the merger owns its single outgoing edge:
|
||||
/// - `P_out − P_in_0 = 0` (outlet pressure = reference inlet pressure)
|
||||
/// - `h_out − Σ wₖ·h_in_k = 0` (mass-weighted enthalpy mixing)
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inlets.len() + 1
|
||||
2
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0..N] = incoming inlet edges, [N] = outgoing outlet edge.
|
||||
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
|
||||
let n = self.inlets.len();
|
||||
if external_edge_state_indices.len() >= n {
|
||||
self.inlet_idx = external_edge_state_indices[0..n]
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
if external_edge_state_indices.len() >= n + 1 {
|
||||
self.outlet_idx = Some((
|
||||
external_edge_state_indices[n].1,
|
||||
external_edge_state_indices[n].2,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_eqs = self.n_equations();
|
||||
if residuals.len() < n_eqs {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: n_eqs,
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let p_ref = self.inlets[0].pressure().to_pascals();
|
||||
let mut r_idx = 0;
|
||||
let (out_p, out_h) = match self.outlet_idx {
|
||||
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowMerger requires all live inlet edges and one live outlet edge".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
// r0: outlet pressure tracks the (reference) first inlet pressure.
|
||||
let p_ref = state[self.inlet_idx[0].0];
|
||||
residuals[0] = state[out_p] - p_ref;
|
||||
|
||||
// --- 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;
|
||||
// r1: mass-weighted enthalpy mixing.
|
||||
let weights = self.normalized_weights();
|
||||
let h_mix: f64 = self
|
||||
.inlet_idx
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(&(_, h_idx), &w)| w * state[h_idx])
|
||||
.sum();
|
||||
residuals[1] = state[out_h] - h_mix;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -620,11 +653,25 @@ impl Component for FlowMerger {
|
||||
_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);
|
||||
let (out_p, out_h) = match self.outlet_idx {
|
||||
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FlowMerger Jacobian requires all live inlet edges and one live outlet edge"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// r0 = P_out - P_in_0
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, self.inlet_idx[0].0, -1.0);
|
||||
|
||||
// r1 = h_out - Σ wₖ·h_in_k
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
let weights = self.normalized_weights();
|
||||
for (&(_, h_idx), &w) in self.inlet_idx.iter().zip(weights.iter()) {
|
||||
jacobian.add_entry(1, h_idx, -w);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -693,7 +740,11 @@ impl Component for FlowMerger {
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!("FlowMerger(fluid={}, inlets={})", self.fluid_id, self.inlets.len())
|
||||
format!(
|
||||
"FlowMerger(fluid={}, inlets={})",
|
||||
self.fluid_id,
|
||||
self.inlets.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
@@ -762,8 +813,8 @@ mod tests {
|
||||
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);
|
||||
// n_equations = 2*N = 4 (owns 2 outgoing edges, P+h each)
|
||||
assert_eq!(s.n_equations(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -776,8 +827,8 @@ mod tests {
|
||||
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);
|
||||
// n_equations = 2*N = 6
|
||||
assert_eq!(s.n_equations(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -802,13 +853,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_splitter_residuals_zero_at_consistent_state() {
|
||||
// Consistent state: all pressures and enthalpies equal
|
||||
// Consistent state: all branch pressures/enthalpies equal the inlet.
|
||||
let inlet = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_a = make_port("Water", 3.0e5, 2.0e5);
|
||||
let out_b = make_port("Water", 3.0e5, 2.0e5);
|
||||
|
||||
let 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 s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
// Edges (CM1.3 stride-3): inlet=(ṁ@0,P@1,h@2), out_a=(ṁ@3,P@4,h@5), out_b=(ṁ@6,P@7,h@8)
|
||||
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
// State: ṁ slots are don't-care (splitter ignores them); P and h are consistent.
|
||||
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
|
||||
let mut res = vec![0.0; s.n_equations()];
|
||||
s.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
@@ -825,11 +880,14 @@ mod tests {
|
||||
#[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_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];
|
||||
let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
|
||||
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
// out_a pressure lower than inlet by 0.5e5; ṁ slots are don't-care.
|
||||
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 2.5e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
|
||||
let mut res = vec![0.0; s.n_equations()];
|
||||
s.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
@@ -846,8 +904,8 @@ mod tests {
|
||||
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);
|
||||
// N=3 → 2*N = 6
|
||||
assert_eq!(s.n_equations(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -872,8 +930,8 @@ mod tests {
|
||||
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);
|
||||
// Merger owns its single outgoing edge → 2 equations (P + h)
|
||||
assert_eq!(m.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -886,8 +944,8 @@ mod tests {
|
||||
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);
|
||||
// Merger owns its single outgoing edge → 2 equations (P + h)
|
||||
assert_eq!(m.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -907,8 +965,11 @@ mod tests {
|
||||
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 m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
|
||||
// Edges (CM1.3 stride-3): in_a=(ṁ@0,P@1,h@2), in_b=(ṁ@3,P@4,h@5), outlet=(ṁ@6,P@7,h@8)
|
||||
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
let state = vec![0.0, p, h, 0.0, p, h, 0.0, p, h];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
@@ -928,8 +989,10 @@ mod tests {
|
||||
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 m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
|
||||
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
let state = vec![0.0, p, h_a, 0.0, p, h_b, 0.0, p, h_expected];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
@@ -948,12 +1011,13 @@ mod tests {
|
||||
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)
|
||||
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
|
||||
.unwrap()
|
||||
.with_mass_flows(vec![0.3, 0.7])
|
||||
.unwrap();
|
||||
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
let state = vec![0.0; 6];
|
||||
let state = vec![0.0, p, 2.0e5, 0.0, p, 3.0e5, 0.0, p, 2.7e5];
|
||||
let mut res = vec![0.0; m.n_equations()];
|
||||
m.compute_residuals(&state, &mut res).unwrap();
|
||||
|
||||
@@ -973,7 +1037,7 @@ mod tests {
|
||||
|
||||
let merger: Box<dyn Component> =
|
||||
Box::new(FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap());
|
||||
assert_eq!(merger.n_equations(), 3);
|
||||
assert_eq!(merger.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -984,7 +1048,7 @@ mod tests {
|
||||
|
||||
let splitter: Box<dyn Component> =
|
||||
Box::new(FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b]).unwrap());
|
||||
assert_eq!(splitter.n_equations(), 3);
|
||||
assert_eq!(splitter.n_equations(), 4);
|
||||
}
|
||||
|
||||
// ── energy_transfers tests ─────────────────────────────────────────────────
|
||||
|
||||
@@ -92,9 +92,9 @@ pub struct FreeCoolingExchanger {
|
||||
/// Fluid backend for property calculations
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
/// Calibration factor for UA scaling (default 1.0)
|
||||
f_ua: f64,
|
||||
z_ua: f64,
|
||||
/// Calibration factor for pressure drop scaling (default 1.0)
|
||||
f_dp: f64,
|
||||
z_dp: f64,
|
||||
/// Calibration indices for inverse calibration
|
||||
calib_indices: CalibIndices,
|
||||
}
|
||||
@@ -109,8 +109,8 @@ impl std::fmt::Debug for FreeCoolingExchanger {
|
||||
.field("outdoor_temp", &self.outdoor_temp)
|
||||
.field("heat_transfer_rate", &self.heat_transfer_rate)
|
||||
.field("current_effectiveness", &self.current_effectiveness)
|
||||
.field("f_ua", &self.f_ua)
|
||||
.field("f_dp", &self.f_dp)
|
||||
.field("f_ua", &self.z_ua)
|
||||
.field("f_dp", &self.z_dp)
|
||||
.field("fluid_backend", &"<FluidBackend>")
|
||||
.finish()
|
||||
}
|
||||
@@ -150,13 +150,18 @@ impl FreeCoolingExchanger {
|
||||
circuit_id,
|
||||
config,
|
||||
mode: FreeCoolingMode::Bypass,
|
||||
ports: [port_cold_inlet, port_cold_outlet, port_hot_inlet, port_hot_outlet],
|
||||
ports: [
|
||||
port_cold_inlet,
|
||||
port_cold_outlet,
|
||||
port_hot_inlet,
|
||||
port_hot_outlet,
|
||||
],
|
||||
outdoor_temp: None,
|
||||
heat_transfer_rate: None,
|
||||
current_effectiveness,
|
||||
fluid_backend: None,
|
||||
f_ua: 1.0,
|
||||
f_dp: 1.0,
|
||||
z_ua: 1.0,
|
||||
z_dp: 1.0,
|
||||
calib_indices: CalibIndices::default(),
|
||||
})
|
||||
}
|
||||
@@ -229,7 +234,8 @@ impl FreeCoolingExchanger {
|
||||
}
|
||||
_ => {
|
||||
if t_outdoor.0
|
||||
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
|
||||
> (t_cold_in - self.config.min_outdoor_temp
|
||||
+ self.config.hysteresis)
|
||||
{
|
||||
self.mode = FreeCoolingMode::Bypass;
|
||||
}
|
||||
@@ -245,24 +251,24 @@ impl FreeCoolingExchanger {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the f_ua calibration factor
|
||||
pub fn set_f_ua(&mut self, f_ua: f64) {
|
||||
self.f_ua = f_ua;
|
||||
/// Sets the z_ua calibration factor (BOLT `Z_UA`).
|
||||
pub fn set_z_ua(&mut self, z_ua: f64) {
|
||||
self.z_ua = z_ua;
|
||||
}
|
||||
|
||||
/// Returns the f_ua calibration factor
|
||||
pub fn f_ua(&self) -> f64 {
|
||||
self.f_ua
|
||||
/// Returns the z_ua calibration factor.
|
||||
pub fn z_ua(&self) -> f64 {
|
||||
self.z_ua
|
||||
}
|
||||
|
||||
/// Sets the f_dp calibration factor
|
||||
pub fn set_f_dp(&mut self, f_dp: f64) {
|
||||
self.f_dp = f_dp;
|
||||
/// Sets the z_dp calibration factor (BOLT `Z_dpc`).
|
||||
pub fn set_z_dp(&mut self, z_dp: f64) {
|
||||
self.z_dp = z_dp;
|
||||
}
|
||||
|
||||
/// Returns the f_dp calibration factor
|
||||
pub fn f_dp(&self) -> f64 {
|
||||
self.f_dp
|
||||
/// Returns the z_dp calibration factor.
|
||||
pub fn z_dp(&self) -> f64 {
|
||||
self.z_dp
|
||||
}
|
||||
|
||||
/// Returns the current operational state
|
||||
@@ -414,7 +420,7 @@ impl Component for FreeCoolingExchanger {
|
||||
let c_min = c_cold.min(c_hot);
|
||||
|
||||
// UA with calibration scaling
|
||||
let ua_eff = self.f_ua * self.config.ua;
|
||||
let ua_eff = self.z_ua * self.config.ua;
|
||||
|
||||
// ε-NTU effectiveness
|
||||
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
|
||||
@@ -442,8 +448,7 @@ impl Component for FreeCoolingExchanger {
|
||||
residuals[0] = m_cold * dh_cold - q;
|
||||
residuals[1] = m_hot * dh_hot + q;
|
||||
residuals[2] = m_cold * dh_cold + m_hot * dh_hot;
|
||||
residuals[3] =
|
||||
(p_cold_in - p_cold_out) - self.f_dp * self.config.cold_dp_nominal;
|
||||
residuals[3] = (p_cold_in - p_cold_out) - self.z_dp * self.config.cold_dp_nominal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,8 +461,8 @@ impl Component for FreeCoolingExchanger {
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Jacobian entries for calibration variable sensitivities
|
||||
if let Some(f_ua_idx) = self.calib_indices.f_ua {
|
||||
// ∂r[0]/∂f_ua: cold-side energy balance sensitivity
|
||||
if let Some(z_ua_idx) = self.calib_indices.z_ua {
|
||||
// ∂r[0]/∂z_ua: cold-side energy balance sensitivity
|
||||
// r[0] = m_cold * (h_cold_out - h_cold_in) - Q(f_ua)
|
||||
// ∂r[0]/∂f_ua = -∂Q/∂f_ua = -ε × C_min × (T_hot_in - T_cold_in) × UA_nominal
|
||||
let m_cold = self.config.cold_mass_flow;
|
||||
@@ -468,28 +473,26 @@ impl Component for FreeCoolingExchanger {
|
||||
|
||||
let h_cold_in = self.port_enthalpy_raw(COLD_INLET);
|
||||
let h_hot_in = self.port_enthalpy_raw(HOT_INLET);
|
||||
let t_cold_in =
|
||||
self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
|
||||
let t_hot_in =
|
||||
self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);
|
||||
let t_cold_in = self.temperature_from_enthalpy(h_cold_in, self.config.cold_cp);
|
||||
let t_hot_in = self.temperature_from_enthalpy(h_hot_in, self.config.hot_cp);
|
||||
|
||||
let dt = t_hot_in - t_cold_in;
|
||||
// Approximate ∂Q/∂f_ua ≈ C_min × dt × (∂ε/∂f_ua) × UA_nominal
|
||||
// For small variations: ∂Q/∂f_ua ≈ Q / f_ua when linearized
|
||||
let ua_eff = self.f_ua * self.config.ua;
|
||||
let ua_eff = self.z_ua * self.config.ua;
|
||||
let eps = self.compute_effectiveness(ua_eff, c_cold, c_hot);
|
||||
let q_per_f_ua = eps * c_min * dt; // Q / f_ua at current operating point
|
||||
|
||||
jacobian.add_entry(0, f_ua_idx, -q_per_f_ua);
|
||||
jacobian.add_entry(1, f_ua_idx, q_per_f_ua);
|
||||
jacobian.add_entry(0, z_ua_idx, -q_per_f_ua);
|
||||
jacobian.add_entry(1, z_ua_idx, q_per_f_ua);
|
||||
// r[2] = r[0] + r[1], so ∂r[2]/∂f_ua = ∂r[0]/∂f_ua + ∂r[1]/∂f_ua = 0
|
||||
jacobian.add_entry(2, f_ua_idx, 0.0);
|
||||
jacobian.add_entry(2, z_ua_idx, 0.0);
|
||||
}
|
||||
|
||||
if let Some(f_dp_idx) = self.calib_indices.f_dp {
|
||||
if let Some(z_dp_idx) = self.calib_indices.z_dp {
|
||||
// r[3] = (P_cold_in - P_cold_out) - f_dp × ΔP_nominal
|
||||
// ∂r[3]/∂f_dp = -ΔP_nominal
|
||||
jacobian.add_entry(3, f_dp_idx, -self.config.cold_dp_nominal);
|
||||
jacobian.add_entry(3, z_dp_idx, -self.config.cold_dp_nominal);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -499,10 +502,7 @@ impl Component for FreeCoolingExchanger {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
) {
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
@@ -517,10 +517,7 @@ impl Component for FreeCoolingExchanger {
|
||||
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
fn port_enthalpies(&self, _state: &[f64]) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
Ok(vec![
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_INLET)),
|
||||
Enthalpy::from_joules_per_kg(self.port_enthalpy_raw(COLD_OUTLET)),
|
||||
@@ -532,7 +529,7 @@ impl Component for FreeCoolingExchanger {
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FreeCoolingExchanger(id={},eff={},ua={},mode={:?},f_ua={},f_dp={})",
|
||||
self.id, self.config.effectiveness, self.config.ua, self.mode, self.f_ua, self.f_dp
|
||||
self.id, self.config.effectiveness, self.config.ua, self.mode, self.z_ua, self.z_dp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -547,19 +544,19 @@ impl Component for FreeCoolingExchanger {
|
||||
.with_param("coldCp", self.config.cold_cp)
|
||||
.with_param("hotCp", self.config.hot_cp)
|
||||
.with_param("bypassFraction", self.config.bypass_fraction)
|
||||
.with_param("f_ua", self.f_ua)
|
||||
.with_param("f_dp", self.f_dp)
|
||||
.with_param("f_ua", self.z_ua)
|
||||
.with_param("f_dp", self.z_dp)
|
||||
.with_param("mode", format!("{:?}", self.mode))
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
match factor {
|
||||
"f_ua" => {
|
||||
self.f_ua = value;
|
||||
self.z_ua = value;
|
||||
true
|
||||
}
|
||||
"f_dp" => {
|
||||
self.f_dp = value;
|
||||
self.z_dp = value;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -575,7 +572,7 @@ impl Default for FreeCoolingConfig {
|
||||
min_outdoor_temp: 285.15,
|
||||
hysteresis: 2.0,
|
||||
control_mode: FreeCoolingControlMode::AutoTemperature,
|
||||
ua: 10_000.0, // 10 kW/K typical for plate HX
|
||||
ua: 10_000.0, // 10 kW/K typical for plate HX
|
||||
cold_mass_flow: 0.5,
|
||||
hot_mass_flow: 0.5,
|
||||
cold_cp: CP_WATER,
|
||||
@@ -712,9 +709,7 @@ mod tests {
|
||||
bypass_fraction: 0.3,
|
||||
};
|
||||
let expected = 85.0 * 0.3;
|
||||
assert!(
|
||||
(exchanger.energy_savings_percent() - expected).abs() < 1e-10
|
||||
);
|
||||
assert!((exchanger.energy_savings_percent() - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -754,11 +749,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_residuals_bypass_mode() {
|
||||
let (ci, co, hi, ho) = make_connected_ports_with(
|
||||
Pressure::from_pascals(3e5),
|
||||
50_000.0,
|
||||
105_000.0,
|
||||
);
|
||||
let (ci, co, hi, ho) =
|
||||
make_connected_ports_with(Pressure::from_pascals(3e5), 50_000.0, 105_000.0);
|
||||
let fc = FreeCoolingExchanger::new(
|
||||
"fc_test",
|
||||
CircuitId(0),
|
||||
@@ -794,7 +786,7 @@ mod tests {
|
||||
|
||||
// With f_ua calib index
|
||||
let mut fc = fc;
|
||||
fc.calib_indices.f_ua = Some(100);
|
||||
fc.calib_indices.z_ua = Some(100);
|
||||
let mut jb = JacobianBuilder::new();
|
||||
fc.jacobian_entries(&[], &mut jb).unwrap();
|
||||
assert!(!jb.entries().is_empty(), "Should have f_ua entries");
|
||||
@@ -811,7 +803,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_jacobian_with_f_dp() {
|
||||
let mut fc = make_exchanger_active();
|
||||
fc.calib_indices.f_dp = Some(200);
|
||||
fc.calib_indices.z_dp = Some(200);
|
||||
fc.config.cold_dp_nominal = 5000.0;
|
||||
|
||||
let mut jb = JacobianBuilder::new();
|
||||
@@ -843,7 +835,7 @@ mod tests {
|
||||
fn test_calibration_scaling() {
|
||||
let fc1 = make_exchanger_active();
|
||||
let mut fc2 = make_exchanger_active();
|
||||
fc2.f_ua = 1.5; // 50% higher UA
|
||||
fc2.z_ua = 1.5; // 50% higher UA
|
||||
|
||||
let mut r1 = vec![0.0; N_EQUATIONS];
|
||||
let mut r2 = vec![0.0; N_EQUATIONS];
|
||||
@@ -875,13 +867,13 @@ mod tests {
|
||||
fn test_set_calib_indices() {
|
||||
let mut fc = make_exchanger_active();
|
||||
let indices = CalibIndices {
|
||||
f_ua: Some(10),
|
||||
f_dp: Some(20),
|
||||
z_ua: Some(10),
|
||||
z_dp: Some(20),
|
||||
..Default::default()
|
||||
};
|
||||
fc.set_calib_indices(indices);
|
||||
assert_eq!(fc.calib_indices.f_ua, Some(10));
|
||||
assert_eq!(fc.calib_indices.f_dp, Some(20));
|
||||
assert_eq!(fc.calib_indices.z_ua, Some(10));
|
||||
assert_eq!(fc.calib_indices.z_dp, Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
302
crates/components/src/heat_exchanger/air_cooled_condenser.rs
Normal file
302
crates/components/src/heat_exchanger/air_cooled_condenser.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
//! Air-Cooled Condenser Component
|
||||
//!
|
||||
//! A condenseur à condensation par air où l'ingénieur fournit directement :
|
||||
//! - `oat_k` : Outdoor Air Temperature [K] (OAT = température de l'air extérieur)
|
||||
//! - `approach_k` : Approach temperature [K], différence de température entre la
|
||||
//! condensation et l'air extérieur. Valeur typique : 10–15 K.
|
||||
//! Défaut : 12 K (représentatif d'un chiller air-cooled industriel).
|
||||
//!
|
||||
//! La température de condensation est calculée automatiquement :
|
||||
//! ```text
|
||||
//! T_cond = OAT + approach_k
|
||||
//! ```
|
||||
//!
|
||||
//! ## Équations (2)
|
||||
//!
|
||||
//! - r0 = P_out − P_sat(T_cond) [drive outlet pressure to condensing saturation]
|
||||
//! - r1 = H_out − H_sat_liq(T_cond) [drive outlet enthalpy to saturated-liquid]
|
||||
//!
|
||||
//! ## Jacobian (analytique)
|
||||
//!
|
||||
//! - ∂r0/∂P_out = 1
|
||||
//! - ∂r1/∂H_out = 1
|
||||
//!
|
||||
//! ## Exemple JSON
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "type": "AirCooledCondenser",
|
||||
//! "name": "cond",
|
||||
//! "oat_k": 308.15,
|
||||
//! "approach_k": 12.0
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Soit : OAT = 35°C → T_cond = 47°C → P_cond_sat(R290) ≈ 16.8 bar
|
||||
|
||||
use super::condenser::Condenser;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::Calib;
|
||||
use entropyk_fluids::FluidBackend;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Air-cooled condenser : T_cond = OAT + approach.
|
||||
///
|
||||
/// L'ingénieur saisit directement la température extérieure (OAT) et
|
||||
/// l'approche de condensation (approach_k). Aucune conversion manuelle
|
||||
/// en `t_sat_k` n'est nécessaire.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::heat_exchanger::AirCooledCondenser;
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// // OAT = 35°C, approach = 12 K → T_cond = 47°C = 320.15 K
|
||||
/// let cond = AirCooledCondenser::new(308.15, 12.0);
|
||||
/// assert_eq!(cond.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
|
||||
/// assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct AirCooledCondenser {
|
||||
inner: Condenser,
|
||||
oat_k: f64,
|
||||
approach_k: f64,
|
||||
}
|
||||
|
||||
impl AirCooledCondenser {
|
||||
/// Crée un condenseur à air.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `oat_k` — Outdoor Air Temperature \[K\]
|
||||
/// * `approach_k` — Approach temperature = T_cond − OAT \[K\]. Valeur typique : 10–15 K.
|
||||
pub fn new(oat_k: f64, approach_k: f64) -> Self {
|
||||
let t_cond_k = oat_k + approach_k;
|
||||
Self {
|
||||
inner: Condenser::with_saturation_temp(0.0, t_cond_k),
|
||||
oat_k,
|
||||
approach_k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un condenseur à air avec UA connu.
|
||||
///
|
||||
/// Le UA n'est pas utilisé dans les équations du solver (approach-based),
|
||||
/// mais il est disponible pour les calculs de performance post-traitement.
|
||||
pub fn with_ua(oat_k: f64, approach_k: f64, ua: f64) -> Self {
|
||||
let t_cond_k = oat_k + approach_k;
|
||||
Self {
|
||||
inner: Condenser::with_saturation_temp(ua, t_cond_k),
|
||||
oat_k,
|
||||
approach_k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attache un identifiant de fluide réfrigérant.
|
||||
pub fn with_refrigerant(mut self, id: &str) -> Self {
|
||||
self.inner = self.inner.with_refrigerant(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attache un backend de propriétés thermodynamiques.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.inner = self.inner.with_fluid_backend(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Retourne la température de condensation calculée [K].
|
||||
pub fn t_cond_k(&self) -> f64 {
|
||||
self.oat_k + self.approach_k
|
||||
}
|
||||
|
||||
/// Retourne la température extérieure (OAT) [K].
|
||||
pub fn oat_k(&self) -> f64 {
|
||||
self.oat_k
|
||||
}
|
||||
|
||||
/// Retourne l'approche de condensation [K].
|
||||
pub fn approach_k(&self) -> f64 {
|
||||
self.approach_k
|
||||
}
|
||||
|
||||
/// Retourne le UA [W/K].
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
/// Retourne les facteurs de calibration.
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
/// Met à jour les facteurs de calibration.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.inner.set_calib(calib);
|
||||
}
|
||||
|
||||
/// Met à jour OAT en runtime (ex. simulation paramétrique).
|
||||
pub fn set_oat_k(&mut self, oat_k: f64) {
|
||||
self.oat_k = oat_k;
|
||||
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
|
||||
}
|
||||
|
||||
/// Met à jour l'approche en runtime.
|
||||
pub fn set_approach_k(&mut self, approach_k: f64) {
|
||||
self.approach_k = approach_k;
|
||||
self.inner.set_saturation_temp(self.oat_k + self.approach_k);
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for AirCooledCondenser {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.inner
|
||||
.set_system_context(state_offset, external_edge_state_indices);
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.compute_residuals(state, residuals)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inner.n_equations()
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
self.inner.port_enthalpies(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"AirCooledCondenser(oat={:.1}K, approach={:.1}K, t_cond={:.1}K)",
|
||||
self.oat_k,
|
||||
self.approach_k,
|
||||
self.t_cond_k()
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for AirCooledCondenser {
|
||||
fn state(&self) -> OperationalState {
|
||||
self.inner.state()
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
self.inner.set_state(state)
|
||||
}
|
||||
|
||||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||
self.inner.can_transition_to(target)
|
||||
}
|
||||
|
||||
fn circuit_id(&self) -> &CircuitId {
|
||||
self.inner.circuit_id()
|
||||
}
|
||||
|
||||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||
self.inner.set_circuit_id(circuit_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_t_cond_computed() {
|
||||
// OAT = 35°C = 308.15 K, approach = 12 K → T_cond = 47°C = 320.15 K
|
||||
let cond = AirCooledCondenser::new(308.15, 12.0);
|
||||
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||||
assert_eq!(cond.oat_k(), 308.15);
|
||||
assert_eq!(cond.approach_k(), 12.0);
|
||||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
|
||||
assert_eq!(cond.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_ua() {
|
||||
let cond = AirCooledCondenser::with_ua(308.15, 12.0, 6000.0);
|
||||
assert_eq!(cond.ua(), 6000.0);
|
||||
assert!((cond.t_cond_k() - 320.15).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_oat_updates_t_cond() {
|
||||
let mut cond = AirCooledCondenser::new(308.15, 12.0);
|
||||
// Change OAT to 40°C = 313.15 K → T_cond = 52°C = 325.15 K
|
||||
cond.set_oat_k(313.15);
|
||||
assert!((cond.t_cond_k() - 325.15).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approach_updates_t_cond() {
|
||||
let mut cond = AirCooledCondenser::new(308.15, 12.0);
|
||||
cond.set_approach_k(10.0);
|
||||
assert!((cond.t_cond_k() - 318.15).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_backend_does_not_fabricate_residuals() {
|
||||
let cond = AirCooledCondenser::new(308.15, 12.0);
|
||||
let state = vec![0.0_f64; 10];
|
||||
let mut residuals = vec![99.0_f64; 3];
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@
|
||||
//! assert_eq!(cond.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::CorrelationSelectionError;
|
||||
use super::exchanger::HxSideConditions;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
@@ -105,17 +106,22 @@ impl BphxCondenser {
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
self.inner.set_refrigerant_id(self.refrigerant_id.clone());
|
||||
self.inner.set_hot_fluid(self.refrigerant_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the secondary fluid identifier (water, brine, etc.).
|
||||
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.secondary_fluid_id = fluid.into();
|
||||
self.inner.set_cold_fluid(self.secondary_fluid_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for property queries.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.inner
|
||||
.set_fluid_backend_from_builder(Arc::clone(&backend));
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
@@ -298,7 +304,7 @@ impl BphxCondenser {
|
||||
pr_l: f64,
|
||||
t_sat: f64,
|
||||
t_wall: f64,
|
||||
) -> CorrelationResult {
|
||||
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
|
||||
self.inner.compute_htc(
|
||||
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
|
||||
)
|
||||
@@ -393,6 +399,23 @@ impl Component for BphxCondenser {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
self.inner.set_port_context(port_edges);
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
vec![
|
||||
"inlet".to_string(),
|
||||
"outlet".to_string(),
|
||||
"secondary_inlet".to_string(),
|
||||
"secondary_outlet".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
vec![(0, 1), (2, 3)]
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
@@ -409,11 +432,14 @@ impl Component for BphxCondenser {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
self.fluid_backend = Some(Arc::clone(&backend));
|
||||
}
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
@@ -580,6 +606,7 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_refrigerant("R410A");
|
||||
assert_eq!(cond.refrigerant_id, "R410A");
|
||||
assert_eq!(cond.inner.refrigerant_id(), Some("R410A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -606,7 +633,24 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_exposes_four_port_metadata() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
|
||||
assert_eq!(
|
||||
cond.port_names(),
|
||||
vec![
|
||||
"inlet".to_string(),
|
||||
"outlet".to_string(),
|
||||
"secondary_inlet".to_string(),
|
||||
"secondary_outlet".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(cond.flow_paths(), vec![(0, 1), (2, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -636,7 +680,7 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let calib = cond.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
assert_eq!(calib.z_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -644,9 +688,9 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let mut cond = BphxCondenser::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
calib.z_ua = 0.9;
|
||||
cond.set_calib(calib);
|
||||
assert_eq!(cond.calib().f_ua, 0.9);
|
||||
assert_eq!(cond.calib().z_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,9 +742,11 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
|
||||
let result = cond.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
);
|
||||
let result = cond
|
||||
.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.re > 0.0);
|
||||
@@ -717,7 +763,7 @@ mod tests {
|
||||
|
||||
let mut cond_with_calib = BphxCondenser::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_dp = 0.5;
|
||||
calib.z_dp = 0.5;
|
||||
cond_with_calib.set_calib(calib);
|
||||
|
||||
let dp_calib = cond_with_calib.compute_pressure_drop(30.0, 1100.0);
|
||||
@@ -777,7 +823,7 @@ mod tests {
|
||||
let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -845,13 +891,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_default_correlation_is_longo() {
|
||||
fn test_bphx_condenser_default_selection_is_automatic() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let sig = cond.signature();
|
||||
assert!(
|
||||
sig.contains("Longo (2004)"),
|
||||
"Default correlation should be Longo2004 for condensation"
|
||||
sig.contains("Automatic"),
|
||||
"Default correlation policy should be automatic for condensation"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,32 @@
|
||||
//! BphxEvaporator - Brazed Plate Heat Exchanger Evaporator Component
|
||||
//!
|
||||
//! A plate evaporator component supporting both DX (Direct Expansion) and
|
||||
//! Flooded operation modes with geometry-based heat transfer correlations.
|
||||
//! A plate evaporator component. Brazed plate exchangers in this project are
|
||||
//! modeled as Direct Expansion (DX) only: the refrigerant outlet is always
|
||||
//! superheated vapor (x >= 1), controlled by a target superheat closure.
|
||||
//!
|
||||
//! ## Operation Modes
|
||||
//!
|
||||
//! - **DX Mode**: Outlet is superheated vapor (x >= 1), controlled by superheat
|
||||
//! - **Flooded Mode**: Outlet is two-phase (x ~ 0.5-0.8), works with Drum for recirculation
|
||||
//! A "flooded" operating style (liquid overfeed with vapor/liquid separation)
|
||||
//! is a system-topology property achieved by connecting a `Drum` and
|
||||
//! recirculation loop around a DX-terminated exchanger — it is not a distinct
|
||||
//! mode of the plate exchanger itself, so it is intentionally not modeled here.
|
||||
//! Use `FloodedEvaporator` for a shell-and-tube flooded evaporator.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry, BphxEvaporatorMode};
|
||||
//! use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry};
|
||||
//!
|
||||
//! // DX evaporator
|
||||
//! let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
//! let evap = BphxEvaporator::new(geo)
|
||||
//! .with_mode(BphxEvaporatorMode::Dx { target_superheat: 5.0 })
|
||||
//! .with_target_superheat(5.0)
|
||||
//! .with_refrigerant("R410A");
|
||||
//!
|
||||
//! assert_eq!(evap.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationEvaluation};
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::CorrelationSelectionError;
|
||||
use super::exchanger::HxSideConditions;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
@@ -35,69 +37,18 @@ use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Operation mode for BphxEvaporator
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BphxEvaporatorMode {
|
||||
/// Direct Expansion - outlet is superheated vapor (x >= 1)
|
||||
Dx {
|
||||
/// Target superheat in Kelvin (default: 5.0 K)
|
||||
target_superheat: f64,
|
||||
},
|
||||
/// Flooded - outlet is two-phase (x ~ 0.5-0.8)
|
||||
Flooded {
|
||||
/// Target outlet quality (default: 0.7)
|
||||
target_quality: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for BphxEvaporatorMode {
|
||||
fn default() -> Self {
|
||||
Self::Dx {
|
||||
target_superheat: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BphxEvaporatorMode {
|
||||
/// Returns the target superheat (DX mode) or None (Flooded mode)
|
||||
pub fn target_superheat(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Dx { target_superheat } => Some(*target_superheat),
|
||||
Self::Flooded { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the target quality (Flooded mode) or None (DX mode)
|
||||
pub fn target_quality(&self) -> Option<f64> {
|
||||
match self {
|
||||
Self::Dx { .. } => None,
|
||||
Self::Flooded { target_quality } => Some(*target_quality),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if DX mode
|
||||
pub fn is_dx(&self) -> bool {
|
||||
matches!(self, Self::Dx { .. })
|
||||
}
|
||||
|
||||
/// Returns true if Flooded mode
|
||||
pub fn is_flooded(&self) -> bool {
|
||||
matches!(self, Self::Flooded { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// BphxEvaporator - Brazed Plate Heat Exchanger configured as evaporator
|
||||
///
|
||||
/// Supports DX and Flooded operation modes with geometry-based correlations.
|
||||
/// Wraps a `BphxExchanger` for base residual computation.
|
||||
/// Direct Expansion (DX) only: the refrigerant outlet is always superheated
|
||||
/// vapor (x >= 1), controlled by a target superheat closure. Wraps a
|
||||
/// `BphxExchanger` for base residual computation.
|
||||
pub struct BphxEvaporator {
|
||||
inner: BphxExchanger,
|
||||
mode: BphxEvaporatorMode,
|
||||
target_superheat_k: f64,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
last_superheat: Cell<Option<f64>>,
|
||||
last_outlet_quality: Cell<Option<f64>>,
|
||||
outlet_pressure_idx: Option<usize>,
|
||||
outlet_enthalpy_idx: Option<usize>,
|
||||
}
|
||||
@@ -107,7 +58,7 @@ impl std::fmt::Debug for BphxEvaporator {
|
||||
f.debug_struct("BphxEvaporator")
|
||||
.field("ua", &self.inner.ua())
|
||||
.field("geometry", &self.inner.geometry())
|
||||
.field("mode", &self.mode)
|
||||
.field("target_superheat_k", &self.target_superheat_k)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
@@ -136,12 +87,11 @@ impl BphxEvaporator {
|
||||
let geometry = geometry.with_exchanger_type(BphxType::Evaporator);
|
||||
Self {
|
||||
inner: BphxExchanger::new(geometry),
|
||||
mode: BphxEvaporatorMode::default(),
|
||||
target_superheat_k: 5.0,
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
last_superheat: Cell::new(None),
|
||||
last_outlet_quality: Cell::new(None),
|
||||
outlet_pressure_idx: None,
|
||||
outlet_enthalpy_idx: None,
|
||||
}
|
||||
@@ -152,26 +102,31 @@ impl BphxEvaporator {
|
||||
Self::new(geometry)
|
||||
}
|
||||
|
||||
/// Sets the operation mode.
|
||||
pub fn with_mode(mut self, mode: BphxEvaporatorMode) -> Self {
|
||||
self.mode = mode;
|
||||
/// Sets the target superheat (K) used as the DX outlet closure.
|
||||
pub fn with_target_superheat(mut self, target_superheat_k: f64) -> Self {
|
||||
self.target_superheat_k = target_superheat_k;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
self.inner.set_refrigerant_id(self.refrigerant_id.clone());
|
||||
self.inner.set_cold_fluid(self.refrigerant_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the secondary fluid identifier (water, brine, etc.).
|
||||
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.secondary_fluid_id = fluid.into();
|
||||
self.inner.set_hot_fluid(self.secondary_fluid_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for property queries.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.inner
|
||||
.set_fluid_backend_from_builder(Arc::clone(&backend));
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
@@ -192,9 +147,9 @@ impl BphxEvaporator {
|
||||
self.inner.geometry()
|
||||
}
|
||||
|
||||
/// Returns the operation mode.
|
||||
pub fn mode(&self) -> BphxEvaporatorMode {
|
||||
self.mode
|
||||
/// Returns the target superheat (K) used as the DX outlet closure.
|
||||
pub fn target_superheat_k(&self) -> f64 {
|
||||
self.target_superheat_k
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
@@ -215,23 +170,12 @@ impl BphxEvaporator {
|
||||
/// Returns the last computed superheat (K).
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - Mode is not DX
|
||||
/// - `compute_residuals` has not been called
|
||||
/// - No FluidBackend configured
|
||||
pub fn superheat(&self) -> Option<f64> {
|
||||
self.last_superheat.get()
|
||||
}
|
||||
|
||||
/// Returns the last computed outlet quality.
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - Mode is not Flooded
|
||||
/// - `compute_residuals` has not been called
|
||||
/// - No FluidBackend configured
|
||||
pub fn outlet_quality(&self) -> Option<f64> {
|
||||
self.last_outlet_quality.get()
|
||||
}
|
||||
|
||||
/// Sets the outlet state indices for quality/superheat control.
|
||||
///
|
||||
/// These indices point to the pressure and enthalpy in the global state vector
|
||||
@@ -253,8 +197,9 @@ impl BphxEvaporator {
|
||||
|
||||
/// Computes outlet quality from enthalpy and saturation properties.
|
||||
///
|
||||
/// Returns `None` if no FluidBackend is configured or saturation properties
|
||||
/// cannot be computed.
|
||||
/// Used only to validate that the DX outlet is genuinely superheated
|
||||
/// (quality >= 1.0). Returns `None` if no FluidBackend is configured or
|
||||
/// saturation properties cannot be computed.
|
||||
fn compute_quality(&self, h_out: f64, p_pa: f64) -> Option<f64> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return None;
|
||||
@@ -331,7 +276,7 @@ impl BphxEvaporator {
|
||||
pr_l: f64,
|
||||
t_sat: f64,
|
||||
t_wall: f64,
|
||||
) -> CorrelationResult {
|
||||
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
|
||||
self.inner.compute_htc(
|
||||
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
|
||||
)
|
||||
@@ -347,12 +292,11 @@ impl BphxEvaporator {
|
||||
self.inner.update_ua_from_htc(h);
|
||||
}
|
||||
|
||||
/// Validates that outlet is in the correct region for the mode.
|
||||
/// Validates that the outlet is genuinely superheated (DX operation).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - DX mode: Returns error if outlet quality < 1.0 (not superheated)
|
||||
/// - Flooded mode: Returns error if outlet quality >= 1.0 (superheated)
|
||||
/// Returns an error if outlet quality < 1.0 (not superheated).
|
||||
pub fn validate_outlet(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
@@ -372,27 +316,13 @@ impl BphxEvaporator {
|
||||
))
|
||||
})?;
|
||||
|
||||
match self.mode {
|
||||
BphxEvaporatorMode::Dx { .. } => {
|
||||
if quality < 1.0 {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"BphxEvaporator DX mode: outlet quality {:.2} < 1.0 (not superheated)",
|
||||
quality
|
||||
)))
|
||||
} else {
|
||||
Ok(quality)
|
||||
}
|
||||
}
|
||||
BphxEvaporatorMode::Flooded { .. } => {
|
||||
if quality >= 1.0 {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"BphxEvaporator Flooded mode: outlet quality {:.2} >= 1.0 (superheated). Use DX mode instead.",
|
||||
quality
|
||||
)))
|
||||
} else {
|
||||
Ok(quality)
|
||||
}
|
||||
}
|
||||
if quality < 1.0 {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"BphxEvaporator DX mode: outlet quality {:.2} < 1.0 (not superheated)",
|
||||
quality
|
||||
)))
|
||||
} else {
|
||||
Ok(quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,17 +344,8 @@ impl Component for BphxEvaporator {
|
||||
let p_pa = state[p_idx];
|
||||
let h_out = state[h_idx];
|
||||
|
||||
match self.mode {
|
||||
BphxEvaporatorMode::Dx { .. } => {
|
||||
if let Some(sh) = self.compute_superheat(h_out, p_pa) {
|
||||
self.last_superheat.set(Some(sh));
|
||||
}
|
||||
}
|
||||
BphxEvaporatorMode::Flooded { .. } => {
|
||||
if let Some(q) = self.compute_quality(h_out, p_pa) {
|
||||
self.last_outlet_quality.set(Some(q.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
if let Some(sh) = self.compute_superheat(h_out, p_pa) {
|
||||
self.last_superheat.set(Some(sh));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +365,29 @@ impl Component for BphxEvaporator {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
let remapped = [
|
||||
port_edges.get(2).copied().flatten(),
|
||||
port_edges.get(3).copied().flatten(),
|
||||
port_edges.first().copied().flatten(),
|
||||
port_edges.get(1).copied().flatten(),
|
||||
];
|
||||
self.inner.set_port_context(&remapped);
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
vec![
|
||||
"inlet".to_string(),
|
||||
"outlet".to_string(),
|
||||
"secondary_inlet".to_string(),
|
||||
"secondary_outlet".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
vec![(0, 1), (2, 3)]
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
@@ -460,22 +404,18 @@ impl Component for BphxEvaporator {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
self.fluid_backend = Some(Arc::clone(&backend));
|
||||
}
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
let mode_str = match self.mode {
|
||||
BphxEvaporatorMode::Dx { target_superheat } => {
|
||||
format!("DX,tsh={:.1}K", target_superheat)
|
||||
}
|
||||
BphxEvaporatorMode::Flooded { target_quality } => {
|
||||
format!("Flooded,q={:.2}", target_quality)
|
||||
}
|
||||
};
|
||||
let mode_str = format!("DX,tsh={:.1}K", self.target_superheat_k);
|
||||
format!(
|
||||
"BphxEvaporator({} plates, dh={:.2}mm, A={:.3}m², {}, {}, {})",
|
||||
self.inner.geometry().n_plates,
|
||||
@@ -534,32 +474,15 @@ mod tests {
|
||||
fn test_bphx_evaporator_mode_default() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
assert!(evap.mode().is_dx());
|
||||
assert_eq!(evap.mode().target_superheat(), Some(5.0));
|
||||
assert_eq!(evap.target_superheat_k(), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_dx_mode() {
|
||||
fn test_bphx_evaporator_with_target_superheat() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
|
||||
target_superheat: 10.0,
|
||||
});
|
||||
let evap = BphxEvaporator::new(geo).with_target_superheat(10.0);
|
||||
|
||||
assert!(evap.mode().is_dx());
|
||||
assert_eq!(evap.mode().target_superheat(), Some(10.0));
|
||||
assert_eq!(evap.mode().target_quality(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_flooded_mode() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
});
|
||||
|
||||
assert!(evap.mode().is_flooded());
|
||||
assert_eq!(evap.mode().target_quality(), Some(0.7));
|
||||
assert_eq!(evap.mode().target_superheat(), None);
|
||||
assert_eq!(evap.target_superheat_k(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -567,6 +490,7 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_refrigerant("R410A");
|
||||
assert_eq!(evap.refrigerant_id, "R410A");
|
||||
assert_eq!(evap.inner.refrigerant_id(), Some("R410A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -593,7 +517,24 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = evap.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_exposes_four_port_metadata() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
|
||||
assert_eq!(
|
||||
evap.port_names(),
|
||||
vec![
|
||||
"inlet".to_string(),
|
||||
"outlet".to_string(),
|
||||
"secondary_inlet".to_string(),
|
||||
"secondary_outlet".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(evap.flow_paths(), vec![(0, 1), (2, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -623,7 +564,7 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let calib = evap.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
assert_eq!(calib.z_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,9 +572,9 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let mut evap = BphxEvaporator::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
calib.z_ua = 0.9;
|
||||
evap.set_calib(calib);
|
||||
assert_eq!(evap.calib().f_ua, 0.9);
|
||||
assert_eq!(evap.calib().z_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -647,9 +588,7 @@ mod tests {
|
||||
fn test_bphx_evaporator_signature_dx() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Dx {
|
||||
target_superheat: 5.0,
|
||||
})
|
||||
.with_target_superheat(5.0)
|
||||
.with_refrigerant("R410A");
|
||||
|
||||
let sig = evap.signature();
|
||||
@@ -659,22 +598,6 @@ mod tests {
|
||||
assert!(sig.contains("tsh=5.0K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_signature_flooded() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
})
|
||||
.with_refrigerant("R134a");
|
||||
|
||||
let sig = evap.signature();
|
||||
assert!(sig.contains("BphxEvaporator"));
|
||||
assert!(sig.contains("Flooded"));
|
||||
assert!(sig.contains("R134a"));
|
||||
assert!(sig.contains("q=0.70"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_energy_transfers() {
|
||||
let geo = test_geometry();
|
||||
@@ -688,29 +611,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_bphx_evaporator_superheat_initial() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
|
||||
target_superheat: 5.0,
|
||||
});
|
||||
let evap = BphxEvaporator::new(geo).with_target_superheat(5.0);
|
||||
assert_eq!(evap.superheat(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_outlet_quality_initial() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
});
|
||||
assert_eq!(evap.outlet_quality(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_compute_htc() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
|
||||
let result = evap.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
);
|
||||
let result = evap
|
||||
.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.re > 0.0);
|
||||
@@ -727,7 +641,7 @@ mod tests {
|
||||
|
||||
let mut evap_with_calib = BphxEvaporator::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_dp = 0.5;
|
||||
calib.z_dp = 0.5;
|
||||
evap_with_calib.set_calib(calib);
|
||||
|
||||
let dp_calib = evap_with_calib.compute_pressure_drop(30.0, 1100.0);
|
||||
@@ -787,27 +701,12 @@ mod tests {
|
||||
let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = evap.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_mode_is_dx() {
|
||||
let dx_mode = BphxEvaporatorMode::Dx {
|
||||
target_superheat: 5.0,
|
||||
};
|
||||
assert!(dx_mode.is_dx());
|
||||
assert!(!dx_mode.is_flooded());
|
||||
|
||||
let flooded_mode = BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
};
|
||||
assert!(flooded_mode.is_flooded());
|
||||
assert!(!flooded_mode.is_dx());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_superheat_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_core::Enthalpy;
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
@@ -871,9 +770,7 @@ mod tests {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(MockBackend);
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Dx {
|
||||
target_superheat: 5.0,
|
||||
})
|
||||
.with_target_superheat(5.0)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
@@ -945,9 +842,7 @@ mod tests {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(MockBackend);
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
})
|
||||
.with_target_superheat(5.0)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
@@ -958,28 +853,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_drum_interface_compatibility() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Flooded {
|
||||
target_quality: 0.7,
|
||||
})
|
||||
.with_refrigerant("R410A");
|
||||
|
||||
assert_eq!(evap.refrigerant_id, "R410A");
|
||||
assert!(evap.mode().is_flooded());
|
||||
assert_eq!(evap.mode().target_quality(), Some(0.7));
|
||||
assert_eq!(evap.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_default_correlation_is_longo() {
|
||||
fn test_bphx_evaporator_default_selection_is_automatic() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let sig = evap.signature();
|
||||
assert!(
|
||||
sig.contains("Longo (2004)"),
|
||||
"Default correlation should be Longo2004 for evaporation"
|
||||
sig.contains("Automatic"),
|
||||
"Default correlation policy should be automatic for evaporation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{
|
||||
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
|
||||
ValidityStatus,
|
||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||
CorrelationSelector, ValidityStatus,
|
||||
};
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::correlation_registry::{
|
||||
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
|
||||
};
|
||||
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
||||
use super::exchanger::{HeatExchanger, HxSideConditions};
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
@@ -41,7 +44,7 @@ use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// BphxExchanger - Brazed Plate Heat Exchanger component
|
||||
@@ -57,6 +60,7 @@ pub struct BphxExchanger {
|
||||
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
|
||||
last_htc: Cell<f64>,
|
||||
last_htc_result: Cell<Option<CorrelationResult>>,
|
||||
last_selection: RefCell<Option<SelectionOutcome>>,
|
||||
last_validity_warning: Cell<bool>,
|
||||
}
|
||||
|
||||
@@ -109,6 +113,7 @@ impl BphxExchanger {
|
||||
fluid_backend: None,
|
||||
last_htc: Cell::new(0.0),
|
||||
last_htc_result: Cell::new(None),
|
||||
last_selection: RefCell::new(None),
|
||||
last_validity_warning: Cell::new(false),
|
||||
}
|
||||
}
|
||||
@@ -125,6 +130,7 @@ impl BphxExchanger {
|
||||
fluid_backend: None,
|
||||
last_htc: Cell::new(0.0),
|
||||
last_htc_result: Cell::new(None),
|
||||
last_selection: RefCell::new(None),
|
||||
last_validity_warning: Cell::new(false),
|
||||
}
|
||||
}
|
||||
@@ -147,22 +153,46 @@ impl BphxExchanger {
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
self.set_refrigerant_id(fluid);
|
||||
self.inner.set_hot_fluid(self.refrigerant_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the refrigerant identifier used by correlation applicability selection.
|
||||
pub fn set_refrigerant_id(&mut self, fluid: impl Into<String>) {
|
||||
self.refrigerant_id = fluid.into();
|
||||
}
|
||||
|
||||
/// Returns the configured refrigerant identifier, when present.
|
||||
pub fn refrigerant_id(&self) -> Option<&str> {
|
||||
(!self.refrigerant_id.is_empty()).then_some(self.refrigerant_id.as_str())
|
||||
}
|
||||
|
||||
/// Sets the secondary fluid identifier (water, brine, etc.).
|
||||
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.secondary_fluid_id = fluid.into();
|
||||
self.inner.set_cold_fluid(self.secondary_fluid_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for property queries.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
|
||||
self.inner
|
||||
.set_fluid_backend_from_builder(Arc::clone(&backend));
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Declares the hot-side live-port fluid for the delegated four-port exchanger.
|
||||
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
|
||||
self.inner.set_hot_fluid(fluid);
|
||||
}
|
||||
|
||||
/// Declares the cold-side live-port fluid for the delegated four-port exchanger.
|
||||
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
|
||||
self.inner.set_cold_fluid(fluid);
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
@@ -173,9 +203,13 @@ impl BphxExchanger {
|
||||
&self.geometry
|
||||
}
|
||||
|
||||
/// Returns the name of the configured heat transfer correlation.
|
||||
/// Returns the configured formula name, or `Automatic` before/while auto-selecting.
|
||||
pub fn correlation_name(&self) -> &'static str {
|
||||
self.correlation_selector.correlation.name()
|
||||
if self.correlation_selector.automatic {
|
||||
"Automatic"
|
||||
} else {
|
||||
self.correlation_selector.correlation.name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
@@ -203,6 +237,11 @@ impl BphxExchanger {
|
||||
self.last_htc_result.take()
|
||||
}
|
||||
|
||||
/// Returns the latest ranked selection report.
|
||||
pub fn last_selection_report(&self) -> Option<SelectionOutcome> {
|
||||
self.last_selection.borrow().clone()
|
||||
}
|
||||
|
||||
/// Returns whether a validity warning was issued.
|
||||
pub fn had_validity_warning(&self) -> bool {
|
||||
self.last_validity_warning.get()
|
||||
@@ -245,7 +284,7 @@ impl BphxExchanger {
|
||||
pr_l: f64,
|
||||
t_sat: f64,
|
||||
t_wall: f64,
|
||||
) -> CorrelationResult {
|
||||
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
|
||||
let regime = match self.geometry.exchanger_type {
|
||||
BphxType::Evaporator => FlowRegime::Evaporation,
|
||||
BphxType::Condenser => FlowRegime::Condensation,
|
||||
@@ -266,18 +305,37 @@ impl BphxExchanger {
|
||||
t_wall,
|
||||
regime,
|
||||
chevron_angle: self.geometry.chevron_angle,
|
||||
p_reduced: 0.2,
|
||||
};
|
||||
|
||||
let result = self.correlation_selector.compute_htc(¶ms);
|
||||
let evaluation = match self.correlation_selector.select_and_compute(
|
||||
¶ms,
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
self.refrigerant_id(),
|
||||
) {
|
||||
Ok(evaluation) => evaluation,
|
||||
Err(error) => {
|
||||
self.clear_last_htc_evaluation();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let result = &evaluation.result;
|
||||
|
||||
self.last_htc.set(result.h);
|
||||
self.last_htc_result.set(Some(result.clone()));
|
||||
self.last_selection
|
||||
.replace(Some(evaluation.selection.clone()));
|
||||
self.last_validity_warning
|
||||
.set(result.validity != ValidityStatus::Valid);
|
||||
|
||||
if result.validity == ValidityStatus::Extrapolation {
|
||||
self.last_validity_warning.set(true);
|
||||
}
|
||||
Ok(evaluation)
|
||||
}
|
||||
|
||||
result
|
||||
fn clear_last_htc_evaluation(&self) {
|
||||
self.last_htc.set(0.0);
|
||||
self.last_htc_result.set(None);
|
||||
self.last_selection.replace(None);
|
||||
self.last_validity_warning.set(false);
|
||||
}
|
||||
|
||||
/// Computes the pressure drop using a simplified correlation.
|
||||
@@ -285,7 +343,7 @@ impl BphxExchanger {
|
||||
/// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h)
|
||||
///
|
||||
/// where f is the friction factor, L is the plate length, G is mass flux.
|
||||
/// The result is scaled by `calib().f_dp`.
|
||||
/// The result is scaled by `calib().z_dp`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -300,24 +358,47 @@ impl BphxExchanger {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let re = mass_flux * self.geometry.dh / 0.0002;
|
||||
let f = if re < 2300.0 {
|
||||
64.0 / re.max(1.0)
|
||||
let re = mass_flux.abs() * self.geometry.dh / 0.0002;
|
||||
// C¹ laminar→turbulent blend over [2300, 4000] (same rationale as the
|
||||
// pipe friction factor and the Gnielinski correlation): a hard switch at
|
||||
// Re = 2300 put a kink in the pressure-drop residual that the analytic
|
||||
// Newton Jacobian could not see, hurting convergence near the transition.
|
||||
// Reynolds uses |mass_flux| so transient reverse flow during iteration
|
||||
// yields a physical friction factor instead of the flat `64` the old
|
||||
// signed/`max(1.0)` form produced. The lower clamp only guards the exact
|
||||
// div-by-zero at stagnation; at 1e-9 it is far below any reachable Re, so
|
||||
// it introduces no visible kink (the previous `max(1.0)` kinked at Re = 1).
|
||||
const RE_LAMINAR: f64 = 2300.0;
|
||||
const RE_TURBULENT: f64 = 4000.0;
|
||||
let f_laminar = 64.0 / re.max(1e-9);
|
||||
let f = if re <= RE_LAMINAR {
|
||||
f_laminar
|
||||
} else {
|
||||
0.079 * re.powf(-0.25)
|
||||
let f_turbulent = 0.079 * re.powf(-0.25);
|
||||
if re >= RE_TURBULENT {
|
||||
f_turbulent
|
||||
} else {
|
||||
entropyk_core::smoothing::cubic_blend(
|
||||
f_laminar,
|
||||
f_turbulent,
|
||||
re,
|
||||
RE_LAMINAR,
|
||||
RE_TURBULENT,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let dp_base =
|
||||
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
|
||||
|
||||
dp_base * self.calib().f_dp
|
||||
dp_base * self.calib().z_dp
|
||||
}
|
||||
|
||||
/// Updates UA based on computed HTC.
|
||||
///
|
||||
/// UA_eff = h × A × f_ua
|
||||
pub fn update_ua_from_htc(&mut self, h: f64) {
|
||||
let ua = h * self.geometry.area * self.calib().f_ua;
|
||||
let ua = h * self.geometry.area * self.calib().z_ua;
|
||||
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
|
||||
}
|
||||
}
|
||||
@@ -347,6 +428,18 @@ impl Component for BphxExchanger {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
self.inner.set_port_context(port_edges);
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
self.inner.port_names()
|
||||
}
|
||||
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
self.inner.flow_paths()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
@@ -363,10 +456,14 @@ impl Component for BphxExchanger {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
self.fluid_backend = Some(Arc::clone(&backend));
|
||||
}
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
@@ -375,7 +472,7 @@ impl Component for BphxExchanger {
|
||||
self.geometry.n_plates,
|
||||
self.geometry.dh * 1000.0,
|
||||
self.geometry.area,
|
||||
self.correlation_selector.correlation.name()
|
||||
self.correlation_name()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -456,7 +553,24 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = hx.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_exchanger_exposes_four_port_metadata() {
|
||||
let geo = test_geometry();
|
||||
let hx = BphxExchanger::new(geo);
|
||||
|
||||
assert_eq!(
|
||||
hx.port_names(),
|
||||
vec![
|
||||
"hot_inlet".to_string(),
|
||||
"hot_outlet".to_string(),
|
||||
"cold_inlet".to_string(),
|
||||
"cold_outlet".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -486,13 +600,24 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let hx = BphxExchanger::new(geo);
|
||||
|
||||
let result = hx.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
);
|
||||
let result = hx
|
||||
.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.re > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
assert_eq!(
|
||||
result.selection.selected,
|
||||
super::super::correlation_registry::CorrelationId::Longo2004
|
||||
);
|
||||
assert_eq!(result.selection.assessments.len(), 8);
|
||||
assert_eq!(
|
||||
hx.last_selection_report().unwrap().selected,
|
||||
result.selection.selected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -502,9 +627,11 @@ mod tests {
|
||||
|
||||
assert_eq!(hx.last_htc(), 0.0);
|
||||
|
||||
let result = hx.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
);
|
||||
let result = hx
|
||||
.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!((hx.last_htc() - result.h).abs() < 1e-10);
|
||||
}
|
||||
@@ -517,7 +644,7 @@ mod tests {
|
||||
let sig = hx.signature();
|
||||
assert!(sig.contains("BphxExchanger"));
|
||||
assert!(sig.contains("20 plates"));
|
||||
assert!(sig.contains("Longo (2004)"));
|
||||
assert!(sig.contains("Automatic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -535,7 +662,7 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let hx = BphxExchanger::new(geo);
|
||||
let calib = hx.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
assert_eq!(calib.z_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,9 +670,9 @@ mod tests {
|
||||
let geo = test_geometry();
|
||||
let mut hx = BphxExchanger::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
calib.z_ua = 0.9;
|
||||
hx.set_calib(calib);
|
||||
assert_eq!(hx.calib().f_ua, 0.9);
|
||||
assert_eq!(hx.calib().z_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -570,18 +697,59 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_bphx_exchanger_validity_warning() {
|
||||
let geo = test_geometry();
|
||||
let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
|
||||
let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Longo2004);
|
||||
|
||||
assert!(!hx.had_validity_warning());
|
||||
|
||||
hx.compute_htc(
|
||||
100.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(hx.had_validity_warning());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_exchanger_invalid_input_returns_error() {
|
||||
let hx = BphxExchanger::new(test_geometry());
|
||||
let error = hx
|
||||
.compute_htc(
|
||||
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CorrelationSelectionError::InvalidInput(
|
||||
super::super::correlation_registry::DomainInputError::InvalidQuality { .. }
|
||||
)
|
||||
));
|
||||
assert_eq!(hx.last_htc(), 0.0);
|
||||
assert!(hx.last_selection_report().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_htc_evaluation_clears_previous_success_state() {
|
||||
let hx = BphxExchanger::new(test_geometry()).with_refrigerant("R134a");
|
||||
hx.compute_htc(
|
||||
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(hx.last_htc() > 0.0);
|
||||
assert!(hx.last_selection_report().is_some());
|
||||
|
||||
hx.compute_htc(
|
||||
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(hx.last_htc(), 0.0);
|
||||
assert!(hx.last_htc_result().is_none());
|
||||
assert!(hx.last_selection_report().is_none());
|
||||
assert!(!hx.had_validity_warning());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_exchanger_pressure_drop() {
|
||||
let geo = test_geometry();
|
||||
@@ -592,10 +760,88 @@ mod tests {
|
||||
|
||||
let mut hx_with_calib = BphxExchanger::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_dp = 0.5;
|
||||
calib.z_dp = 0.5;
|
||||
hx_with_calib.set_calib(calib);
|
||||
|
||||
let dp_calib = hx_with_calib.compute_pressure_drop(30.0, 1100.0);
|
||||
assert!((dp_calib - dp * 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_pressure_drop_transition_is_c1() {
|
||||
// re = mass_flux · dh / 0.0002 = mass_flux · 15 for dh = 0.003.
|
||||
// The laminar→turbulent friction blend over [2300, 4000] must keep ΔP
|
||||
// and its slope continuous at the band edges so the analytic Jacobian
|
||||
// sees no kink.
|
||||
let hx = BphxExchanger::new(test_geometry());
|
||||
let rho = 1100.0;
|
||||
let re_to_g = |re: f64| re / 15.0;
|
||||
let d_g = 0.01; // mass-flux step for the finite-difference slope
|
||||
|
||||
for &re_edge in &[2300.0_f64, 4000.0_f64] {
|
||||
let g = re_to_g(re_edge);
|
||||
let dp_below = hx.compute_pressure_drop(g - d_g, rho);
|
||||
let dp_at = hx.compute_pressure_drop(g, rho);
|
||||
let dp_above = hx.compute_pressure_drop(g + d_g, rho);
|
||||
|
||||
let rel_jump = (dp_above - dp_below).abs() / dp_at.abs();
|
||||
assert!(
|
||||
rel_jump < 0.01,
|
||||
"ΔP jump {:.4} at Re = {}",
|
||||
rel_jump,
|
||||
re_edge
|
||||
);
|
||||
|
||||
let slope_below = (dp_at - dp_below) / d_g;
|
||||
let slope_above = (dp_above - dp_at) / d_g;
|
||||
let denom = slope_below.abs().max(1e-9);
|
||||
assert!(
|
||||
(slope_above - slope_below).abs() / denom < 0.5,
|
||||
"ΔP slope discontinuity at Re = {} ({:.4} vs {:.4})",
|
||||
re_edge,
|
||||
slope_below,
|
||||
slope_above
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_pressure_drop_reverse_flow_and_low_re() {
|
||||
// ΔP must be a positive magnitude and symmetric under flow reversal
|
||||
// (friction now uses |Re|), and must not exhibit the old slope kink at
|
||||
// Re = 1 (the `re.max(1.0)` floor was lowered to a pure div-by-zero
|
||||
// guard). It must also stay finite and vanish at exactly zero flow.
|
||||
let hx = BphxExchanger::new(test_geometry());
|
||||
let rho = 1100.0;
|
||||
|
||||
for &g in &[0.5_f64, 5.0, 50.0] {
|
||||
let dp_fwd = hx.compute_pressure_drop(g, rho);
|
||||
let dp_rev = hx.compute_pressure_drop(-g, rho);
|
||||
assert!(dp_fwd >= 0.0, "ΔP must be non-negative, got {dp_fwd}");
|
||||
assert!(
|
||||
(dp_fwd - dp_rev).abs() <= 1e-9 * dp_fwd.max(1.0),
|
||||
"ΔP not symmetric under flow reversal: {dp_fwd} vs {dp_rev}"
|
||||
);
|
||||
}
|
||||
|
||||
// Zero flow → finite, ~zero ΔP (no NaN from the div-by-zero guard).
|
||||
let dp_zero = hx.compute_pressure_drop(0.0, rho);
|
||||
assert!(
|
||||
dp_zero.is_finite() && dp_zero.abs() < 1e-6,
|
||||
"ΔP(0) = {dp_zero}"
|
||||
);
|
||||
|
||||
// No kink near the old Re = 1 transition (dh=0.003 ⇒ re = g·15).
|
||||
let g1 = 1.0 / 15.0; // Re = 1
|
||||
let d_g = g1 * 0.1;
|
||||
let s_below =
|
||||
(hx.compute_pressure_drop(g1, rho) - hx.compute_pressure_drop(g1 - d_g, rho)) / d_g;
|
||||
let s_above =
|
||||
(hx.compute_pressure_drop(g1 + d_g, rho) - hx.compute_pressure_drop(g1, rho)) / d_g;
|
||||
let denom = s_below.abs().max(1e-12);
|
||||
assert!(
|
||||
(s_above - s_below).abs() / denom < 0.5,
|
||||
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ use crate::{
|
||||
///
|
||||
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
|
||||
/// assert_eq!(coil.ua(), 10_000.0);
|
||||
/// assert_eq!(coil.n_equations(), 2);
|
||||
/// assert_eq!(coil.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct CondenserCoil {
|
||||
@@ -197,7 +197,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_condenser_coil_n_equations() {
|
||||
let coil = CondenserCoil::new(10_000.0);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
|
||||
assert_eq!(coil.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -212,11 +213,7 @@ mod tests {
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = coil.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(
|
||||
residuals.iter().all(|r| r.is_finite()),
|
||||
"residuals must be finite"
|
||||
);
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
1394
crates/components/src/heat_exchanger/correlation_registry.rs
Normal file
1394
crates/components/src/heat_exchanger/correlation_registry.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,7 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = economizer.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -234,11 +234,8 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
)
|
||||
.to_watts();
|
||||
|
||||
let q_hot =
|
||||
hot_inlet.mass_flow * hot_inlet.cp * (hot_inlet.temperature - hot_outlet.temperature);
|
||||
let q_cold = cold_inlet.mass_flow
|
||||
* cold_inlet.cp
|
||||
* (cold_outlet.temperature - cold_inlet.temperature);
|
||||
let q_hot = hot_inlet.mass_flow * (hot_inlet.enthalpy - hot_outlet.enthalpy);
|
||||
let q_cold = cold_inlet.mass_flow * (cold_outlet.enthalpy - cold_inlet.enthalpy);
|
||||
|
||||
residuals[0] = q_hot - q;
|
||||
residuals[1] = q_cold - q;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ use crate::{
|
||||
///
|
||||
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
|
||||
/// assert_eq!(coil.ua(), 8_000.0);
|
||||
/// assert_eq!(coil.n_equations(), 2);
|
||||
/// assert_eq!(coil.n_equations(), 3); // 2 thermo + 1 mass-flow (CM1.3)
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct EvaporatorCoil {
|
||||
@@ -207,7 +207,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_evaporator_coil_n_equations() {
|
||||
let coil = EvaporatorCoil::new(5_000.0);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Evaporator)
|
||||
assert_eq!(coil.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -223,11 +224,7 @@ mod tests {
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = coil.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(
|
||||
residuals.iter().all(|r| r.is_finite()),
|
||||
"residuals must be finite"
|
||||
);
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
//!
|
||||
//! ## Fluid Backend Integration (Story 5.1)
|
||||
//!
|
||||
//! When a `FluidBackend` is provided via `with_fluid_backend()`, `compute_residuals`
|
||||
//! queries the backend for real Cp and enthalpy values at the boundary conditions
|
||||
//! instead of using hardcoded placeholder values.
|
||||
//! `compute_residuals` requires live four-port edge state. Inlet-only boundary
|
||||
//! conditions may be used for property inspection, but they are not enough to
|
||||
//! synthesize outlet states.
|
||||
|
||||
use super::model::{FluidState, HeatTransferModel};
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
@@ -48,7 +48,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the heat exchanger with placeholder connected ports.
|
||||
/// Builds the heat exchanger. Topology is injected later by name/context.
|
||||
pub fn build(self) -> HeatExchanger<Model> {
|
||||
HeatExchanger::new(self.model, self.name).with_circuit_id(self.circuit_id)
|
||||
}
|
||||
@@ -167,8 +167,8 @@ impl HxSideConditions {
|
||||
/// Uses the Strategy Pattern for heat transfer calculations via the
|
||||
/// `HeatTransferModel` trait. When a `FluidBackend` is attached via
|
||||
/// [`with_fluid_backend`](Self::with_fluid_backend), the `compute_residuals`
|
||||
/// method queries real thermodynamic properties (Cp, h) from the backend
|
||||
/// instead of using hardcoded placeholder values.
|
||||
/// method queries real thermodynamic properties (Cp, h) from the live edge
|
||||
/// state instead of using hardcoded placeholder values.
|
||||
pub struct HeatExchanger<Model: HeatTransferModel> {
|
||||
model: Model,
|
||||
name: String,
|
||||
@@ -184,6 +184,23 @@ pub struct HeatExchanger<Model: HeatTransferModel> {
|
||||
hot_conditions: Option<HxSideConditions>,
|
||||
/// Boundary conditions for the cold side inlet.
|
||||
cold_conditions: Option<HxSideConditions>,
|
||||
// ── 4-port (Modelica-style) edge-driven mode ───────────────────────────
|
||||
/// Hot inlet edge state indices (m, p, h). Wired by `set_port_context` port 0.
|
||||
hot_in_idx: Option<(usize, usize, usize)>,
|
||||
/// Hot outlet edge state indices. Wired by `set_port_context` port 1.
|
||||
hot_out_idx: Option<(usize, usize, usize)>,
|
||||
/// Cold inlet edge state indices. Wired by `set_port_context` port 2.
|
||||
cold_in_idx: Option<(usize, usize, usize)>,
|
||||
/// Cold outlet edge state indices. Wired by `set_port_context` port 3.
|
||||
cold_out_idx: Option<(usize, usize, usize)>,
|
||||
/// Hot-side fluid identifier ("Water", "Air", "INCOMP::MEG-30"…).
|
||||
hot_fluid_id_str: String,
|
||||
/// Cold-side fluid identifier.
|
||||
cold_fluid_id_str: String,
|
||||
/// Humidity ratio for moist-air hot side (0 = dry).
|
||||
hot_humidity_ratio: f64,
|
||||
/// Humidity ratio for moist-air cold side.
|
||||
cold_humidity_ratio: f64,
|
||||
_phantom: PhantomData<()>,
|
||||
}
|
||||
|
||||
@@ -204,7 +221,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
/// Creates a new heat exchanger with the given model.
|
||||
pub fn new(mut model: Model, name: impl Into<String>) -> Self {
|
||||
let calib = Calib::default();
|
||||
model.set_ua_scale(calib.f_ua);
|
||||
model.set_ua_scale(calib.z_ua);
|
||||
Self {
|
||||
model,
|
||||
name: name.into(),
|
||||
@@ -215,6 +232,14 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
fluid_backend: None,
|
||||
hot_conditions: None,
|
||||
cold_conditions: None,
|
||||
hot_in_idx: None,
|
||||
hot_out_idx: None,
|
||||
cold_in_idx: None,
|
||||
cold_out_idx: None,
|
||||
hot_fluid_id_str: String::new(),
|
||||
cold_fluid_id_str: String::new(),
|
||||
hot_humidity_ratio: 0.0,
|
||||
cold_humidity_ratio: 0.0,
|
||||
_phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -349,6 +374,7 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
}
|
||||
|
||||
/// Queries Cp (J/(kg·K)) from the backend for a given side.
|
||||
#[allow(dead_code)]
|
||||
fn query_cp(&self, conditions: &HxSideConditions) -> Result<f64, ComponentError> {
|
||||
if let Some(backend) = &self.fluid_backend {
|
||||
let state = entropyk_fluids::FluidState::from_pt(
|
||||
@@ -448,10 +474,261 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
|
||||
/// Sets calibration factors.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.model.set_ua_scale(calib.f_ua);
|
||||
self.model.set_ua_scale(calib.z_ua);
|
||||
self.calib = calib;
|
||||
}
|
||||
|
||||
// ── 4-port (Modelica-style) configuration ───────────────────────────────
|
||||
|
||||
/// Declares the hot-side fluid for edge-driven 4-port mode ("Water", "Air",
|
||||
/// "INCOMP::MEG-30"…). When hot-side edges are wired (ports 0 and 1), the
|
||||
/// HX reads T and cp from the live edge state via the backend.
|
||||
pub fn with_hot_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.hot_fluid_id_str = fluid.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Declares the cold-side fluid for edge-driven 4-port mode.
|
||||
pub fn with_cold_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.cold_fluid_id_str = fluid.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the hot-side fluid identifier (see [`with_hot_fluid`]).
|
||||
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
|
||||
self.hot_fluid_id_str = fluid.into();
|
||||
}
|
||||
|
||||
/// Sets the cold-side fluid identifier.
|
||||
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
|
||||
self.cold_fluid_id_str = fluid.into();
|
||||
}
|
||||
|
||||
/// Sets the humidity ratio for the hot side (moist air).
|
||||
pub fn set_hot_humidity_ratio(&mut self, w: f64) {
|
||||
self.hot_humidity_ratio = w.max(0.0);
|
||||
}
|
||||
|
||||
/// Sets the humidity ratio for the cold side (moist air).
|
||||
pub fn set_cold_humidity_ratio(&mut self, w: f64) {
|
||||
self.cold_humidity_ratio = w.max(0.0);
|
||||
}
|
||||
|
||||
/// `true` when all 4 edges are wired (Modelica-style 4-port mode).
|
||||
fn edges_ready(&self) -> bool {
|
||||
self.hot_in_idx.is_some()
|
||||
&& self.hot_out_idx.is_some()
|
||||
&& self.cold_in_idx.is_some()
|
||||
&& self.cold_out_idx.is_some()
|
||||
&& !self.hot_fluid_id_str.is_empty()
|
||||
&& !self.cold_fluid_id_str.is_empty()
|
||||
}
|
||||
|
||||
fn live_state_required_error(&self) -> ComponentError {
|
||||
ComponentError::InvalidState(format!(
|
||||
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",
|
||||
self.name
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn live_fluid_states(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<(FluidState, FluidState, FluidState, FluidState), ComponentError> {
|
||||
if !self.edges_ready() {
|
||||
return Err(self.live_state_required_error());
|
||||
}
|
||||
|
||||
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
|
||||
let (m_h_out, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
|
||||
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
|
||||
let (m_c_out, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
|
||||
let max_idx = [
|
||||
m_h, p_h_in, h_h_in, m_h_out, p_h_out, h_h_out, m_c, p_c_in, h_c_in, m_c_out, p_c_out,
|
||||
h_c_out,
|
||||
]
|
||||
.into_iter()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let hot_cp_in = self.hot_cp(state[p_h_in], state[h_h_in])?;
|
||||
let hot_cp_out = self.hot_cp(state[p_h_out], state[h_h_out])?;
|
||||
let cold_cp_in = self.cold_cp(state[p_c_in], state[h_c_in])?;
|
||||
let cold_cp_out = self.cold_cp(state[p_c_out], state[h_c_out])?;
|
||||
|
||||
let hot_t_in = self.hot_temperature(state[p_h_in], state[h_h_in])?;
|
||||
let hot_t_out = self.hot_temperature(state[p_h_out], state[h_h_out])?;
|
||||
let cold_t_in = self.cold_temperature(state[p_c_in], state[h_c_in])?;
|
||||
let cold_t_out = self.cold_temperature(state[p_c_out], state[h_c_out])?;
|
||||
|
||||
let m_hot = state[m_h].max(0.0);
|
||||
let m_cold = state[m_c].max(0.0);
|
||||
|
||||
Ok((
|
||||
Self::create_fluid_state(hot_t_in, state[p_h_in], state[h_h_in], m_hot, hot_cp_in),
|
||||
Self::create_fluid_state(hot_t_out, state[p_h_out], state[h_h_out], m_hot, hot_cp_out),
|
||||
Self::create_fluid_state(cold_t_in, state[p_c_in], state[h_c_in], m_cold, cold_cp_in),
|
||||
Self::create_fluid_state(
|
||||
cold_t_out,
|
||||
state[p_c_out],
|
||||
state[h_c_out],
|
||||
m_cold,
|
||||
cold_cp_out,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// `true` when the hot-side fluid follows the moist-air convention.
|
||||
fn hot_is_air(&self) -> bool {
|
||||
let f = self.hot_fluid_id_str.trim();
|
||||
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
|
||||
}
|
||||
|
||||
/// `true` when the cold-side fluid follows the moist-air convention.
|
||||
fn cold_is_air(&self) -> bool {
|
||||
let f = self.cold_fluid_id_str.trim();
|
||||
f.eq_ignore_ascii_case("air") || f.eq_ignore_ascii_case("moistair")
|
||||
}
|
||||
|
||||
/// Hot-side cp [J/(kg·K)] at (P, h). Moist air uses the psychrometric cp;
|
||||
/// other fluids query the backend.
|
||||
fn hot_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||
if self.hot_is_air() {
|
||||
return Ok(1006.0 + 1860.0 * self.hot_humidity_ratio);
|
||||
}
|
||||
self.query_live_property("hot", &self.hot_fluid_id_str, Property::Cp, p_pa, h_jkg)
|
||||
.and_then(|cp| {
|
||||
if cp.is_finite() && cp > 0.0 {
|
||||
Ok(cp)
|
||||
} else {
|
||||
Err(ComponentError::CalculationFailed(format!(
|
||||
"{} hot-side Cp is invalid: {}",
|
||||
self.name, cp
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Cold-side cp [J/(kg·K)] at (P, h).
|
||||
fn cold_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||
if self.cold_is_air() {
|
||||
return Ok(1006.0 + 1860.0 * self.cold_humidity_ratio);
|
||||
}
|
||||
self.query_live_property("cold", &self.cold_fluid_id_str, Property::Cp, p_pa, h_jkg)
|
||||
.and_then(|cp| {
|
||||
if cp.is_finite() && cp > 0.0 {
|
||||
Ok(cp)
|
||||
} else {
|
||||
Err(ComponentError::CalculationFailed(format!(
|
||||
"{} cold-side Cp is invalid: {}",
|
||||
self.name, cp
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Hot-side temperature [K] at (P, h). Moist air uses the linear psychrometric
|
||||
/// inversion; other fluids query the backend T(P,h).
|
||||
fn hot_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||
if self.hot_is_air() {
|
||||
let w = self.hot_humidity_ratio;
|
||||
let cp = 1006.0 + 1860.0 * w;
|
||||
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
|
||||
}
|
||||
self.query_live_property(
|
||||
"hot",
|
||||
&self.hot_fluid_id_str,
|
||||
Property::Temperature,
|
||||
p_pa,
|
||||
h_jkg,
|
||||
)
|
||||
.and_then(|t| {
|
||||
if t.is_finite() && t > 0.0 {
|
||||
Ok(t)
|
||||
} else {
|
||||
Err(ComponentError::CalculationFailed(format!(
|
||||
"{} hot-side temperature is invalid: {}",
|
||||
self.name, t
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Cold-side temperature [K] at (P, h).
|
||||
fn cold_temperature(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
|
||||
if self.cold_is_air() {
|
||||
let w = self.cold_humidity_ratio;
|
||||
let cp = 1006.0 + 1860.0 * w;
|
||||
return Ok((h_jkg - 2_501_000.0 * w) / cp + 273.15);
|
||||
}
|
||||
self.query_live_property(
|
||||
"cold",
|
||||
&self.cold_fluid_id_str,
|
||||
Property::Temperature,
|
||||
p_pa,
|
||||
h_jkg,
|
||||
)
|
||||
.and_then(|t| {
|
||||
if t.is_finite() && t > 0.0 {
|
||||
Ok(t)
|
||||
} else {
|
||||
Err(ComponentError::CalculationFailed(format!(
|
||||
"{} cold-side temperature is invalid: {}",
|
||||
self.name, t
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn query_live_property(
|
||||
&self,
|
||||
side: &str,
|
||||
fluid_id: &str,
|
||||
property: Property,
|
||||
p_pa: f64,
|
||||
h_jkg: f64,
|
||||
) -> Result<f64, ComponentError> {
|
||||
if !p_pa.is_finite() || p_pa <= 0.0 {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"{} {} side has invalid pressure: {} Pa",
|
||||
self.name, side, p_pa
|
||||
)));
|
||||
}
|
||||
if !h_jkg.is_finite() {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"{} {} side has invalid enthalpy: {} J/kg",
|
||||
self.name, side, h_jkg
|
||||
)));
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||
ComponentError::InvalidState(format!(
|
||||
"{} {} side fluid '{}' requires a FluidBackend; no simulation fallback is allowed",
|
||||
self.name, side, fluid_id
|
||||
))
|
||||
})?;
|
||||
backend
|
||||
.property(
|
||||
FluidsFluidId::new(fluid_id),
|
||||
property,
|
||||
entropyk_fluids::FluidState::PressureEnthalpy(
|
||||
Pressure::from_pascals(p_pa),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(h_jkg),
|
||||
),
|
||||
)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"{} failed to evaluate {:?} for {} side fluid '{}': {}",
|
||||
self.name, property, side, fluid_id, e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a fluid state from temperature, pressure, enthalpy, mass flow, and Cp.
|
||||
fn create_fluid_state(
|
||||
temperature: f64,
|
||||
@@ -509,63 +786,10 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
|
||||
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
|
||||
&self.hot_conditions,
|
||||
&self.cold_conditions,
|
||||
&self.fluid_backend,
|
||||
) {
|
||||
// Hot side from backend
|
||||
let hot_cp = self.query_cp(hot_cond)?;
|
||||
let hot_h_in = self.query_enthalpy(hot_cond)?;
|
||||
let hot_inlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k(),
|
||||
hot_cond.pressure_pa(),
|
||||
hot_h_in,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
|
||||
let hot_dh = hot_cp * 5.0; // J/kg per degree
|
||||
let hot_outlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k() - 5.0,
|
||||
hot_cond.pressure_pa() * 0.998,
|
||||
hot_h_in - hot_dh,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
|
||||
// Cold side from backend
|
||||
let cold_cp = self.query_cp(cold_cond)?;
|
||||
let cold_h_in = self.query_enthalpy(cold_cond)?;
|
||||
let cold_inlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k(),
|
||||
cold_cond.pressure_pa(),
|
||||
cold_h_in,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
let cold_dh = cold_cp * 5.0;
|
||||
let cold_outlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k() + 5.0,
|
||||
cold_cond.pressure_pa() * 0.998,
|
||||
cold_h_in + cold_dh,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
} else {
|
||||
let hot_inlet = Self::create_fluid_state(350.0, 500_000.0, 400_000.0, 0.1, 1000.0);
|
||||
let hot_outlet = Self::create_fluid_state(330.0, 490_000.0, 380_000.0, 0.1, 1000.0);
|
||||
let cold_inlet = Self::create_fluid_state(290.0, 101_325.0, 80_000.0, 0.2, 4180.0);
|
||||
let cold_outlet =
|
||||
Self::create_fluid_state(300.0, 101_325.0, 120_000.0, 0.2, 4180.0);
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
};
|
||||
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) = self.live_fluid_states(_state)?;
|
||||
|
||||
let dynamic_f_ua =
|
||||
custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
|
||||
custom_ua_scale.or_else(|| self.calib_indices.z_ua.map(|idx| _state[idx]));
|
||||
|
||||
self.model.compute_residuals(
|
||||
&hot_inlet,
|
||||
@@ -594,68 +818,49 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// ∂r/∂f_ua = -∂Q/∂f_ua (Story 5.5)
|
||||
if let Some(f_ua_idx) = self.calib_indices.f_ua {
|
||||
// Need to compute Q_nominal (with UA_scale = 1.0)
|
||||
// This requires repeating the residual calculation logic with dynamic_ua_scale = None
|
||||
// For now, we'll use a finite difference approximation or a simplified nominal calculation.
|
||||
// 4-port mode: numerical Jacobian via finite differences. Perturb each
|
||||
// relevant state variable, recompute residuals, take the difference.
|
||||
if self.edges_ready() {
|
||||
let (m_h, p_h_in, h_h_in) = self.hot_in_idx.unwrap();
|
||||
let (_, p_h_out, h_h_out) = self.hot_out_idx.unwrap();
|
||||
let (m_c, p_c_in, h_c_in) = self.cold_in_idx.unwrap();
|
||||
let (_, p_c_out, h_c_out) = self.cold_out_idx.unwrap();
|
||||
|
||||
// Re-use logic from compute_residuals but only for Q
|
||||
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
|
||||
&self.hot_conditions,
|
||||
&self.cold_conditions,
|
||||
&self.fluid_backend,
|
||||
) {
|
||||
let hot_cp = self.query_cp(hot_cond)?;
|
||||
let hot_h_in = self.query_enthalpy(hot_cond)?;
|
||||
let hot_inlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k(),
|
||||
hot_cond.pressure_pa(),
|
||||
hot_h_in,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
let cols = [
|
||||
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
|
||||
];
|
||||
let unique_cols: Vec<usize> = {
|
||||
let mut s: Vec<usize> =
|
||||
cols.iter().copied().filter(|c| *c < _state.len()).collect();
|
||||
s.sort_unstable();
|
||||
s.dedup();
|
||||
s
|
||||
};
|
||||
|
||||
let hot_dh = hot_cp * 5.0;
|
||||
let hot_outlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k() - 5.0,
|
||||
hot_cond.pressure_pa() * 0.998,
|
||||
hot_h_in - hot_dh,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
let compute_res = |s: &[f64]| -> [f64; 2] {
|
||||
let mut r = vec![0.0_f64; 2];
|
||||
let _ = self.do_compute_residuals(s, &mut r, None);
|
||||
[r[0], r[1]]
|
||||
};
|
||||
|
||||
let cold_cp = self.query_cp(cold_cond)?;
|
||||
let cold_h_in = self.query_enthalpy(cold_cond)?;
|
||||
let cold_inlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k(),
|
||||
cold_cond.pressure_pa(),
|
||||
cold_h_in,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
let cold_dh = cold_cp * 5.0;
|
||||
let cold_outlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k() + 5.0,
|
||||
cold_cond.pressure_pa() * 0.998,
|
||||
cold_h_in + cold_dh,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
|
||||
let q_nominal = self
|
||||
.model
|
||||
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
|
||||
.to_watts();
|
||||
|
||||
// r0 = Q_hot - Q -> ∂r0/∂f_ua = -Q_nominal
|
||||
// r1 = Q_cold - Q -> ∂r1/∂f_ua = -Q_nominal
|
||||
// r2 = Q_hot - Q_cold -> ∂r2/∂f_ua = 0
|
||||
_jacobian.add_entry(0, f_ua_idx, -q_nominal);
|
||||
_jacobian.add_entry(1, f_ua_idx, -q_nominal);
|
||||
_jacobian.add_entry(2, f_ua_idx, 0.0);
|
||||
for &col in &unique_cols {
|
||||
let h = (_state[col].abs() * 1e-6).max(1e-3);
|
||||
let mut sp = _state.to_vec();
|
||||
sp[col] += h;
|
||||
let rp = compute_res(&sp);
|
||||
let mut sm = _state.to_vec();
|
||||
sm[col] -= h;
|
||||
let rm = compute_res(&sm);
|
||||
for row in 0..2 {
|
||||
let fd = (rp[row] - rm[row]) / (2.0 * h);
|
||||
if fd.abs() > 1e-15 {
|
||||
_jacobian.add_entry(row, col, fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -668,63 +873,93 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
// TODO: Return actual ports when port storage is implemented.
|
||||
// Port storage pending integration with Port<Connected> system from Story 1.3.
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
if let Some(Some(triple)) = port_edges.first() {
|
||||
self.hot_in_idx = Some(*triple);
|
||||
}
|
||||
if let Some(Some(triple)) = port_edges.get(1) {
|
||||
self.hot_out_idx = Some(*triple);
|
||||
}
|
||||
if let Some(Some(triple)) = port_edges.get(2) {
|
||||
self.cold_in_idx = Some(*triple);
|
||||
}
|
||||
if let Some(Some(triple)) = port_edges.get(3) {
|
||||
self.cold_out_idx = Some(*triple);
|
||||
}
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
vec![
|
||||
"hot_inlet".to_string(),
|
||||
"hot_outlet".to_string(),
|
||||
"cold_inlet".to_string(),
|
||||
"cold_outlet".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
vec![(0, 1), (2, 3)]
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
// HeatExchanger has two sides: hot and cold, each with inlet and outlet.
|
||||
// Mass balance: hot_in = hot_out, cold_in = cold_out (no mixing between sides)
|
||||
//
|
||||
// For now, we use the configured conditions if available.
|
||||
// When port storage is implemented, this will use actual port state.
|
||||
let mut flows = Vec::with_capacity(4);
|
||||
|
||||
if let Some(hot_cond) = &self.hot_conditions {
|
||||
let m_hot = hot_cond.mass_flow_kg_s();
|
||||
// Hot inlet (positive = entering), Hot outlet (negative = leaving)
|
||||
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_hot));
|
||||
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_hot));
|
||||
if !self.edges_ready() {
|
||||
return Err(self.live_state_required_error());
|
||||
}
|
||||
|
||||
if let Some(cold_cond) = &self.cold_conditions {
|
||||
let m_cold = cold_cond.mass_flow_kg_s();
|
||||
// Cold inlet (positive = entering), Cold outlet (negative = leaving)
|
||||
flows.push(entropyk_core::MassFlow::from_kg_per_s(m_cold));
|
||||
flows.push(entropyk_core::MassFlow::from_kg_per_s(-m_cold));
|
||||
let (m_h_in, _, _) = self.hot_in_idx.unwrap();
|
||||
let (m_h_out, _, _) = self.hot_out_idx.unwrap();
|
||||
let (m_c_in, _, _) = self.cold_in_idx.unwrap();
|
||||
let (m_c_out, _, _) = self.cold_out_idx.unwrap();
|
||||
let max_idx = [m_h_in, m_h_out, m_c_in, m_c_out]
|
||||
.into_iter()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(flows)
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(state[m_h_in]),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-state[m_h_out]),
|
||||
entropyk_core::MassFlow::from_kg_per_s(state[m_c_in]),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-state[m_c_out]),
|
||||
])
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
let mut enthalpies = Vec::with_capacity(4);
|
||||
|
||||
// This matches the order in port_mass_flows
|
||||
if let Some(hot_cond) = &self.hot_conditions {
|
||||
let h_in = self.query_enthalpy(hot_cond).unwrap_or(400_000.0);
|
||||
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
|
||||
// HACK: As mentioned in compute_residuals, proper port mappings are pending.
|
||||
// We use a dummy 5 K delta for the outlet until full Port system integration.
|
||||
let cp = self.query_cp(hot_cond).unwrap_or(1000.0);
|
||||
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in - cp * 5.0));
|
||||
if !self.edges_ready() {
|
||||
return Err(self.live_state_required_error());
|
||||
}
|
||||
|
||||
if let Some(cold_cond) = &self.cold_conditions {
|
||||
let h_in = self.query_enthalpy(cold_cond).unwrap_or(80_000.0);
|
||||
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in));
|
||||
let cp = self.query_cp(cold_cond).unwrap_or(4180.0);
|
||||
enthalpies.push(entropyk_core::Enthalpy::from_joules_per_kg(h_in + cp * 5.0));
|
||||
let (_, _, h_h_in) = self.hot_in_idx.unwrap();
|
||||
let (_, _, h_h_out) = self.hot_out_idx.unwrap();
|
||||
let (_, _, h_c_in) = self.cold_in_idx.unwrap();
|
||||
let (_, _, h_c_out) = self.cold_out_idx.unwrap();
|
||||
let max_idx = [h_h_in, h_h_out, h_c_in, h_c_out]
|
||||
.into_iter()
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(enthalpies)
|
||||
Ok(vec![
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_in]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[h_h_out]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_in]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[h_c_out]),
|
||||
])
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
@@ -742,7 +977,43 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
match kind {
|
||||
crate::MeasuredOutput::Capacity | crate::MeasuredOutput::HeatTransferRate => {
|
||||
if !self.edges_ready() {
|
||||
return None;
|
||||
}
|
||||
let (m_h, _, h_h_in) = self.hot_in_idx?;
|
||||
let (_, _, h_h_out) = self.hot_out_idx?;
|
||||
let (m_c, _, h_c_in) = self.cold_in_idx?;
|
||||
let (_, _, h_c_out) = self.cold_out_idx?;
|
||||
let max_idx = [m_h, h_h_in, h_h_out, m_c, h_c_in, h_c_out]
|
||||
.into_iter()
|
||||
.max()?;
|
||||
if max_idx >= state.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let q_hot_w = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
|
||||
let q_cold_w = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
|
||||
if q_hot_w.is_finite() && q_cold_w.is_finite() {
|
||||
Some(0.5 * (q_hot_w + q_cold_w))
|
||||
} else if q_hot_w.is_finite() {
|
||||
Some(q_hot_w)
|
||||
} else if q_cold_w.is_finite() {
|
||||
Some(q_cold_w)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
@@ -755,7 +1026,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new(&self.name)
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -808,6 +1082,10 @@ mod tests {
|
||||
use crate::heat_exchanger::{FlowConfiguration, LmtdModel};
|
||||
use crate::state_machine::StateManageable;
|
||||
|
||||
fn live_air_state(t_k: f64) -> f64 {
|
||||
1006.0 * (t_k - 273.15)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heat_exchanger_creation() {
|
||||
let model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
|
||||
@@ -835,7 +1113,65 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = hx.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_live_four_port_residuals_compute_from_state() {
|
||||
let model = LmtdModel::counter_flow(5000.0);
|
||||
let mut hx = HeatExchanger::new(model, "Test")
|
||||
.with_hot_fluid("Air")
|
||||
.with_cold_fluid("Air");
|
||||
hx.set_port_context(&[
|
||||
Some((0, 1, 2)),
|
||||
Some((0, 3, 4)),
|
||||
Some((5, 6, 7)),
|
||||
Some((5, 8, 9)),
|
||||
]);
|
||||
|
||||
let state = vec![
|
||||
0.5,
|
||||
101_325.0,
|
||||
live_air_state(350.0),
|
||||
101_325.0,
|
||||
live_air_state(330.0),
|
||||
0.8,
|
||||
101_325.0,
|
||||
live_air_state(290.0),
|
||||
101_325.0,
|
||||
live_air_state(300.0),
|
||||
];
|
||||
let mut residuals = vec![0.0; hx.n_equations()];
|
||||
|
||||
hx.compute_residuals(&state, &mut residuals).unwrap();
|
||||
assert!(residuals.iter().all(|r| r.is_finite()));
|
||||
assert!(residuals.iter().any(|r| r.abs() > 1e-9));
|
||||
assert_eq!(
|
||||
hx.port_enthalpies(&state)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|h| h.to_joules_per_kg())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
live_air_state(350.0),
|
||||
live_air_state(330.0),
|
||||
live_air_state(290.0),
|
||||
live_air_state(300.0)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_four_port_metadata_is_name_based() {
|
||||
let model = LmtdModel::counter_flow(5000.0);
|
||||
let hx = HeatExchanger::new(model, "Test");
|
||||
|
||||
assert!(hx.get_ports().is_empty());
|
||||
assert_eq!(
|
||||
hx.port_names(),
|
||||
vec!["hot_inlet", "hot_outlet", "cold_inlet", "cold_outlet"]
|
||||
);
|
||||
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -999,8 +1335,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_with_backend_succeeds() {
|
||||
/// Using TestBackend: Water on cold side, R410A on hot side.
|
||||
fn test_boundary_conditions_without_outlet_state_error() {
|
||||
use entropyk_core::{MassFlow, Pressure, Temperature};
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
@@ -1031,30 +1366,27 @@ mod tests {
|
||||
let mut residuals = vec![0.0f64; 3];
|
||||
let result = hx.compute_residuals(&state, &mut residuals);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"compute_residuals with FluidBackend should succeed"
|
||||
matches!(result, Err(ComponentError::InvalidState(_))),
|
||||
"inlet-only boundary conditions must not fabricate outlet states"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_residuals_with_backend_vs_without_differ() {
|
||||
/// Residuals computed with a real backend should differ from placeholder residuals
|
||||
/// because real Cp and enthalpy values are used.
|
||||
fn test_unwired_hx_never_returns_dummy_finite_residuals() {
|
||||
use entropyk_core::{MassFlow, Pressure, Temperature};
|
||||
use entropyk_fluids::TestBackend;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Without backend (placeholder values)
|
||||
let model1 = LmtdModel::counter_flow(5000.0);
|
||||
let hx_no_backend = HeatExchanger::new(model1, "HX_nobackend");
|
||||
|
||||
let state = vec![0.0f64; 10];
|
||||
let mut residuals_no_backend = vec![0.0f64; 3];
|
||||
hx_no_backend
|
||||
.compute_residuals(&state, &mut residuals_no_backend)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
hx_no_backend.compute_residuals(&state, &mut residuals_no_backend),
|
||||
Err(ComponentError::InvalidState(_))
|
||||
));
|
||||
|
||||
// With backend (real Water + R410A properties)
|
||||
let model2 = LmtdModel::counter_flow(5000.0);
|
||||
let hx_with_backend = HeatExchanger::new(model2, "HX_with_backend")
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()))
|
||||
@@ -1078,22 +1410,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut residuals_with_backend = vec![0.0f64; 3];
|
||||
hx_with_backend
|
||||
.compute_residuals(&state, &mut residuals_with_backend)
|
||||
.unwrap();
|
||||
|
||||
// The energy balance residual (index 2) should differ because real Cp differs
|
||||
// from the 1000.0/4180.0 hardcoded fallback values.
|
||||
// (TestBackend returns Cp=1500 for refrigerants and 4184 for water,
|
||||
// but temperatures and flows differ, so the residual WILL differ)
|
||||
let residuals_are_different = residuals_no_backend
|
||||
.iter()
|
||||
.zip(residuals_with_backend.iter())
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6);
|
||||
assert!(
|
||||
residuals_are_different,
|
||||
"Residuals with FluidBackend should differ from placeholder residuals"
|
||||
);
|
||||
assert!(matches!(
|
||||
hx_with_backend.compute_residuals(&state, &mut residuals_with_backend),
|
||||
Err(ComponentError::InvalidState(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
165
crates/components/src/heat_exchanger/fan_coil_unit.rs
Normal file
165
crates/components/src/heat_exchanger/fan_coil_unit.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Fan-coil unit (FCU): water–air ε-NTU with bypass factor (BPF) wet-coil model.
|
||||
//!
|
||||
//! Sensible / latent split via apparatus dew point (ADP) and BPF:
|
||||
//! `T_leave = BPF · T_enter + (1 − BPF) · T_ADP`.
|
||||
|
||||
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
||||
|
||||
/// Psychrometric / coil inputs for FCU rating.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FanCoilRatingInput {
|
||||
/// Entering air dry-bulb [K].
|
||||
pub t_air_in_k: f64,
|
||||
/// Entering air wet-bulb or humidity ratio proxy via dewpoint [K].
|
||||
pub t_dew_in_k: f64,
|
||||
/// Entering water temperature [K].
|
||||
pub t_water_in_k: f64,
|
||||
/// Air capacity rate ṁ·cp [W/K].
|
||||
pub c_air: f64,
|
||||
/// Water capacity rate ṁ·cp [W/K].
|
||||
pub c_water: f64,
|
||||
/// Bypass factor (0.05–0.40 typical).
|
||||
pub bypass_factor: f64,
|
||||
/// Apparatus dew-point temperature [K] (coil surface).
|
||||
pub t_adp_k: f64,
|
||||
}
|
||||
|
||||
/// Result of an FCU rating.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FanCoilRating {
|
||||
/// Total cooling capacity [W] (sensible + latent proxy).
|
||||
pub q_total_w: f64,
|
||||
/// Sensible capacity [W].
|
||||
pub q_sensible_w: f64,
|
||||
/// Latent capacity [W] (q_total − q_sensible, ≥ 0).
|
||||
pub q_latent_w: f64,
|
||||
/// Leaving air dry-bulb [K].
|
||||
pub t_air_out_k: f64,
|
||||
/// Leaving water temperature [K].
|
||||
pub t_water_out_k: f64,
|
||||
/// Sensible heat ratio SHR = q_sensible / q_total.
|
||||
pub shr: f64,
|
||||
/// Effectiveness of the dry ε-NTU backbone [-].
|
||||
pub effectiveness: f64,
|
||||
}
|
||||
|
||||
/// Fan-coil unit with fixed UA and BPF wet-coil model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FanCoilUnit {
|
||||
ua: f64,
|
||||
/// Default bypass factor when not overridden in the rating call.
|
||||
default_bpf: f64,
|
||||
}
|
||||
|
||||
impl FanCoilUnit {
|
||||
/// Creates an FCU with overall UA [W/K].
|
||||
pub fn new(ua: f64) -> Self {
|
||||
Self {
|
||||
ua: ua.max(0.0),
|
||||
default_bpf: 0.15,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets default bypass factor.
|
||||
pub fn with_bypass_factor(mut self, bpf: f64) -> Self {
|
||||
self.default_bpf = bpf.clamp(0.0, 0.5);
|
||||
self
|
||||
}
|
||||
|
||||
/// Rates the coil. Uses `input.bypass_factor` if > 0, else default BPF.
|
||||
pub fn rate(&self, input: &FanCoilRatingInput) -> FanCoilRating {
|
||||
let bpf = if input.bypass_factor > 0.0 {
|
||||
input.bypass_factor.clamp(0.0, 0.5)
|
||||
} else {
|
||||
self.default_bpf
|
||||
};
|
||||
let c_min = input.c_air.min(input.c_water).max(1.0);
|
||||
let c_max = input.c_air.max(input.c_water).max(c_min);
|
||||
let cr = c_min / c_max;
|
||||
let model = EpsNtuModel::new(self.ua, ExchangerType::CrossFlowUnmixed);
|
||||
let ntu = self.ua / c_min;
|
||||
let eps = model.effectiveness(ntu, cr);
|
||||
|
||||
// Dry ε-NTU sensible backbone on water–air.
|
||||
let q_max = c_min * (input.t_air_in_k - input.t_water_in_k).max(0.0);
|
||||
let q_dry = eps * q_max;
|
||||
|
||||
// Wet-coil leaving air via BPF / ADP.
|
||||
let t_air_out = bpf * input.t_air_in_k + (1.0 - bpf) * input.t_adp_k;
|
||||
let q_sensible = input.c_air * (input.t_air_in_k - t_air_out).max(0.0);
|
||||
|
||||
// Latent proxy: dewpoint depression when coil below dewpoint.
|
||||
let wet = input.t_adp_k < input.t_dew_in_k;
|
||||
let q_latent = if wet {
|
||||
// Contact factor × latent drive (simplified, ~2450 kJ/kg · Δω proxy via ΔT).
|
||||
let cf = 1.0 - bpf;
|
||||
cf * input.c_air * 0.5 * (input.t_dew_in_k - input.t_adp_k).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let (q_total, q_sensible_out, q_latent_out) = if wet {
|
||||
(q_sensible + q_latent, q_sensible, q_latent)
|
||||
} else {
|
||||
let q = q_dry.max(q_sensible);
|
||||
(q, q, 0.0)
|
||||
};
|
||||
let t_water_out = input.t_water_in_k + q_total / input.c_water.max(1.0);
|
||||
let shr = if wet && q_total > 1.0 {
|
||||
(q_sensible_out / q_total).clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
FanCoilRating {
|
||||
q_total_w: q_total,
|
||||
q_sensible_w: q_sensible_out,
|
||||
q_latent_w: q_latent_out,
|
||||
t_air_out_k: t_air_out,
|
||||
t_water_out_k: t_water_out,
|
||||
shr,
|
||||
effectiveness: eps,
|
||||
}
|
||||
}
|
||||
|
||||
/// UA [W/K].
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.ua
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cool_input() -> FanCoilRatingInput {
|
||||
FanCoilRatingInput {
|
||||
t_air_in_k: 300.15, // 27°C
|
||||
t_dew_in_k: 289.15, // 16°C dew
|
||||
t_water_in_k: 280.15, // 7°C
|
||||
c_air: 1200.0,
|
||||
c_water: 2500.0,
|
||||
bypass_factor: 0.15,
|
||||
t_adp_k: 283.15, // 10°C
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wet_coil_has_latent() {
|
||||
let fcu = FanCoilUnit::new(8000.0);
|
||||
let r = fcu.rate(&cool_input());
|
||||
assert!(r.q_latent_w > 0.0);
|
||||
assert!(r.shr < 1.0);
|
||||
assert!(r.t_air_out_k < cool_input().t_air_in_k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_coil_zero_latent() {
|
||||
let fcu = FanCoilUnit::new(8000.0);
|
||||
let mut inp = cool_input();
|
||||
inp.t_adp_k = 295.0; // above dewpoint
|
||||
let r = fcu.rate(&inp);
|
||||
assert_eq!(r.q_latent_w, 0.0);
|
||||
assert!((r.shr - 1.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
909
crates/components/src/heat_exchanger/fin_coil_condenser.rs
Normal file
909
crates/components/src/heat_exchanger/fin_coil_condenser.rs
Normal file
@@ -0,0 +1,909 @@
|
||||
//! Fin-and-Tube Condenser Coil — RTPF (Round Tube Plate Fin)
|
||||
//!
|
||||
//! Modèle physique complet d'un condenseur à air à ailettes et tubes ronds,
|
||||
//! tel qu'utilisé dans les chillers air-cooled industriels (Carrier, Trane, York, Daikin).
|
||||
//!
|
||||
//! ## Paramètres géométriques ingénieur
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌──────────────────────────────────────────────────┐
|
||||
//! │ COIL FACE (vue de face) │
|
||||
//! │ │
|
||||
//! │ ←── face_width_m ──→ │
|
||||
//! │ ┌─────────────────────┐ ↑ │
|
||||
//! │ │ ○ ○ ○ ○ ○ ○ │ │ face_height_m │
|
||||
//! │ │ ○ ○ ○ ○ ○ ○ │ │ │
|
||||
//! │ │ ○ ○ ○ ○ ○ ○ │ ↓ │
|
||||
//! │ └─────────────────────┘ │
|
||||
//! │ ↑ Pt (tube pitch transversal) │
|
||||
//! └──────────────────────────────────────────────────┘
|
||||
//!
|
||||
//! Profondeur (coupe latérale) :
|
||||
//! ← n_rows × Pl →
|
||||
//! ○ ○ ○ (n_rows = 3, arrangement staggered)
|
||||
//! ○ ○
|
||||
//! ○ ○ ○
|
||||
//! ```
|
||||
//!
|
||||
//! ## Physique — UA depuis la géométrie
|
||||
//!
|
||||
//! ### Côté air (dominant)
|
||||
//!
|
||||
//! Corrélation de Chang & Wang (1997) pour ailettes à persiennes (louvered) :
|
||||
//! ```text
|
||||
//! j = C × Re_Lp^n (facteur de Colburn pour transfert de chaleur)
|
||||
//! h_air = j × G_air × Cp_air / Pr^(2/3)
|
||||
//! ```
|
||||
//! Pour autres types d'ailettes : facteurs multiplicatifs empiriques.
|
||||
//!
|
||||
//! Efficacité des ailettes (profil rectangulaire) :
|
||||
//! ```text
|
||||
//! m = sqrt(2 × h_air / (k_fin × t_fin))
|
||||
//! L = (Pt/2 - d_tube/2) × correction_stagger
|
||||
//! η_fin = tanh(m × L) / (m × L)
|
||||
//! η_surface = 1 - (1 - η_fin) × A_fin / A_total
|
||||
//! UA_air = η_surface × h_air × A_total
|
||||
//! ```
|
||||
//!
|
||||
//! ### Côté réfrigérant (condensation, résistance faible ~5-10%)
|
||||
//!
|
||||
//! ```text
|
||||
//! UA_total = 1 / (1/UA_air + 1/UA_ref) ≈ UA_air × 0.92 (résistance réfrigérant ≈ 8%)
|
||||
//! ```
|
||||
//!
|
||||
//! ### Température de condensation — Méthode ε-NTU (isotherme côté réfrigérant)
|
||||
//!
|
||||
//! Pour condensation isotherme (T_cond = const), la méthode ε-NTU donne :
|
||||
//! ```text
|
||||
//! ṁ_air = ρ_air × A_face × v_face
|
||||
//! C_air = ṁ_air × Cp_air
|
||||
//! NTU = UA_total / C_air
|
||||
//! ε = 1 - exp(-NTU) (efficacité pour fluide isotherme)
|
||||
//! ΔT_air = Q_design / C_air (montée en température côté air)
|
||||
//! T_cond = OAT + ΔT_air / ε (EXACT pour condensation isotherme)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Références
|
||||
//!
|
||||
//! - Chang & Wang (1997) : Int. J. Heat Mass Transfer, 40(3):533–544
|
||||
//! - ASHRAE Handbook of Fundamentals (2021), Chap. 4 — Two-Phase Flow
|
||||
//! - Webb & Kim (2005) : Principles of Enhanced Heat Transfer
|
||||
|
||||
use super::condenser::Condenser;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::Calib;
|
||||
use entropyk_fluids::FluidBackend;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Constantes physiques air (conditions standard)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Chaleur spécifique de l'air sec [J/(kg·K)]
|
||||
const CP_AIR: f64 = 1006.0;
|
||||
/// Nombre de Prandtl de l'air (~35°C)
|
||||
const PR_AIR: f64 = 0.707;
|
||||
/// Viscosité dynamique de l'air (~35°C) [Pa·s]
|
||||
const MU_AIR: f64 = 1.87e-5;
|
||||
/// Conductivité thermique de l'air (~35°C) [W/(m·K)]
|
||||
#[allow(dead_code)] // Reference air property, kept for completeness/future correlations.
|
||||
const K_AIR: f64 = 0.0270;
|
||||
/// Conductivité des ailettes aluminium [W/(m·K)]
|
||||
const K_FIN_ALU: f64 = 205.0;
|
||||
/// Densité air standard (35°C, 101.325 kPa) [kg/m³]
|
||||
#[allow(dead_code)] // Reference air property, kept for completeness/future correlations.
|
||||
const RHO_AIR_STD: f64 = 1.145;
|
||||
/// Fraction de résistance côté réfrigérant (condensation) — correction UA_total
|
||||
const REF_RESISTANCE_FACTOR: f64 = 0.92;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types publics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Type d'ailette — détermine la corrélation de transfert de chaleur côté air.
|
||||
///
|
||||
/// | Type | Corrélation | Performance relative | Application typique |
|
||||
/// |------------|-----------------|----------------------|-----------------------------|
|
||||
/// | Louvered | Chang & Wang | 100% (référence) | Chillers air-cooled modernes|
|
||||
/// | Wavy | Corrélation VDI | 78% | Anciennes unités, plus robuste|
|
||||
/// | Slit | Interpolation | 93% | Compromis performance/coût |
|
||||
/// | Plain | Gnielinski-fin | 58% | Ambiances corrosives/poussiéreuses|
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FinType {
|
||||
/// Ailettes à persiennes (louvered) — technologie dominante pour chillers modernes.
|
||||
/// Corrélation Chang & Wang (1997) : j = 0.49 × (Lp/Fp)^0.27 × Re_Lp^(-0.49)
|
||||
Louvered,
|
||||
/// Ailettes ondulées (wavy/corrugated).
|
||||
Wavy,
|
||||
/// Ailettes à fentes (slit fins).
|
||||
Slit,
|
||||
/// Ailettes plates (plain fins) — application ambiances poussiéreuses.
|
||||
Plain,
|
||||
}
|
||||
|
||||
impl FinType {
|
||||
/// Facteur multiplicatif sur le coefficient j de Colburn par rapport aux ailettes louvered.
|
||||
/// Valeurs calibrées sur données fabricants (Carrier, Trane, York).
|
||||
pub fn j_factor_relative(&self) -> f64 {
|
||||
match self {
|
||||
FinType::Louvered => 1.00,
|
||||
FinType::Slit => 0.93,
|
||||
FinType::Wavy => 0.78,
|
||||
FinType::Plain => 0.58,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nom textuel pour la sérialisation JSON.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FinType::Louvered => "louvered",
|
||||
FinType::Wavy => "wavy",
|
||||
FinType::Slit => "slit",
|
||||
FinType::Plain => "plain",
|
||||
}
|
||||
}
|
||||
|
||||
/// Désérialisation depuis chaîne JSON.
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"louvered" | "louver" | "lv" => FinType::Louvered,
|
||||
"wavy" | "wave" | "corrugated" => FinType::Wavy,
|
||||
"slit" => FinType::Slit,
|
||||
"plain" | "flat" => FinType::Plain,
|
||||
_ => FinType::Louvered, // Default sécurisé
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Géométrie d'un condenseur RTPF.
|
||||
///
|
||||
/// Tous les paramètres par défaut correspondent à un condenseur de chiller
|
||||
/// air-cooled industriel typique (Carrier 30XA / Trane RTAC).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoilGeometry {
|
||||
/// Largeur de face de bobine [m] (dimension horizontale du coil)
|
||||
pub face_width_m: f64,
|
||||
/// Hauteur de face de bobine [m] (dimension verticale)
|
||||
pub face_height_m: f64,
|
||||
/// Nombre de rangs de tubes (profondeur du coil)
|
||||
pub n_rows: u32,
|
||||
/// Type d'ailettes
|
||||
pub fin_type: FinType,
|
||||
/// Pas des ailettes [ailettes/pouce], typique : 10–18 FPI
|
||||
pub fin_pitch_fpi: f64,
|
||||
/// Épaisseur des ailettes aluminium [m] (défaut 0.1 mm)
|
||||
pub fin_thickness_m: f64,
|
||||
/// Diamètre extérieur des tubes [m] (défaut 9.52 mm = 3/8")
|
||||
pub tube_od_m: f64,
|
||||
/// Pas transversal des tubes (Pt) [m] (défaut 25.4 mm = 1")
|
||||
pub tube_pitch_transverse_m: f64,
|
||||
/// Pas longitudinal des tubes (Pl) [m] (défaut 22.0 mm, arrangement staggered)
|
||||
pub tube_pitch_longitudinal_m: f64,
|
||||
/// Louver pitch [m] — pour ailettes louvered (défaut 1.4 mm)
|
||||
pub louver_pitch_m: f64,
|
||||
/// Longueur des persiennes [m] — pour ailettes louvered (défaut 8.0 mm)
|
||||
pub louver_length_m: f64,
|
||||
}
|
||||
|
||||
impl Default for CoilGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
face_width_m: 1.0,
|
||||
face_height_m: 1.0,
|
||||
n_rows: 3,
|
||||
fin_type: FinType::Louvered,
|
||||
fin_pitch_fpi: 14.0,
|
||||
fin_thickness_m: 0.0001, // 0.1 mm
|
||||
tube_od_m: 0.009525, // 9.525 mm = 3/8"
|
||||
tube_pitch_transverse_m: 0.0254, // 25.4 mm = 1"
|
||||
tube_pitch_longitudinal_m: 0.022, // 22.0 mm staggered
|
||||
louver_pitch_m: 0.0014, // 1.4 mm
|
||||
louver_length_m: 0.008, // 8.0 mm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CoilGeometry {
|
||||
/// Construit une géométrie depuis la surface de face et le nombre de rangs.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `face_width_m` — Largeur de face [m]
|
||||
/// * `face_height_m` — Hauteur de face [m]
|
||||
/// * `n_rows` — Nombre de rangs de tubes
|
||||
pub fn new(face_width_m: f64, face_height_m: f64, n_rows: u32) -> Self {
|
||||
Self {
|
||||
face_width_m,
|
||||
face_height_m,
|
||||
n_rows,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Surface de face du coil [m²].
|
||||
pub fn face_area_m2(&self) -> f64 {
|
||||
self.face_width_m * self.face_height_m
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Composant principal
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Condenseur à air RTPF (Round Tube Plate Fin) — modèle physique complet.
|
||||
///
|
||||
/// L'ingénieur définit :
|
||||
/// 1. La géométrie du coil (rangs, type d'ailettes, dimensions)
|
||||
/// 2. Les conditions d'air (OAT, vitesse de face)
|
||||
/// 3. La capacité nominale (pour le calcul de T_cond via ε-NTU)
|
||||
///
|
||||
/// Le composant calcule automatiquement :
|
||||
/// - UA total à partir de la géométrie (corrélation Chang & Wang)
|
||||
/// - Température de condensation T_cond via la méthode ε-NTU
|
||||
///
|
||||
/// # Exemple JSON
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": "FinCoilCondenser",
|
||||
/// "name": "cond_bat",
|
||||
/// "oat_k": 308.15,
|
||||
/// "face_width_m": 2.0,
|
||||
/// "face_height_m": 1.5,
|
||||
/// "n_rows": 3,
|
||||
/// "fin_type": "louvered",
|
||||
/// "fin_pitch_fpi": 14,
|
||||
/// "air_face_velocity_m_s": 2.5,
|
||||
/// "design_capacity_kw": 125.0
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Exemple Rust
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_components::heat_exchanger::{FinCoilCondenser, CoilGeometry, FinType};
|
||||
///
|
||||
/// let geometry = CoilGeometry {
|
||||
/// face_width_m: 2.0,
|
||||
/// face_height_m: 1.5,
|
||||
/// n_rows: 3,
|
||||
/// fin_type: FinType::Louvered,
|
||||
/// fin_pitch_fpi: 14.0,
|
||||
/// ..Default::default()
|
||||
/// };
|
||||
///
|
||||
/// let cond = FinCoilCondenser::new(
|
||||
/// 308.15, // OAT = 35°C [K]
|
||||
/// geometry,
|
||||
/// 2.5, // vitesse air [m/s]
|
||||
/// 125_000.0, // Q_design [W]
|
||||
/// );
|
||||
///
|
||||
/// println!("UA = {:.0} W/K", cond.ua_total());
|
||||
/// println!("T_cond = {:.1} °C", cond.t_cond_k() - 273.15);
|
||||
/// println!("Approach = {:.1} K", cond.approach_k());
|
||||
/// ```
|
||||
pub struct FinCoilCondenser {
|
||||
inner: Condenser,
|
||||
|
||||
// ── Inputs ingénieur ──────────────────────────────────────────────────
|
||||
oat_k: f64,
|
||||
geometry: CoilGeometry,
|
||||
air_face_velocity_m_s: f64,
|
||||
air_density_kg_m3: f64,
|
||||
design_capacity_w: f64,
|
||||
/// When true, apply Wang–Lin–Lee (2000) wet-surface j-factor correction.
|
||||
wet_surface: bool,
|
||||
|
||||
// ── Résultats calculés ────────────────────────────────────────────────
|
||||
ua_air_side: f64, // UA côté air [W/K]
|
||||
ua_total: f64, // UA total (air + réfrigérant) [W/K]
|
||||
t_cond_k: f64, // Température de condensation calculée [K]
|
||||
approach_k: f64, // Approche = T_cond − OAT [K]
|
||||
ntu: f64, // Nombre d'Unités de Transfert []
|
||||
effectiveness: f64, // Efficacité ε = 1 − exp(−NTU)
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FinCoilCondenser {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FinCoilCondenser")
|
||||
.field("oat_k", &self.oat_k)
|
||||
.field("t_cond_k", &self.t_cond_k)
|
||||
.field("approach_k", &self.approach_k)
|
||||
.field("ua_total", &self.ua_total)
|
||||
.field("ntu", &self.ntu)
|
||||
.field("n_rows", &self.geometry.n_rows)
|
||||
.field("fin_type", &self.geometry.fin_type)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FinCoilCondenser {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Constructeurs
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Construit un condenseur RTPF complet.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `oat_k` — Outdoor Air Temperature \[K\]
|
||||
/// * `geometry` — Géométrie du coil (rangs, ailettes, tubes)
|
||||
/// * `air_face_velocity` — Vitesse d'air en face de coil \[m/s\] (typique : 2.0–3.0)
|
||||
/// * `design_capacity_w` — Capacité de rejet thermique au point nominal \[W\]
|
||||
pub fn new(
|
||||
oat_k: f64,
|
||||
geometry: CoilGeometry,
|
||||
air_face_velocity_m_s: f64,
|
||||
design_capacity_w: f64,
|
||||
) -> Self {
|
||||
// Densité air à OAT (loi des gaz parfaits, P = 101325 Pa, R = 287 J/kg·K)
|
||||
let rho_air = 101_325.0 / (287.0 * oat_k);
|
||||
|
||||
let mut s = Self {
|
||||
inner: Condenser::new(0.0), // UA sera mis à jour
|
||||
oat_k,
|
||||
geometry,
|
||||
air_face_velocity_m_s,
|
||||
air_density_kg_m3: rho_air,
|
||||
design_capacity_w,
|
||||
wet_surface: false,
|
||||
ua_air_side: 0.0,
|
||||
ua_total: 0.0,
|
||||
t_cond_k: oat_k + 15.0, // valeur initiale
|
||||
approach_k: 15.0,
|
||||
ntu: 0.0,
|
||||
effectiveness: 0.0,
|
||||
};
|
||||
s.recompute();
|
||||
s
|
||||
}
|
||||
|
||||
/// Enables Wang–Lin–Lee wet-surface air-side correlation (cooling coils).
|
||||
pub fn with_wet_surface(mut self, wet: bool) -> Self {
|
||||
self.wet_surface = wet;
|
||||
self.recompute();
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns whether wet-surface air-side correlations are active.
|
||||
pub fn wet_surface(&self) -> bool {
|
||||
self.wet_surface
|
||||
}
|
||||
|
||||
/// Attache un identifiant de fluide réfrigérant.
|
||||
pub fn with_refrigerant(mut self, id: &str) -> Self {
|
||||
self.inner = self.inner.with_refrigerant(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attache un backend de propriétés thermodynamiques.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.inner = self.inner.with_fluid_backend(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Surcharge la densité d'air (par ex. altitude élevée).
|
||||
pub fn with_air_density(mut self, rho: f64) -> Self {
|
||||
self.air_density_kg_m3 = rho;
|
||||
self.recompute();
|
||||
self
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Setters runtime (simulation paramétrique)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Met à jour OAT et recalcule T_cond.
|
||||
pub fn set_oat_k(&mut self, oat_k: f64) {
|
||||
self.oat_k = oat_k;
|
||||
self.air_density_kg_m3 = 101_325.0 / (287.0 * oat_k);
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
/// Met à jour la vitesse d'air et recalcule UA + T_cond.
|
||||
pub fn set_air_face_velocity(&mut self, v: f64) {
|
||||
self.air_face_velocity_m_s = v;
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
/// Met à jour la capacité nominale et recalcule T_cond.
|
||||
pub fn set_design_capacity_w(&mut self, q_w: f64) {
|
||||
self.design_capacity_w = q_w;
|
||||
self.recompute();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Getters
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Température de condensation calculée [K].
|
||||
pub fn t_cond_k(&self) -> f64 {
|
||||
self.t_cond_k
|
||||
}
|
||||
|
||||
/// OAT [K].
|
||||
pub fn oat_k(&self) -> f64 {
|
||||
self.oat_k
|
||||
}
|
||||
|
||||
/// Approche = T_cond − OAT [K].
|
||||
pub fn approach_k(&self) -> f64 {
|
||||
self.approach_k
|
||||
}
|
||||
|
||||
/// UA côté air [W/K].
|
||||
pub fn ua_air_side(&self) -> f64 {
|
||||
self.ua_air_side
|
||||
}
|
||||
|
||||
/// UA total (air + réfrigérant) [W/K].
|
||||
pub fn ua_total(&self) -> f64 {
|
||||
self.ua_total
|
||||
}
|
||||
|
||||
/// Nombre d'unités de transfert NTU.
|
||||
pub fn ntu(&self) -> f64 {
|
||||
self.ntu
|
||||
}
|
||||
|
||||
/// Efficacité ε du condenseur.
|
||||
pub fn effectiveness(&self) -> f64 {
|
||||
self.effectiveness
|
||||
}
|
||||
|
||||
/// Débit massique d'air [kg/s].
|
||||
pub fn air_mass_flow_kg_s(&self) -> f64 {
|
||||
self.air_density_kg_m3 * self.geometry.face_area_m2() * self.air_face_velocity_m_s
|
||||
}
|
||||
|
||||
/// Puissance côté air C_air = ṁ_air × Cp_air [W/K].
|
||||
pub fn c_air(&self) -> f64 {
|
||||
self.air_mass_flow_kg_s() * CP_AIR
|
||||
}
|
||||
|
||||
/// Température de sortie de l'air [K].
|
||||
pub fn t_air_out_k(&self) -> f64 {
|
||||
self.oat_k + self.design_capacity_w / self.c_air().max(1.0)
|
||||
}
|
||||
|
||||
/// Géométrie du coil.
|
||||
pub fn geometry(&self) -> &CoilGeometry {
|
||||
&self.geometry
|
||||
}
|
||||
|
||||
/// UA [W/K] (alias vers ua_total pour compatibilité avec Condenser).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.ua_total
|
||||
}
|
||||
|
||||
/// Facteurs de calibration.
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Calcul interne : UA depuis géométrie + T_cond via ε-NTU
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Recalcule UA_air, UA_total et T_cond depuis les paramètres courants.
|
||||
///
|
||||
/// Appelé automatiquement à la construction et lors de tout changement
|
||||
/// des paramètres (OAT, vitesse d'air, capacité nominale).
|
||||
fn recompute(&mut self) {
|
||||
self.ua_air_side = self.compute_ua_air();
|
||||
// Correction résistance réfrigérant (condensation ~ 8% de la résistance totale)
|
||||
self.ua_total = self.ua_air_side * REF_RESISTANCE_FACTOR;
|
||||
|
||||
// ε-NTU pour condensation isotherme
|
||||
let c_air = self.c_air().max(1.0);
|
||||
self.ntu = self.ua_total / c_air;
|
||||
self.effectiveness = 1.0 - (-self.ntu).exp();
|
||||
|
||||
// T_cond analytique (isothermal refrigerant, Chang & Wang 1997, App. B)
|
||||
// ΔT_air = Q / C_air
|
||||
// ε = ΔT_air / (T_cond - OAT) → T_cond = OAT + ΔT_air / ε
|
||||
let delta_t_air = self.design_capacity_w / c_air;
|
||||
self.t_cond_k = if self.effectiveness > 1e-6 {
|
||||
self.oat_k + delta_t_air / self.effectiveness
|
||||
} else {
|
||||
self.oat_k + 20.0 // fallback si ε ≈ 0
|
||||
};
|
||||
self.approach_k = self.t_cond_k - self.oat_k;
|
||||
|
||||
// Mettre à jour le condenseur interne avec la nouvelle T_cond
|
||||
self.inner.set_saturation_temp(self.t_cond_k);
|
||||
self.inner.set_ua(self.ua_total);
|
||||
}
|
||||
|
||||
/// Calcule le UA côté air via la corrélation Chang & Wang (1997) + efficacité ailettes.
|
||||
///
|
||||
/// ## Algorithme
|
||||
///
|
||||
/// 1. Géométrie → paramètres de maille (cellule unitaire)
|
||||
/// 2. Vitesse d'air interstitielle → Reynolds Re_Lp
|
||||
/// 3. Facteur j de Colburn → coefficient h_air
|
||||
/// 4. Efficacité ailettes η_fin (profil rectangulaire)
|
||||
/// 5. Efficacité de surface η_surface
|
||||
/// 6. UA_air = η_surface × h_air × A_total
|
||||
fn compute_ua_air(&self) -> f64 {
|
||||
let geom = &self.geometry;
|
||||
|
||||
// ── Paramètres dérivés de la géométrie ───────────────────────────
|
||||
let fin_pitch_m = 0.0254 / geom.fin_pitch_fpi; // distance centre-à-centre entre ailettes [m]
|
||||
let n_rows = geom.n_rows as f64;
|
||||
|
||||
// Nombre de tubes :
|
||||
// - Dans la direction hauteur (transversale) : n_tubes_per_row = H / Pt
|
||||
// - Profondeur (n_rows rangées) : n_tubes_total = n_per_row × n_rows
|
||||
// - Chaque tube traverse TOUTE la largeur W (longueur de tube = face_width_m)
|
||||
let n_tubes_per_row = (geom.face_height_m / geom.tube_pitch_transverse_m)
|
||||
.floor()
|
||||
.max(1.0);
|
||||
let n_tubes = n_tubes_per_row * n_rows; // tubes au total dans le coil
|
||||
|
||||
// ── Surfaces des ailettes ─────────────────────────────────────────
|
||||
// Chaque ailette : plaque H × coil_depth avec n_tubes trous
|
||||
// coil_depth = n_rows × Pl (Pl = pas longitudinal, profondeur du coil)
|
||||
// A_fin_1_face = H × coil_depth − n_tubes × π×d²/4
|
||||
// A_fin_2_faces = 2 × A_fin_1_face
|
||||
let coil_depth_m = n_rows * geom.tube_pitch_longitudinal_m;
|
||||
let tube_hole_area = n_tubes * std::f64::consts::PI * geom.tube_od_m.powi(2) / 4.0;
|
||||
let a_fin_per_fin = 2.0 * (geom.face_height_m * coil_depth_m - tube_hole_area).max(0.0);
|
||||
|
||||
// Nombre d'ailettes sur la largeur du coil
|
||||
let n_fins = geom.face_width_m / fin_pitch_m;
|
||||
let a_fin_total = n_fins * a_fin_per_fin;
|
||||
|
||||
// ── Surface nue des tubes (portions entre ailettes) ───────────────
|
||||
let tube_perimeter = std::f64::consts::PI * geom.tube_od_m;
|
||||
let tube_length_between_fins = fin_pitch_m - geom.fin_thickness_m;
|
||||
let a_tube_bare = n_tubes * tube_perimeter * tube_length_between_fins.max(0.0);
|
||||
|
||||
let a_total = a_fin_total + a_tube_bare;
|
||||
|
||||
// ── Vitesse d'air massique G [kg/(m²·s)] ─────────────────────────
|
||||
// Section libre minimale : σ = (Pt - d_tube) / Pt (approximation)
|
||||
let sigma = (geom.tube_pitch_transverse_m - geom.tube_od_m) / geom.tube_pitch_transverse_m;
|
||||
let g_air = self.air_density_kg_m3 * self.air_face_velocity_m_s / sigma.max(0.1);
|
||||
|
||||
// ── Nombre de Reynolds basé sur louver pitch (Re_Lp) ─────────────
|
||||
let re_lp = g_air * geom.louver_pitch_m / MU_AIR;
|
||||
|
||||
// ── Facteur j de Colburn (Chang & Wang 1997, Eq.4) ───────────────
|
||||
// j_louvered = 0.49 × (Lp/Fp)^0.27 × Re_Lp^(-0.49) × (Lh/Lp)^(-0.14) × (N)^(-0.29)
|
||||
// Version simplifiée (terme dominant) :
|
||||
let lp_over_fp = geom.louver_pitch_m / fin_pitch_m;
|
||||
let j_louvered = 0.49
|
||||
* lp_over_fp.powf(0.27)
|
||||
* re_lp.powf(-0.49)
|
||||
* (geom.louver_length_m / geom.louver_pitch_m).powf(-0.14)
|
||||
* n_rows.powf(-0.29);
|
||||
|
||||
// Correction pour le type d'ailettes
|
||||
let mut j = j_louvered * geom.fin_type.j_factor_relative();
|
||||
|
||||
// Wang, Lin & Lee (2000) wet-surface plain-fin correction (j_wet / j_dry ≈ 0.85–1.05).
|
||||
// Simplified multiplicative factor used when condensate films the fins.
|
||||
if self.wet_surface {
|
||||
let re_dc = g_air * geom.tube_od_m / MU_AIR;
|
||||
let j_wet_factor = 0.914 * re_dc.powf(-0.05);
|
||||
j *= j_wet_factor.clamp(0.7, 1.1);
|
||||
}
|
||||
|
||||
// ── Coefficient convectif côté air h_air [W/(m²·K)] ─────────────
|
||||
let h_air = j * g_air * CP_AIR / PR_AIR.powf(2.0 / 3.0);
|
||||
|
||||
// ── Efficacité des ailettes η_fin (profil rectangulaire) ─────────
|
||||
// Hauteur effective de l'ailette
|
||||
let l_fin = (geom.tube_pitch_transverse_m / 2.0 - geom.tube_od_m / 2.0)
|
||||
* (1.0 + 0.35 * (geom.tube_pitch_transverse_m / geom.tube_od_m).ln());
|
||||
let m = ((2.0 * h_air) / (K_FIN_ALU * geom.fin_thickness_m)).sqrt();
|
||||
let ml = (m * l_fin).max(1e-9);
|
||||
let eta_fin = if ml < 1e-6 { 1.0 } else { ml.tanh() / ml };
|
||||
|
||||
// ── Efficacité de surface ─────────────────────────────────────────
|
||||
let eta_surface = if a_total > 0.0 {
|
||||
1.0 - (1.0 - eta_fin) * a_fin_total / a_total
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// ── UA côté air ──────────────────────────────────────────────────
|
||||
let ua_air = eta_surface * h_air * a_total;
|
||||
|
||||
// Garde-fous : valeur physiquement raisonnable
|
||||
ua_air.max(100.0).min(1_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Implémentation du trait Component — délégation vers inner Condenser
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl Component for FinCoilCondenser {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.inner
|
||||
.set_system_context(state_offset, external_edge_state_indices);
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.compute_residuals(state, residuals)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inner.n_equations()
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
self.inner.port_enthalpies(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FinCoilCondenser(oat={:.1}K, T_cond={:.1}K, approach={:.1}K, UA={:.0}W/K, NTU={:.2}, {}×{}rows)",
|
||||
self.oat_k,
|
||||
self.t_cond_k,
|
||||
self.approach_k,
|
||||
self.ua_total,
|
||||
self.ntu,
|
||||
self.geometry.fin_type.as_str(),
|
||||
self.geometry.n_rows,
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
self.inner.to_params()
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
self.inner.update_calib_factor(factor, value)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for FinCoilCondenser {
|
||||
fn state(&self) -> OperationalState {
|
||||
self.inner.state()
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
self.inner.set_state(state)
|
||||
}
|
||||
|
||||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||
self.inner.can_transition_to(target)
|
||||
}
|
||||
|
||||
fn circuit_id(&self) -> &CircuitId {
|
||||
self.inner.circuit_id()
|
||||
}
|
||||
|
||||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||
self.inner.set_circuit_id(circuit_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_typical_condenser() -> FinCoilCondenser {
|
||||
// Chiller ~100 kW, condenseur 3 rangs, 14 FPI louvered, OAT=35°C
|
||||
let geom = CoilGeometry {
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
n_rows: 3,
|
||||
fin_type: FinType::Louvered,
|
||||
fin_pitch_fpi: 14.0,
|
||||
..Default::default()
|
||||
};
|
||||
FinCoilCondenser::new(308.15, geom, 2.5, 125_000.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
|
||||
assert_eq!(make_typical_condenser().n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_positive() {
|
||||
let c = make_typical_condenser();
|
||||
assert!(
|
||||
c.ua_total() > 1000.0,
|
||||
"UA should be > 1 kW/K, got {}",
|
||||
c.ua_total()
|
||||
);
|
||||
assert!(
|
||||
c.ua_total() < 200_000.0,
|
||||
"UA unrealistically high: {}",
|
||||
c.ua_total()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approach_realistic() {
|
||||
let c = make_typical_condenser();
|
||||
// Chiller air-cooled : approach typique 10–25 K
|
||||
assert!(
|
||||
c.approach_k() > 5.0 && c.approach_k() < 35.0,
|
||||
"Approach unrealistic: {:.1} K (UA={:.0} W/K)",
|
||||
c.approach_k(),
|
||||
c.ua_total()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_t_cond_above_oat() {
|
||||
let c = make_typical_condenser();
|
||||
assert!(
|
||||
c.t_cond_k() > c.oat_k(),
|
||||
"T_cond ({:.1} K) must be > OAT ({:.1} K)",
|
||||
c.t_cond_k(),
|
||||
c.oat_k()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_more_rows_lower_approach() {
|
||||
let geom3 = CoilGeometry {
|
||||
n_rows: 3,
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
..Default::default()
|
||||
};
|
||||
let geom4 = CoilGeometry {
|
||||
n_rows: 4,
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
..Default::default()
|
||||
};
|
||||
let c3 = FinCoilCondenser::new(308.15, geom3, 2.5, 125_000.0);
|
||||
let c4 = FinCoilCondenser::new(308.15, geom4, 2.5, 125_000.0);
|
||||
assert!(
|
||||
c4.approach_k() < c3.approach_k(),
|
||||
"More rows should reduce approach: 3rows={:.1}K, 4rows={:.1}K",
|
||||
c3.approach_k(),
|
||||
c4.approach_k()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_higher_velocity_lower_approach() {
|
||||
let geom = CoilGeometry {
|
||||
n_rows: 3,
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
..Default::default()
|
||||
};
|
||||
let c_slow = FinCoilCondenser::new(308.15, geom.clone(), 2.0, 125_000.0);
|
||||
let c_fast = FinCoilCondenser::new(308.15, geom, 3.5, 125_000.0);
|
||||
assert!(
|
||||
c_fast.approach_k() < c_slow.approach_k(),
|
||||
"Higher velocity should reduce approach: 2m/s={:.1}K, 3.5m/s={:.1}K",
|
||||
c_slow.approach_k(),
|
||||
c_fast.approach_k()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_louvered_vs_plain_fins() {
|
||||
let geom_louv = CoilGeometry {
|
||||
n_rows: 3,
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
fin_type: FinType::Louvered,
|
||||
..Default::default()
|
||||
};
|
||||
let geom_plain = CoilGeometry {
|
||||
n_rows: 3,
|
||||
face_width_m: 2.0,
|
||||
face_height_m: 1.5,
|
||||
fin_type: FinType::Plain,
|
||||
..Default::default()
|
||||
};
|
||||
let c_louv = FinCoilCondenser::new(308.15, geom_louv, 2.5, 125_000.0);
|
||||
let c_plain = FinCoilCondenser::new(308.15, geom_plain, 2.5, 125_000.0);
|
||||
assert!(
|
||||
c_louv.ua_total() > c_plain.ua_total(),
|
||||
"Louvered fins should give higher UA than plain"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_oat_updates_t_cond() {
|
||||
let mut c = make_typical_condenser();
|
||||
let t_cond_35 = c.t_cond_k();
|
||||
c.set_oat_k(313.15); // OAT = 40°C
|
||||
let t_cond_40 = c.t_cond_k();
|
||||
assert!(
|
||||
t_cond_40 > t_cond_35,
|
||||
"T_cond should increase with OAT: 35°C→{:.1}K, 40°C→{:.1}K",
|
||||
t_cond_35,
|
||||
t_cond_40
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_epsilon_ntu_consistency() {
|
||||
let c = make_typical_condenser();
|
||||
// Vérification : ε × (T_cond - OAT) = ΔT_air = Q/C_air
|
||||
let delta_t_air_check = c.effectiveness() * c.approach_k();
|
||||
let delta_t_air_ref = c.design_capacity_w / c.c_air();
|
||||
assert!(
|
||||
(delta_t_air_check - delta_t_air_ref).abs() < 0.01,
|
||||
"ε-NTU consistency error: {:.3} vs {:.3}",
|
||||
delta_t_air_check,
|
||||
delta_t_air_ref
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fin_type_from_str() {
|
||||
assert_eq!(FinType::from_str("louvered"), FinType::Louvered);
|
||||
assert_eq!(FinType::from_str("wavy"), FinType::Wavy);
|
||||
assert_eq!(FinType::from_str("plain"), FinType::Plain);
|
||||
assert_eq!(FinType::from_str("SLIT"), FinType::Slit);
|
||||
assert_eq!(FinType::from_str("unknown"), FinType::Louvered); // default
|
||||
}
|
||||
}
|
||||
|
||||
// Rendre `design_capacity_w` accessible pour les tests
|
||||
impl FinCoilCondenser {
|
||||
#[doc(hidden)]
|
||||
pub fn design_capacity_w(&self) -> f64 {
|
||||
self.design_capacity_w
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,10 @@ impl Component for FloodedCondenser {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
@@ -334,7 +337,10 @@ impl Component for FloodedCondenser {
|
||||
.with_param("fluid", self.refrigerant_id.as_str())
|
||||
.with_param("ua", self.ua())
|
||||
.with_param("targetSubcoolingK", self.target_subcooling_k)
|
||||
.with_param("calib", serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -414,7 +420,7 @@ mod tests {
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -514,7 +520,7 @@ mod tests {
|
||||
let state = vec![0.0, 0.0, 1_000_000.0, 200_000.0, 0.0, 0.0];
|
||||
let mut residuals = vec![0.0; 4];
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -552,21 +558,21 @@ mod tests {
|
||||
fn test_flooded_condenser_calib_default() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
let calib = cond.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
assert_eq!(calib.z_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_set_calib() {
|
||||
let mut cond = FloodedCondenser::new(15_000.0);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
calib.z_ua = 0.9;
|
||||
cond.set_calib(calib);
|
||||
assert_eq!(cond.calib().f_ua, 0.9);
|
||||
assert_eq!(cond.calib().z_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subcooling_calculation_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_core::Enthalpy;
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
@@ -601,7 +607,7 @@ mod tests {
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
@@ -632,7 +638,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_subcooled_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_core::Enthalpy;
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
@@ -667,7 +673,7 @@ mod tests {
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
182
crates/components/src/heat_exchanger/flow_regularization.rs
Normal file
182
crates/components/src/heat_exchanger/flow_regularization.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
//! C¹/C∞ flow regularization for zero-flow-safe heat-exchanger residuals.
|
||||
//!
|
||||
//! Zero mass flow is a **valid** operating state (circuit staging, isolation,
|
||||
//! Newton trial steps). Hard branches `if |m| < ε { Q = 0 }` create Jacobian
|
||||
//! discontinuities and can leave energy residuals under-ranked.
|
||||
//!
|
||||
//! This module builds on [`entropyk_core::smoothing::smooth_abs`] and provides:
|
||||
//!
|
||||
//! - [`flow_activity`] — smooth activity α(m) ∈ [0, 1), α(0)=0, α→1 as |m| grows
|
||||
//! - [`effective_duty`] — Q scaled by product of stream activities
|
||||
//! - [`blend_transport_residual`] — blend active energy residual with no-flow hold
|
||||
//!
|
||||
//! # Residual units
|
||||
//!
|
||||
//! Active energy residuals are in **watts** (`ṁ·Δh − Q`). The no-flow hold term
|
||||
//! uses `m_scale * Δh` so residual units remain watts when `m_scale` is a
|
||||
//! characteristic mass flow [kg/s].
|
||||
//!
|
||||
//! # Derivatives
|
||||
//!
|
||||
//! All helpers expose analytic first derivatives for exact Jacobians.
|
||||
|
||||
use entropyk_core::smoothing::{smooth_abs, smooth_abs_derivative};
|
||||
|
||||
/// Default mass-flow transition scale [kg/s] (~0.1 g/s).
|
||||
///
|
||||
/// Below this order of magnitude, activity drops toward zero and duty is
|
||||
/// smoothly extinguished. Chosen small relative to HVAC branch flows (0.01–1 kg/s)
|
||||
/// so normal operation is unaffected.
|
||||
pub const DEFAULT_M_EPS_KG_S: f64 = 1e-4;
|
||||
|
||||
/// Default characteristic mass flow [kg/s] for residual scaling of no-flow holds.
|
||||
pub const DEFAULT_M_SCALE_KG_S: f64 = 0.05;
|
||||
|
||||
/// Smooth flow activity α(m) = m² / (m² + ε²) ∈ [0, 1).
|
||||
///
|
||||
/// - α(0) = 0
|
||||
/// - α → 1 as |m| ≫ ε
|
||||
/// - C∞ everywhere
|
||||
///
|
||||
/// Equivalent to `(|m|/smooth_abs(m,ε))²` for ε>0.
|
||||
#[inline]
|
||||
pub fn flow_activity(m: f64, m_eps: f64) -> f64 {
|
||||
let e = m_eps.abs().max(1e-30);
|
||||
let m2 = m * m;
|
||||
m2 / (m2 + e * e)
|
||||
}
|
||||
|
||||
/// Analytic dα/dm for [`flow_activity`].
|
||||
#[inline]
|
||||
pub fn flow_activity_derivative(m: f64, m_eps: f64) -> f64 {
|
||||
let e = m_eps.abs().max(1e-30);
|
||||
let e2 = e * e;
|
||||
let d = m * m + e2;
|
||||
2.0 * m * e2 / (d * d)
|
||||
}
|
||||
|
||||
/// Smooth |m| for capacity-rate constructions C = |ṁ|·cp (never zero denominator).
|
||||
#[inline]
|
||||
pub fn smooth_mass_magnitude(m: f64, m_eps: f64) -> f64 {
|
||||
smooth_abs(m, m_eps.abs().max(1e-30))
|
||||
}
|
||||
|
||||
/// d(|m|_smooth)/dm.
|
||||
#[inline]
|
||||
pub fn smooth_mass_magnitude_derivative(m: f64, m_eps: f64) -> f64 {
|
||||
smooth_abs_derivative(m, m_eps.abs().max(1e-30))
|
||||
}
|
||||
|
||||
/// Effective heat duty after stream activity gating: Q_eff = α_a · α_b · Q.
|
||||
#[inline]
|
||||
pub fn effective_duty(q: f64, alpha_a: f64, alpha_b: f64) -> f64 {
|
||||
alpha_a * alpha_b * q
|
||||
}
|
||||
|
||||
/// Blend an active transport residual with a no-flow hold on Δh.
|
||||
///
|
||||
/// ```text
|
||||
/// r = α · r_active + (1 − α) · m_scale · (h_out − h_in)
|
||||
/// ```
|
||||
///
|
||||
/// When α→0 (zero flow), the residual forces `h_out ≈ h_in` (no transport)
|
||||
/// without a hard branch. Residual units stay [W] if `m_scale` is [kg/s].
|
||||
#[inline]
|
||||
pub fn blend_transport_residual(
|
||||
alpha: f64,
|
||||
r_active: f64,
|
||||
m_scale: f64,
|
||||
h_out: f64,
|
||||
h_in: f64,
|
||||
) -> f64 {
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
a * r_active + (1.0 - a) * m_scale * (h_out - h_in)
|
||||
}
|
||||
|
||||
/// Partials of [`blend_transport_residual`] for analytic Jacobians.
|
||||
///
|
||||
/// Returns `(∂r/∂α, ∂r/∂r_active, ∂r/∂h_out, ∂r/∂h_in)` treating `r_active` as
|
||||
/// independent of `h_*` (caller adds chain rule on `r_active`).
|
||||
#[inline]
|
||||
pub fn blend_transport_partials(
|
||||
alpha: f64,
|
||||
r_active: f64,
|
||||
m_scale: f64,
|
||||
h_out: f64,
|
||||
h_in: f64,
|
||||
) -> (f64, f64, f64, f64) {
|
||||
let a = alpha.clamp(0.0, 1.0);
|
||||
let dr_d_alpha = r_active - m_scale * (h_out - h_in);
|
||||
let dr_d_r_active = a;
|
||||
let dr_d_h_out = (1.0 - a) * m_scale;
|
||||
let dr_d_h_in = -(1.0 - a) * m_scale;
|
||||
(dr_d_alpha, dr_d_r_active, dr_d_h_out, dr_d_h_in)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn activity_zero_at_origin_and_near_one_at_large_flow() {
|
||||
let eps = DEFAULT_M_EPS_KG_S;
|
||||
assert!((flow_activity(0.0, eps)).abs() < 1e-15);
|
||||
assert!(flow_activity(1.0, eps) > 0.999);
|
||||
assert!(flow_activity(-1.0, eps) > 0.999);
|
||||
assert!(flow_activity(eps, eps) > 0.4 && flow_activity(eps, eps) < 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_derivative_matches_central_fd() {
|
||||
let eps = 1e-3;
|
||||
for m in [-1e-2, -1e-4, 0.0, 1e-4, 1e-2, 0.5] {
|
||||
let analytic = flow_activity_derivative(m, eps);
|
||||
let h = 1e-8_f64.max(m.abs() * 1e-6);
|
||||
let fd = (flow_activity(m + h, eps) - flow_activity(m - h, eps)) / (2.0 * h);
|
||||
assert!(
|
||||
(analytic - fd).abs() < 1e-5,
|
||||
"α' mismatch at m={m}: analytic={analytic} fd={fd}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_duty_vanishes_when_either_stream_inactive() {
|
||||
let q = 10_000.0;
|
||||
assert_eq!(effective_duty(q, 0.0, 1.0), 0.0);
|
||||
assert_eq!(effective_duty(q, 1.0, 0.0), 0.0);
|
||||
assert!((effective_duty(q, 1.0, 1.0) - q).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_hold_at_zero_activity_forces_dh_zero() {
|
||||
let r = blend_transport_residual(0.0, 999.0, 0.05, 400e3, 250e3);
|
||||
// (1-0)*0.05*(150e3) = 7500
|
||||
assert!((r - 0.05 * 150e3).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_at_full_activity_is_active_residual() {
|
||||
let r = blend_transport_residual(1.0, 123.0, 0.05, 400e3, 250e3);
|
||||
assert!((r - 123.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_mass_magnitude_positive_at_zero() {
|
||||
let eps = DEFAULT_M_EPS_KG_S;
|
||||
assert!(smooth_mass_magnitude(0.0, eps) > 0.0);
|
||||
assert!((smooth_mass_magnitude(0.0, eps) - eps).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_nan_across_zero_crossing() {
|
||||
let eps = DEFAULT_M_EPS_KG_S;
|
||||
for i in -20..=20 {
|
||||
let m = i as f64 * 1e-5;
|
||||
let a = flow_activity(m, eps);
|
||||
let q = effective_duty(5e3, a, a);
|
||||
let r = blend_transport_residual(a, m * 1e5 - q, DEFAULT_M_SCALE_KG_S, 3e5, 2e5);
|
||||
assert!(a.is_finite() && q.is_finite() && r.is_finite(), "m={m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
142
crates/components/src/heat_exchanger/gas_cooler.rs
Normal file
142
crates/components/src/heat_exchanger/gas_cooler.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! CO₂ / supercritical gas cooler HTC (Pettersen / Cheng–Thome simplified).
|
||||
//!
|
||||
//! Activated when `P > P_crit`. Uses a Gnielinski-like single-phase form with a
|
||||
//! specific-heat peak correction near the pseudo-critical temperature.
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationParams, FlowRegime};
|
||||
|
||||
/// Inputs for gas-cooler HTC evaluation.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GasCoolerInput {
|
||||
/// Pressure [Pa].
|
||||
pub pressure_pa: f64,
|
||||
/// Critical pressure [Pa].
|
||||
pub p_crit_pa: f64,
|
||||
/// Bulk temperature [K].
|
||||
pub t_bulk_k: f64,
|
||||
/// Pseudo-critical temperature at this pressure [K] (≈ 304–320 K for CO₂).
|
||||
pub t_pc_k: f64,
|
||||
/// Mass flux [kg/(m²·s)].
|
||||
pub mass_flux: f64,
|
||||
/// Hydraulic diameter [m].
|
||||
pub dh: f64,
|
||||
/// Density [kg/m³].
|
||||
pub rho: f64,
|
||||
/// Dynamic viscosity [Pa·s].
|
||||
pub mu: f64,
|
||||
/// Thermal conductivity [W/(m·K)].
|
||||
pub k: f64,
|
||||
/// Prandtl number [-].
|
||||
pub pr: f64,
|
||||
}
|
||||
|
||||
/// Returns true when the stream is supercritical (gas-cooler regime).
|
||||
pub fn is_supercritical(pressure_pa: f64, p_crit_pa: f64) -> bool {
|
||||
pressure_pa > p_crit_pa && p_crit_pa > 0.0
|
||||
}
|
||||
|
||||
/// Pettersen-style HTC with pseudo-critical enhancement [W/(m²·K)].
|
||||
///
|
||||
/// Base: Gnielinski single-phase. Near `T_pc`, multiply by
|
||||
/// `1 + 0.8 · exp(−((T−T_pc)/15)²)` to capture the c_p peak.
|
||||
pub fn pettersen_htc(input: &GasCoolerInput) -> f64 {
|
||||
let params = CorrelationParams {
|
||||
mass_flux: input.mass_flux,
|
||||
quality: 0.0,
|
||||
dh: input.dh,
|
||||
rho_l: input.rho,
|
||||
rho_v: input.rho,
|
||||
mu_l: input.mu,
|
||||
mu_v: input.mu,
|
||||
k_l: input.k,
|
||||
pr_l: input.pr,
|
||||
t_sat: input.t_bulk_k,
|
||||
t_wall: input.t_bulk_k + 5.0,
|
||||
regime: FlowRegime::SinglePhaseVapor,
|
||||
chevron_angle: 60.0,
|
||||
p_reduced: (input.pressure_pa / input.p_crit_pa.max(1.0)).min(0.99),
|
||||
};
|
||||
let base = BphxCorrelation::Gnielinski1976.compute_htc(¶ms).h;
|
||||
let d_t = (input.t_bulk_k - input.t_pc_k) / 15.0;
|
||||
let enhance = 1.0 + 0.8 * (-d_t * d_t).exp();
|
||||
(base * enhance).max(50.0)
|
||||
}
|
||||
|
||||
/// Gas cooler component wrapper: stores UA from Pettersen HTC × area.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GasCooler {
|
||||
/// Heat-transfer area [m²].
|
||||
area_m2: f64,
|
||||
/// Last HTC [W/(m²·K)].
|
||||
last_htc: f64,
|
||||
/// Last UA [W/K].
|
||||
last_ua: f64,
|
||||
}
|
||||
|
||||
impl GasCooler {
|
||||
/// Creates a gas cooler with given heat-transfer area.
|
||||
pub fn new(area_m2: f64) -> Self {
|
||||
Self {
|
||||
area_m2: area_m2.max(1e-6),
|
||||
last_htc: 0.0,
|
||||
last_ua: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates HTC/UA from operating point.
|
||||
pub fn update(&mut self, input: &GasCoolerInput) -> f64 {
|
||||
self.last_htc = pettersen_htc(input);
|
||||
self.last_ua = self.last_htc * self.area_m2;
|
||||
self.last_ua
|
||||
}
|
||||
|
||||
/// Last UA [W/K].
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.last_ua
|
||||
}
|
||||
|
||||
/// Last HTC [W/(m²·K)].
|
||||
pub fn htc(&self) -> f64 {
|
||||
self.last_htc
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn co2_input(t_k: f64) -> GasCoolerInput {
|
||||
GasCoolerInput {
|
||||
pressure_pa: 90e5,
|
||||
p_crit_pa: 73.77e5,
|
||||
t_bulk_k: t_k,
|
||||
t_pc_k: 310.0,
|
||||
mass_flux: 400.0,
|
||||
dh: 0.001,
|
||||
rho: 400.0,
|
||||
mu: 3e-5,
|
||||
k: 0.05,
|
||||
pr: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supercritical_detected() {
|
||||
assert!(is_supercritical(90e5, 73.77e5));
|
||||
assert!(!is_supercritical(50e5, 73.77e5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htc_peaks_near_tpc() {
|
||||
let h_peak = pettersen_htc(&co2_input(310.0));
|
||||
let h_far = pettersen_htc(&co2_input(340.0));
|
||||
assert!(h_peak > h_far);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_sets_ua() {
|
||||
let mut gc = GasCooler::new(2.0);
|
||||
let ua = gc.update(&co2_input(315.0));
|
||||
assert!(ua > 0.0 && (ua - gc.htc() * 2.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -203,11 +203,8 @@ impl HeatTransferModel for LmtdModel {
|
||||
)
|
||||
.to_watts();
|
||||
|
||||
let q_hot =
|
||||
hot_inlet.mass_flow * hot_inlet.cp * (hot_inlet.temperature - hot_outlet.temperature);
|
||||
let q_cold = cold_inlet.mass_flow
|
||||
* cold_inlet.cp
|
||||
* (cold_outlet.temperature - cold_inlet.temperature);
|
||||
let q_hot = hot_inlet.mass_flow * (hot_inlet.enthalpy - hot_outlet.enthalpy);
|
||||
let q_cold = cold_inlet.mass_flow * (cold_outlet.enthalpy - cold_inlet.enthalpy);
|
||||
|
||||
residuals[0] = q_hot - q;
|
||||
residuals[1] = q_cold - q;
|
||||
|
||||
@@ -310,6 +310,18 @@ impl Component for MchxCondenserCoil {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
/// CM1.4: Forward system context to the inner `Condenser` so that
|
||||
/// `same_branch_m` is detected and the redundant mass-conservation residual
|
||||
/// is dropped when inlet and outlet share the same ṁ branch index.
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.inner
|
||||
.set_system_context(state_offset, external_edge_state_indices);
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
@@ -451,17 +463,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_n_equations_is_two() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
// CM1.3: 2 thermo + 1 mass-flow = 3 (delegates to inner Condenser)
|
||||
assert_eq!(coil.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_ok() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = coil.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(residuals.iter().all(|r| r.is_finite()));
|
||||
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
//!
|
||||
//! ## BPHX Evaporator (Story 11.6)
|
||||
//!
|
||||
//! - [`BphxEvaporator`]: Plate evaporator supporting DX and Flooded modes
|
||||
//! - [`BphxEvaporatorMode`]: Operation mode (DX with superheat or Flooded with quality)
|
||||
//! - [`BphxEvaporator`]: Plate evaporator (DX only, superheat-controlled outlet)
|
||||
//!
|
||||
//! ## BPHX Condenser (Story 11.7)
|
||||
//!
|
||||
@@ -48,6 +47,7 @@
|
||||
//! // Heat exchanger would be created with connected ports
|
||||
//! ```
|
||||
|
||||
pub mod air_cooled_condenser;
|
||||
pub mod bphx_condenser;
|
||||
pub mod bphx_correlation;
|
||||
pub mod bphx_evaporator;
|
||||
@@ -55,35 +55,81 @@ pub mod bphx_exchanger;
|
||||
pub mod bphx_geometry;
|
||||
pub mod condenser;
|
||||
pub mod condenser_coil;
|
||||
pub mod correlation_registry;
|
||||
pub mod economizer;
|
||||
pub mod eps_ntu;
|
||||
pub mod evaporator;
|
||||
pub mod evaporator_coil;
|
||||
pub mod exchanger;
|
||||
pub mod fin_coil_condenser;
|
||||
pub mod fan_coil_unit;
|
||||
pub mod flooded_condenser;
|
||||
pub mod flooded_evaporator;
|
||||
pub mod flow_regularization;
|
||||
pub mod gas_cooler;
|
||||
pub mod lmtd;
|
||||
pub mod mchx_condenser_coil;
|
||||
pub mod model;
|
||||
pub mod moving_boundary_hx;
|
||||
pub mod pool_boiling;
|
||||
pub mod shell_and_tube;
|
||||
pub mod two_phase_dp;
|
||||
|
||||
pub use air_cooled_condenser::AirCooledCondenser;
|
||||
pub use bphx_condenser::BphxCondenser;
|
||||
pub use bphx_correlation::{
|
||||
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
|
||||
ValidityStatus,
|
||||
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
|
||||
CorrelationSelector, ValidityStatus,
|
||||
};
|
||||
pub use bphx_evaporator::{BphxEvaporator, BphxEvaporatorMode};
|
||||
pub use bphx_evaporator::BphxEvaporator;
|
||||
pub use bphx_exchanger::BphxExchanger;
|
||||
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
|
||||
pub use condenser::Condenser;
|
||||
pub use condenser::{Condenser, CondenserRating};
|
||||
pub use condenser_coil::CondenserCoil;
|
||||
pub use correlation_registry::{
|
||||
assess_candidate, correlation_metadata, registered_correlations, select_correlation,
|
||||
ApplicabilityDomain, BoundaryProximity, BoundedQuantity, BoundedQuantityKind,
|
||||
CandidateAssessment, CandidateRejection, CorrelationId, CorrelationMetadata,
|
||||
CorrelationPurpose, CorrelationSelectionError, DomainInputError, DomainStatus,
|
||||
EvidenceMetadata, ExchangerGeometryType, FlowRegime, LimitViolation, OperatingPoint,
|
||||
RefrigerantApplicability, RefrigerantEvidenceStatus, SelectionContext, SelectionOutcome,
|
||||
ValidationEvidence,
|
||||
};
|
||||
pub use economizer::Economizer;
|
||||
pub use eps_ntu::{EpsNtuModel, ExchangerType};
|
||||
pub use evaporator::Evaporator;
|
||||
pub use evaporator_coil::EvaporatorCoil;
|
||||
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
|
||||
pub use fin_coil_condenser::{CoilGeometry, FinCoilCondenser, FinType};
|
||||
pub use flooded_condenser::FloodedCondenser;
|
||||
pub use flooded_evaporator::FloodedEvaporator;
|
||||
pub use fan_coil_unit::{FanCoilRating, FanCoilRatingInput, FanCoilUnit};
|
||||
pub use flooded_evaporator::{
|
||||
EvaporatorRating, FloodedEvaporator, FloodedPoolBoilingConfig, UaMode,
|
||||
};
|
||||
pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput};
|
||||
pub use shell_and_tube::{
|
||||
bell_delaware_factors, default_tube_params, shell_and_tube_ua, shell_side_htc, tube_side_htc,
|
||||
BellDelawareFactors, BellDelawareGeometry, ShellAndTubeHx,
|
||||
};
|
||||
pub use flow_regularization::{
|
||||
blend_transport_partials, blend_transport_residual, effective_duty, flow_activity,
|
||||
flow_activity_derivative, smooth_mass_magnitude, smooth_mass_magnitude_derivative,
|
||||
DEFAULT_M_EPS_KG_S, DEFAULT_M_SCALE_KG_S,
|
||||
};
|
||||
pub use lmtd::{FlowConfiguration, LmtdModel};
|
||||
pub use mchx_condenser_coil::MchxCondenserCoil;
|
||||
pub use model::HeatTransferModel;
|
||||
pub use pool_boiling::{
|
||||
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
|
||||
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,
|
||||
};
|
||||
pub use two_phase_dp::{
|
||||
acceleration_drop, assess_friedel_domain, assess_msh_domain, calibrate_quadratic_k,
|
||||
default_refrigerant_pressure_drop_coeff, friedel_gradient, friedel_metadata,
|
||||
friedel_multiplier, friedel_pressure_drop, msh_gradient, msh_metadata, msh_pressure_drop,
|
||||
parse_dp_model_name, quadratic_drop, quadratic_drop_dm, tube_two_phase_delta_p,
|
||||
zivi_void_fraction, FriedelInput, SatTransportProps, TubeChannelGeometry,
|
||||
DEFAULT_N_PARALLEL_TUBES, DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
|
||||
DEFAULT_REFRIGERANT_M_NOMINAL_KG_S, DEFAULT_TUBE_DIAMETER_M, DEFAULT_TUBE_LENGTH_M,
|
||||
TwoPhaseDpCorrelation,
|
||||
};
|
||||
|
||||
@@ -169,7 +169,7 @@ pub trait HeatTransferModel: Send + Sync {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
|
||||
/// Sets the UA calibration scale (e.g. from Calib.z_ua).
|
||||
fn set_ua_scale(&mut self, _s: f64) {}
|
||||
|
||||
/// Returns the effective UA used in heat transfer. If dynamic_ua_scale is provided, it is used instead of ua_scale.
|
||||
|
||||
@@ -153,12 +153,14 @@ impl MovingBoundaryHX {
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
self.inner.set_hot_fluid(self.refrigerant_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the secondary fluid identifier.
|
||||
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.secondary_fluid_id = fluid.into();
|
||||
self.inner.set_cold_fluid(self.secondary_fluid_id.clone());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -180,29 +182,13 @@ impl Component for MovingBoundaryHX {
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let (p, m_refrig, t_sec_in, t_sec_out) = if let (Some(hot), Some(cold)) =
|
||||
(self.inner.hot_conditions(), self.inner.cold_conditions())
|
||||
{
|
||||
(
|
||||
hot.pressure_pa(),
|
||||
hot.mass_flow_kg_s(),
|
||||
cold.temperature_k(),
|
||||
cold.temperature_k() + 5.0,
|
||||
)
|
||||
} else {
|
||||
(500_000.0, 0.1, 300.0, 320.0)
|
||||
};
|
||||
|
||||
// Extract enthalpies exactly as HeatExchanger does:
|
||||
let enthalpies = self.port_enthalpies(state)?;
|
||||
let h_in = enthalpies
|
||||
.get(0)
|
||||
.map(|h| h.to_joules_per_kg())
|
||||
.unwrap_or(400_000.0);
|
||||
let h_out = enthalpies
|
||||
.get(1)
|
||||
.map(|h| h.to_joules_per_kg())
|
||||
.unwrap_or(200_000.0);
|
||||
let (ref_in, ref_out, sec_in, sec_out) = self.inner.live_fluid_states(state)?;
|
||||
let p = ref_in.pressure;
|
||||
let m_refrig = ref_in.mass_flow;
|
||||
let t_sec_in = sec_in.temperature;
|
||||
let t_sec_out = sec_out.temperature;
|
||||
let h_in = ref_in.enthalpy;
|
||||
let h_out = ref_out.enthalpy;
|
||||
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
let use_cache = cache.is_valid_for(p, m_refrig);
|
||||
@@ -242,6 +228,18 @@ impl Component for MovingBoundaryHX {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
self.inner.set_port_context(port_edges);
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
self.inner.port_names()
|
||||
}
|
||||
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
self.inner.flow_paths()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
@@ -258,7 +256,10 @@ impl Component for MovingBoundaryHX {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend.clone());
|
||||
self.inner.set_fluid_backend_from_builder(backend);
|
||||
@@ -673,7 +674,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_uses_cache() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use crate::Component;
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
|
||||
@@ -727,11 +728,30 @@ mod tests {
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
|
||||
let hx = MovingBoundaryHX::new()
|
||||
let mut hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_secondary_fluid("Air")
|
||||
.with_fluid_backend(backend.clone());
|
||||
hx.set_port_context(&[
|
||||
Some((0, 1, 2)),
|
||||
Some((0, 3, 4)),
|
||||
Some((5, 6, 7)),
|
||||
Some((5, 8, 9)),
|
||||
]);
|
||||
|
||||
let state = vec![500_000.0, 400_000.0];
|
||||
let h_air = |t_k: f64| 1006.0 * (t_k - 273.15);
|
||||
let state = vec![
|
||||
0.1,
|
||||
500_000.0,
|
||||
400_000.0,
|
||||
490_000.0,
|
||||
200_000.0,
|
||||
0.2,
|
||||
101_325.0,
|
||||
h_air(300.0),
|
||||
101_325.0,
|
||||
h_air(310.0),
|
||||
];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
// First call should calculate property (backend calls)
|
||||
@@ -739,15 +759,19 @@ mod tests {
|
||||
let calls_first = backend.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(calls_first > 0);
|
||||
|
||||
// Second call with same state should use cache -> 0 new backend calls
|
||||
// Second call with same state still evaluates live edge properties, but
|
||||
// should reuse cached zone identification.
|
||||
let _ = hx.compute_residuals(&state, &mut residuals);
|
||||
let calls_second = backend.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(calls_first, calls_second); // Calls remained the same because cache was used
|
||||
assert!(
|
||||
calls_second - calls_first < calls_first,
|
||||
"cache should avoid the zone-identification property calls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_speedup() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use crate::Component;
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
|
||||
|
||||
232
crates/components/src/heat_exchanger/pool_boiling.rs
Normal file
232
crates/components/src/heat_exchanger/pool_boiling.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Nucleate pool-boiling correlations for flooded evaporators.
|
||||
//!
|
||||
//! Implements Cooper (1984) and Mostinski (1963) for shell-side boiling on
|
||||
//! tubes, plus Palen bundle and Thome–Robinson oil attenuation factors.
|
||||
|
||||
use super::correlation_registry::{
|
||||
assess_candidate, correlation_metadata, CandidateAssessment, CorrelationId,
|
||||
CorrelationMetadata, CorrelationPurpose, DomainInputError, ExchangerGeometryType, FlowRegime,
|
||||
OperatingPoint, SelectionContext,
|
||||
};
|
||||
|
||||
/// Inputs for pool-boiling heat-transfer evaluation (SI).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PoolBoilingInput {
|
||||
/// Reduced pressure `p_sat / p_crit` [-].
|
||||
pub p_reduced: f64,
|
||||
/// Heat flux [W/m²].
|
||||
pub heat_flux: f64,
|
||||
/// Molar mass [g/mol] (Cooper uses g/mol in its published form).
|
||||
pub molar_mass_g_mol: f64,
|
||||
/// Surface roughness Ra [μm]. Default 1.0 μm if unknown.
|
||||
pub roughness_um: f64,
|
||||
/// Critical pressure [Pa] (Mostinski).
|
||||
pub p_crit_pa: f64,
|
||||
}
|
||||
|
||||
impl Default for PoolBoilingInput {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
p_reduced: 0.15,
|
||||
heat_flux: 15_000.0,
|
||||
molar_mass_g_mol: 102.0,
|
||||
roughness_um: 1.0,
|
||||
p_crit_pa: 4.059e6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PoolBoilingInput {
|
||||
fn validate(&self) -> Result<(), DomainInputError> {
|
||||
for (field, value) in [
|
||||
("p_reduced", self.p_reduced),
|
||||
("heat_flux", self.heat_flux),
|
||||
("molar_mass_g_mol", self.molar_mass_g_mol),
|
||||
("roughness_um", self.roughness_um),
|
||||
("p_crit_pa", self.p_crit_pa),
|
||||
] {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return Err(DomainInputError::InvalidPositive { field, value });
|
||||
}
|
||||
}
|
||||
if self.p_reduced >= 1.0 {
|
||||
return Err(DomainInputError::InvalidPositive {
|
||||
field: "p_reduced",
|
||||
value: self.p_reduced,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cooper (1984) nucleate pool-boiling HTC [W/(m²·K)].
|
||||
///
|
||||
/// `h = 55 · p_r^0.12 · (−log10 p_r)^(−0.55) · M^(−0.5) · q^0.67`
|
||||
/// with roughness correction `−0.4343·ln(Ra)` on the `p_r^0.12` exponent term
|
||||
/// as commonly implemented for industrial flooded evaporators.
|
||||
pub fn cooper_1984(input: &PoolBoilingInput) -> Result<f64, DomainInputError> {
|
||||
input.validate()?;
|
||||
let pr = input.p_reduced.clamp(1e-6, 0.99);
|
||||
let ra = input.roughness_um.max(0.01);
|
||||
let log_term = (-pr.log10()).max(1e-6);
|
||||
let h = 55.0
|
||||
* pr.powf(0.12 - 0.4343 * ra.ln())
|
||||
* log_term.powf(-0.55)
|
||||
* input.molar_mass_g_mol.powf(-0.5)
|
||||
* input.heat_flux.powf(0.67);
|
||||
Ok(h.max(0.0))
|
||||
}
|
||||
|
||||
/// Mostinski (1963) nucleate pool-boiling HTC [W/(m²·K)].
|
||||
///
|
||||
/// `h = 0.00417 · q^0.7 · p_crit^0.69 · F(p_r)` with
|
||||
/// `F = 1.8 p_r^0.17 + 4 p_r^1.2 + 10 p_r^10` and `p_crit` in kN/m² (kPa).
|
||||
pub fn mostinski_1963(input: &PoolBoilingInput) -> Result<f64, DomainInputError> {
|
||||
input.validate()?;
|
||||
let pr = input.p_reduced.clamp(1e-6, 0.99);
|
||||
let p_crit_kpa = input.p_crit_pa / 1.0e3;
|
||||
let f_pr = 1.8 * pr.powf(0.17) + 4.0 * pr.powf(1.2) + 10.0 * pr.powi(10);
|
||||
let h = 0.00417 * input.heat_flux.powf(0.7) * p_crit_kpa.powf(0.69) * f_pr;
|
||||
Ok(h.max(0.0))
|
||||
}
|
||||
|
||||
/// Palen bundle correction factor (typical flooded evaporator range 0.6–0.9).
|
||||
#[inline]
|
||||
pub fn palen_bundle_factor(f_b: f64) -> f64 {
|
||||
f_b.clamp(0.5, 1.2)
|
||||
}
|
||||
|
||||
/// Thome–Robinson (ASHRAE RP-1089) oil attenuation on pool-boiling HTC.
|
||||
///
|
||||
/// Linear blend from 1.0 at zero oil to ~0.25 at 10 % oil mass fraction
|
||||
/// (published Turbo-BII bundle data). Clamped for numerical safety.
|
||||
pub fn thome_robinson_oil_factor(oil_mass_fraction: f64) -> f64 {
|
||||
let w = oil_mass_fraction.clamp(0.0, 0.20);
|
||||
(1.0 - 7.5 * w).clamp(0.25, 1.0)
|
||||
}
|
||||
|
||||
/// Combined flooded-shell HTC: `h_nb · F_b · F_oil` [W/(m²·K)].
|
||||
pub fn flooded_shell_htc(
|
||||
input: &PoolBoilingInput,
|
||||
correlation: CorrelationId,
|
||||
bundle_factor: f64,
|
||||
oil_mass_fraction: f64,
|
||||
) -> Result<f64, DomainInputError> {
|
||||
let h_nb = match correlation {
|
||||
CorrelationId::Cooper1984 => cooper_1984(input)?,
|
||||
CorrelationId::Mostinski1963 => mostinski_1963(input)?,
|
||||
other => {
|
||||
return Err(DomainInputError::InvalidPositive {
|
||||
field: "pool_boiling_correlation",
|
||||
value: other as u8 as f64,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(h_nb * palen_bundle_factor(bundle_factor) * thome_robinson_oil_factor(oil_mass_fraction))
|
||||
}
|
||||
|
||||
/// UA from refrigerant-side HTC and secondary-side HTC [W/K].
|
||||
///
|
||||
/// `1/UA = 1/(h_ref·A_ref) + 1/(h_sec·A_sec)` (wall resistance neglected).
|
||||
pub fn ua_from_two_side_htc(
|
||||
h_ref: f64,
|
||||
area_ref_m2: f64,
|
||||
h_sec: f64,
|
||||
area_sec_m2: f64,
|
||||
) -> f64 {
|
||||
let r_ref = 1.0 / (h_ref.max(1.0) * area_ref_m2.max(1e-9));
|
||||
let r_sec = 1.0 / (h_sec.max(1.0) * area_sec_m2.max(1e-9));
|
||||
1.0 / (r_ref + r_sec)
|
||||
}
|
||||
|
||||
/// Returns Cooper registry metadata.
|
||||
pub fn cooper_metadata() -> CorrelationMetadata {
|
||||
correlation_metadata(CorrelationId::Cooper1984)
|
||||
}
|
||||
|
||||
/// Assesses Cooper applicability without evaluating the formula.
|
||||
pub fn assess_cooper_domain(
|
||||
geometry: ExchangerGeometryType,
|
||||
refrigerant: Option<&str>,
|
||||
) -> Result<CandidateAssessment, DomainInputError> {
|
||||
let context = SelectionContext {
|
||||
purpose: CorrelationPurpose::HeatTransfer,
|
||||
geometry,
|
||||
regime: FlowRegime::Evaporation,
|
||||
refrigerant: refrigerant.map(str::to_owned),
|
||||
operating_point: OperatingPoint {
|
||||
quality: Some(0.5),
|
||||
..OperatingPoint::default()
|
||||
},
|
||||
};
|
||||
assess_candidate(&cooper_metadata(), &context)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r134a_like() -> PoolBoilingInput {
|
||||
PoolBoilingInput {
|
||||
p_reduced: 0.12,
|
||||
heat_flux: 20_000.0,
|
||||
molar_mass_g_mol: 102.03,
|
||||
roughness_um: 1.0,
|
||||
p_crit_pa: 4.059e6,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooper_htc_plausible_magnitude() {
|
||||
let h = cooper_1984(&r134a_like()).unwrap();
|
||||
assert!(h.is_finite() && h > 500.0 && h < 50_000.0, "h={h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooper_increases_with_heat_flux() {
|
||||
let mut low = r134a_like();
|
||||
low.heat_flux = 10_000.0;
|
||||
let mut high = r134a_like();
|
||||
high.heat_flux = 30_000.0;
|
||||
assert!(cooper_1984(&high).unwrap() > cooper_1984(&low).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mostinski_positive() {
|
||||
let h = mostinski_1963(&r134a_like()).unwrap();
|
||||
assert!(h.is_finite() && h > 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oil_factor_degrades_htc() {
|
||||
assert!((thome_robinson_oil_factor(0.0) - 1.0).abs() < 1e-12);
|
||||
assert!(thome_robinson_oil_factor(0.10) < 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flooded_shell_applies_bundle_and_oil() {
|
||||
let base = cooper_1984(&r134a_like()).unwrap();
|
||||
let combined = flooded_shell_htc(
|
||||
&r134a_like(),
|
||||
CorrelationId::Cooper1984,
|
||||
0.8,
|
||||
0.05,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(combined < base * 0.8);
|
||||
assert!(combined > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ua_two_side_finite() {
|
||||
let ua = ua_from_two_side_htc(3000.0, 10.0, 5000.0, 8.0);
|
||||
assert!(ua.is_finite() && ua > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooper_rejects_invalid_p_reduced() {
|
||||
let mut inp = r134a_like();
|
||||
inp.p_reduced = 1.5;
|
||||
assert!(cooper_1984(&inp).is_err());
|
||||
}
|
||||
}
|
||||
190
crates/components/src/heat_exchanger/shell_and_tube.rs
Normal file
190
crates/components/src/heat_exchanger/shell_and_tube.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Shell-and-tube heat exchanger rating via Bell–Delaware shell-side factors.
|
||||
//!
|
||||
//! Computes shell-side HTC `h_s = h_ideal · J_C · J_L · J_B · J_R · J_S` and a
|
||||
//! combined UA with tube-side Gnielinski / Cooper / Shah as selected.
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationParams, FlowRegime};
|
||||
use super::correlation_registry::CorrelationId;
|
||||
use super::pool_boiling::{cooper_1984, PoolBoilingInput};
|
||||
|
||||
/// Geometric inputs for Bell–Delaware correction factors.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BellDelawareGeometry {
|
||||
/// Fraction of tubes in cross-flow (F_C ≈ 1 − 2 F_W).
|
||||
pub f_c: f64,
|
||||
/// Leakage area ratio r_s = S_sb / (S_sb + S_tb).
|
||||
pub r_s: f64,
|
||||
/// Leakage/main stream area ratio r_lm = (S_sb + S_tb) / S_m.
|
||||
pub r_lm: f64,
|
||||
/// Bypass correction J_B (typical 0.7–0.9).
|
||||
pub j_b: f64,
|
||||
/// Laminar gradient correction J_R (1.0 if Re_s ≥ 100).
|
||||
pub j_r: f64,
|
||||
/// Unequal baffle spacing correction J_S (1.0 if uniform).
|
||||
pub j_s: f64,
|
||||
/// Ideal cross-flow HTC [W/(m²·K)] before corrections.
|
||||
pub h_ideal: f64,
|
||||
/// Shell-side area [m²].
|
||||
pub area_shell_m2: f64,
|
||||
/// Tube-side area [m²].
|
||||
pub area_tube_m2: f64,
|
||||
}
|
||||
|
||||
impl Default for BellDelawareGeometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
f_c: 0.9,
|
||||
r_s: 0.4,
|
||||
r_lm: 0.3,
|
||||
j_b: 0.8,
|
||||
j_r: 1.0,
|
||||
j_s: 1.0,
|
||||
h_ideal: 2500.0,
|
||||
area_shell_m2: 30.0,
|
||||
area_tube_m2: 25.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bell–Delaware correction factors.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BellDelawareFactors {
|
||||
/// Configuration factor J_C.
|
||||
pub j_c: f64,
|
||||
/// Leakage factor J_L.
|
||||
pub j_l: f64,
|
||||
/// Bypass factor J_B.
|
||||
pub j_b: f64,
|
||||
/// Laminar factor J_R.
|
||||
pub j_r: f64,
|
||||
/// Spacing factor J_S.
|
||||
pub j_s: f64,
|
||||
}
|
||||
|
||||
/// Computes J-factors from geometry (Taborek / Delaware handbook forms).
|
||||
pub fn bell_delaware_factors(geom: &BellDelawareGeometry) -> BellDelawareFactors {
|
||||
let j_c = 0.55 + 0.72 * geom.f_c.clamp(0.0, 1.0);
|
||||
let j_l = 0.44 * (1.0 - geom.r_s)
|
||||
+ (1.0 - 0.44 * (1.0 - geom.r_s)) * (-2.2 * geom.r_lm).exp();
|
||||
BellDelawareFactors {
|
||||
j_c: j_c.clamp(0.65, 1.15),
|
||||
j_l: j_l.clamp(0.2, 1.0),
|
||||
j_b: geom.j_b.clamp(0.7, 0.9),
|
||||
j_r: geom.j_r.clamp(0.7, 1.0),
|
||||
j_s: geom.j_s.clamp(0.8, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell-side HTC after Bell–Delaware corrections [W/(m²·K)].
|
||||
pub fn shell_side_htc(geom: &BellDelawareGeometry) -> f64 {
|
||||
let f = bell_delaware_factors(geom);
|
||||
geom.h_ideal * f.j_c * f.j_l * f.j_b * f.j_r * f.j_s
|
||||
}
|
||||
|
||||
/// Tube-side HTC using a registered correlation [W/(m²·K)].
|
||||
pub fn tube_side_htc(
|
||||
correlation: CorrelationId,
|
||||
params: &CorrelationParams,
|
||||
pool: Option<&PoolBoilingInput>,
|
||||
) -> f64 {
|
||||
match correlation {
|
||||
CorrelationId::Cooper1984 => pool
|
||||
.and_then(|p| cooper_1984(p).ok())
|
||||
.unwrap_or(3000.0),
|
||||
CorrelationId::Gnielinski1976 => BphxCorrelation::Gnielinski1976.compute_htc(params).h,
|
||||
CorrelationId::Shah2009 => BphxCorrelation::Shah2009.compute_htc(params).h,
|
||||
CorrelationId::Shah1979 => BphxCorrelation::Shah1979.compute_htc(params).h,
|
||||
CorrelationId::Cavallini2006 => BphxCorrelation::Cavallini2006.compute_htc(params).h,
|
||||
CorrelationId::Kandlikar1990 => BphxCorrelation::Kandlikar1990.compute_htc(params).h,
|
||||
_ => BphxCorrelation::Gnielinski1976.compute_htc(params).h,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined UA [W/K] from shell and tube sides (wall resistance neglected).
|
||||
pub fn shell_and_tube_ua(
|
||||
geom: &BellDelawareGeometry,
|
||||
h_tube: f64,
|
||||
) -> f64 {
|
||||
let h_shell = shell_side_htc(geom);
|
||||
let r = 1.0 / (h_shell.max(1.0) * geom.area_shell_m2.max(1e-9))
|
||||
+ 1.0 / (h_tube.max(1.0) * geom.area_tube_m2.max(1e-9));
|
||||
1.0 / r
|
||||
}
|
||||
|
||||
/// Rating helper wrapping Bell–Delaware + tube correlation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellAndTubeHx {
|
||||
geom: BellDelawareGeometry,
|
||||
tube_correlation: CorrelationId,
|
||||
last_ua: f64,
|
||||
}
|
||||
|
||||
impl ShellAndTubeHx {
|
||||
/// Creates a shell-and-tube rater with default geometry.
|
||||
pub fn new(geom: BellDelawareGeometry) -> Self {
|
||||
Self {
|
||||
geom,
|
||||
tube_correlation: CorrelationId::Gnielinski1976,
|
||||
last_ua: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects tube-side correlation.
|
||||
pub fn with_tube_correlation(mut self, id: CorrelationId) -> Self {
|
||||
self.tube_correlation = id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Rates UA from operating tube-side params.
|
||||
pub fn rate_ua(&mut self, params: &CorrelationParams) -> f64 {
|
||||
let h_tube = tube_side_htc(self.tube_correlation, params, None);
|
||||
self.last_ua = shell_and_tube_ua(&self.geom, h_tube);
|
||||
self.last_ua
|
||||
}
|
||||
|
||||
/// Last computed UA [W/K].
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.last_ua
|
||||
}
|
||||
|
||||
/// Geometry accessor.
|
||||
pub fn geometry(&self) -> &BellDelawareGeometry {
|
||||
&self.geom
|
||||
}
|
||||
}
|
||||
|
||||
/// Default condensation CorrelationParams for tube-side rating.
|
||||
pub fn default_tube_params(regime: FlowRegime) -> CorrelationParams {
|
||||
CorrelationParams {
|
||||
regime,
|
||||
mass_flux: 200.0,
|
||||
..CorrelationParams::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn factors_in_expected_bands() {
|
||||
let f = bell_delaware_factors(&BellDelawareGeometry::default());
|
||||
assert!(f.j_c >= 0.65 && f.j_c <= 1.15);
|
||||
assert!(f.j_l >= 0.2 && f.j_l <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_htc_less_than_ideal() {
|
||||
let g = BellDelawareGeometry::default();
|
||||
let h = shell_side_htc(&g);
|
||||
assert!(h < g.h_ideal);
|
||||
assert!(h > 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_ua_positive() {
|
||||
let mut hx = ShellAndTubeHx::new(BellDelawareGeometry::default());
|
||||
let ua = hx.rate_ua(&default_tube_params(FlowRegime::SinglePhaseLiquid));
|
||||
assert!(ua.is_finite() && ua > 1000.0);
|
||||
}
|
||||
}
|
||||
718
crates/components/src/heat_exchanger/two_phase_dp.rs
Normal file
718
crates/components/src/heat_exchanger/two_phase_dp.rs
Normal file
@@ -0,0 +1,718 @@
|
||||
//! Two-phase frictional pressure-drop correlations.
|
||||
//!
|
||||
//! Provides Friedel (1979) and Müller-Steinhagen-Heck (1986) two-phase friction
|
||||
//! models — the latter is the pragmatic default in NIST EVAP-COND — together
|
||||
//! with the Zivi void fraction and a lumped quadratic model for system-level
|
||||
//! solvers that do not carry detailed tube geometry.
|
||||
//!
|
||||
//! All functions are analytic and side-effect free so they can be embedded in a
|
||||
//! Newton residual/Jacobian without breaking the zero-panic policy.
|
||||
|
||||
use super::correlation_registry::{
|
||||
assess_candidate, correlation_metadata, CandidateAssessment, CorrelationId,
|
||||
CorrelationMetadata, CorrelationPurpose, DomainInputError, ExchangerGeometryType, FlowRegime,
|
||||
OperatingPoint, SelectionContext,
|
||||
};
|
||||
|
||||
/// Standard gravitational acceleration [m/s²].
|
||||
const G_ACCEL: f64 = 9.80665;
|
||||
|
||||
/// Inputs describing the local two-phase state and channel for a Friedel
|
||||
/// pressure-gradient evaluation. All quantities are SI.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FriedelInput {
|
||||
/// Mass flux G = ṁ / A_cross [kg/(m²·s)].
|
||||
pub mass_flux: f64,
|
||||
/// Hydraulic diameter [m].
|
||||
pub diameter: f64,
|
||||
/// Vapor quality x ∈ [0, 1] [-].
|
||||
pub quality: f64,
|
||||
/// Saturated-liquid density [kg/m³].
|
||||
pub rho_liquid: f64,
|
||||
/// Saturated-vapor density [kg/m³].
|
||||
pub rho_vapor: f64,
|
||||
/// Liquid dynamic viscosity [Pa·s].
|
||||
pub mu_liquid: f64,
|
||||
/// Vapor dynamic viscosity [Pa·s].
|
||||
pub mu_vapor: f64,
|
||||
/// Surface tension [N/m].
|
||||
pub sigma: f64,
|
||||
}
|
||||
|
||||
/// Returns the registered Friedel applicability metadata.
|
||||
pub fn friedel_metadata() -> CorrelationMetadata {
|
||||
correlation_metadata(CorrelationId::Friedel1979)
|
||||
}
|
||||
|
||||
/// Assesses Friedel applicability without evaluating the analytic formula.
|
||||
pub fn assess_friedel_domain(
|
||||
input: &FriedelInput,
|
||||
geometry: ExchangerGeometryType,
|
||||
regime: FlowRegime,
|
||||
refrigerant: Option<&str>,
|
||||
) -> Result<CandidateAssessment, DomainInputError> {
|
||||
for (field, value) in [
|
||||
("mass_flux", input.mass_flux),
|
||||
("hydraulic_diameter", input.diameter),
|
||||
("liquid_density", input.rho_liquid),
|
||||
("vapor_density", input.rho_vapor),
|
||||
("liquid_viscosity", input.mu_liquid),
|
||||
("vapor_viscosity", input.mu_vapor),
|
||||
("surface_tension", input.sigma),
|
||||
] {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return Err(DomainInputError::InvalidPositive { field, value });
|
||||
}
|
||||
}
|
||||
if !input.quality.is_finite() || !(0.0..=1.0).contains(&input.quality) {
|
||||
return Err(DomainInputError::InvalidQuality {
|
||||
value: input.quality,
|
||||
});
|
||||
}
|
||||
let context = SelectionContext {
|
||||
purpose: CorrelationPurpose::PressureDrop,
|
||||
geometry,
|
||||
regime,
|
||||
refrigerant: refrigerant.map(str::to_owned),
|
||||
operating_point: OperatingPoint {
|
||||
reynolds: Some(input.mass_flux * input.diameter / input.mu_liquid),
|
||||
mass_flux: Some(input.mass_flux),
|
||||
quality: Some(input.quality),
|
||||
..OperatingPoint::default()
|
||||
},
|
||||
};
|
||||
assess_candidate(&friedel_metadata(), &context)
|
||||
}
|
||||
|
||||
/// Fanning friction factor for single-phase flow (Blasius/laminar blend).
|
||||
///
|
||||
/// Uses `16/Re` in the laminar regime (Re < 1187, the Blasius crossover) and
|
||||
/// the Blasius smooth-tube correlation `0.079·Re^-0.25` in the turbulent
|
||||
/// regime. Guards against non-positive Reynolds numbers.
|
||||
#[inline]
|
||||
pub fn fanning_friction_factor(reynolds: f64) -> f64 {
|
||||
if reynolds <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if reynolds < 1187.0 {
|
||||
16.0 / reynolds
|
||||
} else {
|
||||
0.079 * reynolds.powf(-0.25)
|
||||
}
|
||||
}
|
||||
|
||||
/// Homogeneous two-phase density 1/(x/ρ_g + (1-x)/ρ_l) [kg/m³].
|
||||
#[inline]
|
||||
pub fn homogeneous_density(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
|
||||
let x = quality.clamp(0.0, 1.0);
|
||||
let inv = x / rho_vapor.max(1e-9) + (1.0 - x) / rho_liquid.max(1e-9);
|
||||
if inv <= 0.0 {
|
||||
rho_liquid
|
||||
} else {
|
||||
1.0 / inv
|
||||
}
|
||||
}
|
||||
|
||||
/// Zivi (1964) void fraction based on minimum-entropy-production slip.
|
||||
///
|
||||
/// `α = 1 / (1 + ((1-x)/x)·(ρ_g/ρ_l)^(2/3))`, clamped to [0, 1]. Returns 0 for
|
||||
/// x ≤ 0 and 1 for x ≥ 1.
|
||||
#[inline]
|
||||
pub fn zivi_void_fraction(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
|
||||
let x = quality;
|
||||
if x <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let ratio = (rho_vapor.max(1e-9) / rho_liquid.max(1e-9)).powf(2.0 / 3.0);
|
||||
let denom = 1.0 + ((1.0 - x) / x) * ratio;
|
||||
(1.0 / denom).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Liquid-only frictional pressure gradient `(dP/dz)_LO` [Pa/m].
|
||||
///
|
||||
/// The gradient the whole mixture mass flux would produce if flowing as
|
||||
/// saturated liquid: `2·f_LO·G²/(D·ρ_l)` (Fanning convention).
|
||||
#[inline]
|
||||
fn liquid_only_gradient(g: f64, d: f64, rho_l: f64, mu_l: f64) -> f64 {
|
||||
let re_lo = g * d / mu_l.max(1e-12);
|
||||
let f_lo = fanning_friction_factor(re_lo);
|
||||
2.0 * f_lo * g * g / (d.max(1e-9) * rho_l.max(1e-9))
|
||||
}
|
||||
|
||||
/// Friedel (1979) two-phase friction multiplier `φ_LO²` [-].
|
||||
///
|
||||
/// Multiplies the liquid-only gradient to obtain the two-phase frictional
|
||||
/// gradient. Returns 1.0 at x = 0 (single-phase liquid) and is always ≥ 0.
|
||||
pub fn friedel_multiplier(input: &FriedelInput) -> f64 {
|
||||
let x = input.quality.clamp(0.0, 1.0);
|
||||
if x <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let g = input.mass_flux.abs();
|
||||
let d = input.diameter.max(1e-9);
|
||||
let rho_l = input.rho_liquid.max(1e-9);
|
||||
let rho_g = input.rho_vapor.max(1e-9);
|
||||
let mu_l = input.mu_liquid.max(1e-12);
|
||||
let mu_g = input.mu_vapor.max(1e-12);
|
||||
|
||||
let re_lo = g * d / mu_l;
|
||||
let re_go = g * d / mu_g;
|
||||
let f_lo = fanning_friction_factor(re_lo);
|
||||
let f_go = fanning_friction_factor(re_go);
|
||||
|
||||
// E = (1-x)² + x²·(ρ_l·f_go)/(ρ_g·f_lo)
|
||||
let e = (1.0 - x).powi(2) + x * x * (rho_l * f_go) / (rho_g * f_lo.max(1e-12));
|
||||
// F = x^0.78·(1-x)^0.224
|
||||
let f = x.powf(0.78) * (1.0 - x).powf(0.224);
|
||||
// H = (ρ_l/ρ_g)^0.91·(μ_g/μ_l)^0.19·(1-μ_g/μ_l)^0.7
|
||||
let mu_ratio = mu_g / mu_l;
|
||||
let h = (rho_l / rho_g).powf(0.91) * mu_ratio.powf(0.19) * (1.0 - mu_ratio).max(0.0).powf(0.7);
|
||||
|
||||
// Homogeneous Froude and Weber numbers.
|
||||
let rho_h = homogeneous_density(x, rho_l, rho_g);
|
||||
let fr = g * g / (G_ACCEL * d * rho_h * rho_h);
|
||||
let we = g * g * d / (rho_h * input.sigma.max(1e-9));
|
||||
|
||||
e + 3.24 * f * h / (fr.powf(0.045) * we.powf(0.035))
|
||||
}
|
||||
|
||||
/// Two-phase frictional pressure gradient `(dP/dz)` [Pa/m] via Friedel.
|
||||
///
|
||||
/// `φ_LO² · (dP/dz)_LO`. Always ≥ 0 (a magnitude); the caller applies the sign
|
||||
/// according to flow direction (pressure decreases downstream).
|
||||
pub fn friedel_gradient(input: &FriedelInput) -> f64 {
|
||||
let g = input.mass_flux.abs();
|
||||
let dpdz_lo = liquid_only_gradient(g, input.diameter, input.rho_liquid, input.mu_liquid);
|
||||
friedel_multiplier(input) * dpdz_lo
|
||||
}
|
||||
|
||||
/// Total Friedel frictional pressure drop `ΔP` [Pa] over a channel `length`.
|
||||
///
|
||||
/// Evaluated at a representative (e.g. mean) quality. Returns a positive
|
||||
/// magnitude.
|
||||
pub fn friedel_pressure_drop(input: &FriedelInput, length: f64) -> f64 {
|
||||
friedel_gradient(input) * length.max(0.0)
|
||||
}
|
||||
|
||||
/// Selectable two-phase frictional ΔP correlation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TwoPhaseDpCorrelation {
|
||||
/// Friedel (1979) — general-purpose, surface-tension dependent.
|
||||
#[default]
|
||||
Friedel1979,
|
||||
/// Müller-Steinhagen-Heck (1986) — NIST EVAP-COND default, no pattern map.
|
||||
MullerSteinhagenHeck1986,
|
||||
}
|
||||
|
||||
impl TwoPhaseDpCorrelation {
|
||||
/// Stable registry identifier.
|
||||
pub const fn id(self) -> CorrelationId {
|
||||
match self {
|
||||
Self::Friedel1979 => CorrelationId::Friedel1979,
|
||||
Self::MullerSteinhagenHeck1986 => CorrelationId::MullerSteinhagenHeck1986,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frictional pressure gradient [Pa/m] (positive magnitude).
|
||||
pub fn gradient(self, input: &FriedelInput) -> f64 {
|
||||
match self {
|
||||
Self::Friedel1979 => friedel_gradient(input),
|
||||
Self::MullerSteinhagenHeck1986 => msh_gradient(input),
|
||||
}
|
||||
}
|
||||
|
||||
/// Frictional pressure drop [Pa] over `length` (positive magnitude).
|
||||
pub fn pressure_drop(self, input: &FriedelInput, length: f64) -> f64 {
|
||||
self.gradient(input) * length.max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Liquid-only and vapor-only frictional gradients for MSH [Pa/m].
|
||||
#[inline]
|
||||
fn vapor_only_gradient(g: f64, d: f64, rho_g: f64, mu_g: f64) -> f64 {
|
||||
let re_go = g * d / mu_g.max(1e-12);
|
||||
let f_go = fanning_friction_factor(re_go);
|
||||
2.0 * f_go * g * g / (d.max(1e-9) * rho_g.max(1e-9))
|
||||
}
|
||||
|
||||
/// Müller-Steinhagen-Heck (1986) two-phase frictional gradient [Pa/m].
|
||||
///
|
||||
/// Linear blend between liquid-only and vapor-only gradients with a cubic
|
||||
/// quality correction:
|
||||
/// `(dP/dz) = A + 2(B−A)x` at low x, then smooth to vapor-only at x→1 via
|
||||
/// `(dP/dz) = (A + 2(B−A)x)·(1−x)^(1/3) + B·x³` where A=(dP/dz)_LO, B=(dP/dz)_GO.
|
||||
pub fn msh_gradient(input: &FriedelInput) -> f64 {
|
||||
let x = input.quality.clamp(0.0, 1.0);
|
||||
let g = input.mass_flux.abs();
|
||||
let a = liquid_only_gradient(g, input.diameter, input.rho_liquid, input.mu_liquid);
|
||||
let b = vapor_only_gradient(g, input.diameter, input.rho_vapor, input.mu_vapor);
|
||||
if x <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return b;
|
||||
}
|
||||
let linear = a + 2.0 * (b - a) * x;
|
||||
linear * (1.0 - x).powf(1.0 / 3.0) + b * x.powi(3)
|
||||
}
|
||||
|
||||
/// MSH frictional pressure drop [Pa] over `length`.
|
||||
pub fn msh_pressure_drop(input: &FriedelInput, length: f64) -> f64 {
|
||||
msh_gradient(input) * length.max(0.0)
|
||||
}
|
||||
|
||||
/// Returns MSH registry metadata.
|
||||
pub fn msh_metadata() -> CorrelationMetadata {
|
||||
correlation_metadata(CorrelationId::MullerSteinhagenHeck1986)
|
||||
}
|
||||
|
||||
/// Assesses MSH applicability without evaluating the formula.
|
||||
pub fn assess_msh_domain(
|
||||
input: &FriedelInput,
|
||||
geometry: ExchangerGeometryType,
|
||||
regime: FlowRegime,
|
||||
refrigerant: Option<&str>,
|
||||
) -> Result<CandidateAssessment, DomainInputError> {
|
||||
for (field, value) in [
|
||||
("mass_flux", input.mass_flux),
|
||||
("hydraulic_diameter", input.diameter),
|
||||
("liquid_density", input.rho_liquid),
|
||||
("vapor_density", input.rho_vapor),
|
||||
("liquid_viscosity", input.mu_liquid),
|
||||
("vapor_viscosity", input.mu_vapor),
|
||||
] {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return Err(DomainInputError::InvalidPositive { field, value });
|
||||
}
|
||||
}
|
||||
if !input.quality.is_finite() || !(0.0..=1.0).contains(&input.quality) {
|
||||
return Err(DomainInputError::InvalidQuality {
|
||||
value: input.quality,
|
||||
});
|
||||
}
|
||||
let context = SelectionContext {
|
||||
purpose: CorrelationPurpose::PressureDrop,
|
||||
geometry,
|
||||
regime,
|
||||
refrigerant: refrigerant.map(str::to_owned),
|
||||
operating_point: OperatingPoint {
|
||||
reynolds: Some(input.mass_flux * input.diameter / input.mu_liquid),
|
||||
mass_flux: Some(input.mass_flux),
|
||||
quality: Some(input.quality),
|
||||
..OperatingPoint::default()
|
||||
},
|
||||
};
|
||||
assess_candidate(&msh_metadata(), &context)
|
||||
}
|
||||
|
||||
/// Lumped quadratic pressure drop `ΔP = k · ṁ·|ṁ|` [Pa].
|
||||
///
|
||||
/// The standard system-level representation when detailed geometry is
|
||||
/// unavailable: `k` [Pa·s²/kg²] is a single coefficient calibrated from one
|
||||
/// rated (ṁ, ΔP) point. Signed by `ṁ` so it is antisymmetric and its
|
||||
/// derivative `∂ΔP/∂ṁ = 2·k·|ṁ|` is continuous through ṁ = 0.
|
||||
#[inline]
|
||||
pub fn quadratic_drop(k: f64, mass_flow: f64) -> f64 {
|
||||
k * mass_flow * mass_flow.abs()
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`quadratic_drop`] w.r.t. mass flow: `2·k·|ṁ|`.
|
||||
#[inline]
|
||||
pub fn quadratic_drop_dm(k: f64, mass_flow: f64) -> f64 {
|
||||
2.0 * k * mass_flow.abs()
|
||||
}
|
||||
|
||||
/// Calibrates the lumped coefficient `k` from a rated point (ṁ, ΔP).
|
||||
///
|
||||
/// `k = ΔP_rated / ṁ_rated²`. Returns 0 for a non-positive rated flow.
|
||||
#[inline]
|
||||
pub fn calibrate_quadratic_k(rated_dp: f64, rated_mass_flow: f64) -> f64 {
|
||||
if rated_mass_flow.abs() < 1e-12 {
|
||||
0.0
|
||||
} else {
|
||||
rated_dp / (rated_mass_flow * rated_mass_flow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference design pressure drop [Pa] for *quadratic* rating calibration
|
||||
/// (Modelica Buildings `dp_nominal` style). Not applied silently: use
|
||||
/// [`calibrate_quadratic_k`] or tube correlations ([`tube_two_phase_delta_p`]).
|
||||
pub const DEFAULT_REFRIGERANT_DP_NOMINAL_PA: f64 = 15_000.0;
|
||||
|
||||
/// Nominal refrigerant mass flow [kg/s] paired with
|
||||
/// [`DEFAULT_REFRIGERANT_DP_NOMINAL_PA`] for quadratic-`k` calibration.
|
||||
pub const DEFAULT_REFRIGERANT_M_NOMINAL_KG_S: f64 = 0.05;
|
||||
|
||||
/// Default tube length [m] when `dp_model=msh|friedel` omits geometry.
|
||||
pub const DEFAULT_TUBE_LENGTH_M: f64 = 6.0;
|
||||
|
||||
/// Default hydraulic diameter [m] (~3/8″ DX tube).
|
||||
pub const DEFAULT_TUBE_DIAMETER_M: f64 = 0.0095;
|
||||
|
||||
/// Default number of parallel refrigerant channels (keeps G in a DX-like band
|
||||
/// for small chillers ~0.05 kg/s).
|
||||
pub const DEFAULT_N_PARALLEL_TUBES: f64 = 2.0;
|
||||
|
||||
/// Lumped `k` [Pa·s²/kg²] from the reference design point
|
||||
/// (`15 kPa` @ `0.05 kg/s` → `6×10⁶`). Prefer tube MSH/Friedel when geometry
|
||||
/// is known.
|
||||
#[inline]
|
||||
pub fn default_refrigerant_pressure_drop_coeff() -> f64 {
|
||||
calibrate_quadratic_k(
|
||||
DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
|
||||
DEFAULT_REFRIGERANT_M_NOMINAL_KG_S,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal tube-bundle geometry for DX frictional ΔP.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TubeChannelGeometry {
|
||||
/// Refrigerant path length [m].
|
||||
pub length_m: f64,
|
||||
/// Hydraulic diameter [m].
|
||||
pub diameter_m: f64,
|
||||
/// Number of parallel tubes / channels [-] (≥ 1).
|
||||
pub n_parallel: f64,
|
||||
}
|
||||
|
||||
impl TubeChannelGeometry {
|
||||
/// DX defaults: 6 m × 9.5 mm × 2 parallel.
|
||||
pub fn dx_default() -> Self {
|
||||
Self {
|
||||
length_m: DEFAULT_TUBE_LENGTH_M,
|
||||
diameter_m: DEFAULT_TUBE_DIAMETER_M,
|
||||
n_parallel: DEFAULT_N_PARALLEL_TUBES,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total cross-sectional flow area [m²].
|
||||
#[inline]
|
||||
pub fn flow_area_m2(self) -> f64 {
|
||||
let d = self.diameter_m.max(1e-9);
|
||||
self.n_parallel.max(1.0) * std::f64::consts::PI * d * d / 4.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Saturated-phase transport properties at the local pressure.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SatTransportProps {
|
||||
/// Saturated-liquid density [kg/m³].
|
||||
pub rho_liquid: f64,
|
||||
/// Saturated-vapor density [kg/m³].
|
||||
pub rho_vapor: f64,
|
||||
/// Saturated-liquid dynamic viscosity [Pa·s].
|
||||
pub mu_liquid: f64,
|
||||
/// Saturated-vapor dynamic viscosity [Pa·s].
|
||||
pub mu_vapor: f64,
|
||||
/// Surface tension [N/m] (Friedel); unused by MSH but kept for a shared input.
|
||||
pub sigma: f64,
|
||||
}
|
||||
|
||||
/// Acceleration pressure change [Pa]: `G² (v(x_out) − v(x_in))` with homogeneous
|
||||
/// specific volume `v = x/ρ_v + (1−x)/ρ_l`. Positive when quality rises (evaporator).
|
||||
#[inline]
|
||||
pub fn acceleration_drop(
|
||||
mass_flux: f64,
|
||||
x_in: f64,
|
||||
x_out: f64,
|
||||
rho_liquid: f64,
|
||||
rho_vapor: f64,
|
||||
) -> f64 {
|
||||
let g = mass_flux;
|
||||
let v = |x: f64| {
|
||||
let xc = x.clamp(0.0, 1.0);
|
||||
xc / rho_vapor.max(1e-9) + (1.0 - xc) / rho_liquid.max(1e-9)
|
||||
};
|
||||
g * g * (v(x_out) - v(x_in))
|
||||
}
|
||||
|
||||
/// Tube DX pressure drop [Pa] in the flow direction:
|
||||
/// `ΔP = ΔP_friction(x̄) + ΔP_acceleration`.
|
||||
///
|
||||
/// Friction uses MSH (NIST EVAP-COND default) or Friedel at the mean quality
|
||||
/// `x̄ = ½(clamp(x_in)+clamp(x_out))` over [`TubeChannelGeometry::length_m`].
|
||||
/// Signed so `P_out = P_in − ΔP` for `ṁ ≥ 0`.
|
||||
pub fn tube_two_phase_delta_p(
|
||||
correlation: TwoPhaseDpCorrelation,
|
||||
geom: &TubeChannelGeometry,
|
||||
mass_flow: f64,
|
||||
x_in: f64,
|
||||
x_out: f64,
|
||||
props: &SatTransportProps,
|
||||
) -> f64 {
|
||||
let area = geom.flow_area_m2().max(1e-12);
|
||||
let g = mass_flow.abs() / area;
|
||||
let x_mean = 0.5 * (x_in.clamp(0.0, 1.0) + x_out.clamp(0.0, 1.0));
|
||||
let input = FriedelInput {
|
||||
mass_flux: g,
|
||||
diameter: geom.diameter_m.max(1e-9),
|
||||
quality: x_mean,
|
||||
rho_liquid: props.rho_liquid,
|
||||
rho_vapor: props.rho_vapor,
|
||||
mu_liquid: props.mu_liquid,
|
||||
mu_vapor: props.mu_vapor,
|
||||
sigma: props.sigma.max(1e-9),
|
||||
};
|
||||
let dp_fric = correlation.pressure_drop(&input, geom.length_m.max(0.0));
|
||||
let dp_acc = acceleration_drop(g, x_in, x_out, props.rho_liquid, props.rho_vapor);
|
||||
let drop = dp_fric + dp_acc;
|
||||
if mass_flow >= 0.0 {
|
||||
drop
|
||||
} else {
|
||||
-drop
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `dp_model` string: `none`/`isobaric`, `quadratic`, `msh`, `friedel`.
|
||||
pub fn parse_dp_model_name(name: &str) -> Option<&'static str> {
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
"none" | "isobaric" | "zero" => Some("isobaric"),
|
||||
"quadratic" | "rated" | "lumped" | "dp_nominal" => Some("quadratic"),
|
||||
"msh" | "muller" | "müller" | "muller-steinhagen-heck" | "muller_steinhagen_heck" => {
|
||||
Some("msh")
|
||||
}
|
||||
"friedel" => Some("friedel"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r134a_like() -> FriedelInput {
|
||||
// Representative R134a evaporating near 5 °C.
|
||||
FriedelInput {
|
||||
mass_flux: 200.0,
|
||||
diameter: 0.008,
|
||||
quality: 0.5,
|
||||
rho_liquid: 1260.0,
|
||||
rho_vapor: 17.0,
|
||||
mu_liquid: 250e-6,
|
||||
mu_vapor: 11e-6,
|
||||
sigma: 0.011,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiplier_is_one_at_zero_quality() {
|
||||
let mut inp = r134a_like();
|
||||
inp.quality = 0.0;
|
||||
assert!((friedel_multiplier(&inp) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiplier_exceeds_one_in_two_phase() {
|
||||
// Two-phase acceleration of the vapor makes φ_LO² > 1.
|
||||
let inp = r134a_like();
|
||||
assert!(friedel_multiplier(&inp) > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_increases_with_mass_flux() {
|
||||
let low = FriedelInput {
|
||||
mass_flux: 100.0,
|
||||
..r134a_like()
|
||||
};
|
||||
let high = FriedelInput {
|
||||
mass_flux: 400.0,
|
||||
..r134a_like()
|
||||
};
|
||||
assert!(friedel_gradient(&high) > friedel_gradient(&low));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_is_finite_and_positive_across_quality() {
|
||||
for q in [0.05, 0.2, 0.4, 0.6, 0.8, 0.95] {
|
||||
let inp = FriedelInput {
|
||||
quality: q,
|
||||
..r134a_like()
|
||||
};
|
||||
let g = friedel_gradient(&inp);
|
||||
assert!(g.is_finite() && g > 0.0, "q={q} gradient={g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friedel_pressure_drop_reference_magnitude() {
|
||||
// Sanity band: a 2 m, 8 mm R134a tube at G=200, x=0.5 gives a two-phase
|
||||
// drop of order a few kPa (physically plausible for this duty).
|
||||
let dp = friedel_pressure_drop(&r134a_like(), 2.0);
|
||||
assert!(
|
||||
dp > 500.0 && dp < 50_000.0,
|
||||
"dp={dp} Pa out of expected band"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zivi_void_fraction_bounds_and_monotonic() {
|
||||
assert_eq!(zivi_void_fraction(0.0, 1260.0, 17.0), 0.0);
|
||||
assert_eq!(zivi_void_fraction(1.0, 1260.0, 17.0), 1.0);
|
||||
let a_low = zivi_void_fraction(0.1, 1260.0, 17.0);
|
||||
let a_high = zivi_void_fraction(0.9, 1260.0, 17.0);
|
||||
assert!(a_low > 0.0 && a_low < 1.0);
|
||||
assert!(a_high > a_low);
|
||||
// Even at low quality the void fraction is high (vapor occupies most area).
|
||||
assert!(a_low > 0.5, "Zivi void fraction unexpectedly low: {a_low}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadratic_drop_and_derivative() {
|
||||
let k_default = default_refrigerant_pressure_drop_coeff();
|
||||
assert!((k_default - 6.0e6).abs() < 1.0, "15 kPa @ 0.05 kg/s → k=6e6");
|
||||
|
||||
let props = SatTransportProps {
|
||||
rho_liquid: 1260.0,
|
||||
rho_vapor: 17.0,
|
||||
mu_liquid: 250e-6,
|
||||
mu_vapor: 11e-6,
|
||||
sigma: 0.011,
|
||||
};
|
||||
let geom = TubeChannelGeometry::dx_default();
|
||||
let dp_evap = tube_two_phase_delta_p(
|
||||
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
|
||||
&geom,
|
||||
0.05,
|
||||
0.2,
|
||||
0.95,
|
||||
&props,
|
||||
);
|
||||
assert!(dp_evap > 1000.0, "DX evaporating ΔP should be kPa-scale, got {dp_evap}");
|
||||
let dp_cond = tube_two_phase_delta_p(
|
||||
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
|
||||
&geom,
|
||||
0.05,
|
||||
0.95,
|
||||
0.05,
|
||||
&props,
|
||||
);
|
||||
// Condensation: acceleration recovers some pressure → ΔP_cond < ΔP_evap typically.
|
||||
assert!(dp_cond > 0.0 && dp_cond < dp_evap);
|
||||
|
||||
let k = calibrate_quadratic_k(20_000.0, 0.1); // 20 kPa at 0.1 kg/s
|
||||
assert!((quadratic_drop(k, 0.1) - 20_000.0).abs() < 1e-6);
|
||||
// Antisymmetric.
|
||||
assert!((quadratic_drop(k, -0.1) + 20_000.0).abs() < 1e-6);
|
||||
// Derivative matches central finite difference.
|
||||
let m = 0.07;
|
||||
let d_fd = (quadratic_drop(k, m + 1e-6) - quadratic_drop(k, m - 1e-6)) / 2e-6;
|
||||
assert!((quadratic_drop_dm(k, m) - d_fd).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrate_handles_zero_flow() {
|
||||
assert_eq!(calibrate_quadratic_k(1000.0, 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friedel_domain_assessment_is_separate_from_formula() {
|
||||
let assessment = assess_friedel_domain(
|
||||
&r134a_like(),
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
FlowRegime::Evaporation,
|
||||
Some("R134a"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(assessment.accepted);
|
||||
assert_eq!(assessment.id, CorrelationId::Friedel1979);
|
||||
assert_eq!(
|
||||
assessment.domain_status,
|
||||
Some(super::super::DomainStatus::InDomain)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friedel_rejects_wrong_geometry_structurally() {
|
||||
let assessment = assess_friedel_domain(
|
||||
&r134a_like(),
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
FlowRegime::Evaporation,
|
||||
Some("R134a"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!assessment.accepted);
|
||||
assert!(matches!(
|
||||
assessment.rejections.as_slice(),
|
||||
[super::super::CandidateRejection::WrongGeometry { .. }]
|
||||
));
|
||||
assert!(assessment.domain_status.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friedel_assessment_rejects_invalid_physical_input() {
|
||||
let mut input = r134a_like();
|
||||
input.sigma = 0.0;
|
||||
let error = assess_friedel_domain(
|
||||
&input,
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
FlowRegime::Evaporation,
|
||||
Some("R134a"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
DomainInputError::InvalidPositive {
|
||||
field: "surface_tension",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msh_matches_liquid_only_at_x0() {
|
||||
let mut inp = r134a_like();
|
||||
inp.quality = 0.0;
|
||||
let a = liquid_only_gradient(
|
||||
inp.mass_flux,
|
||||
inp.diameter,
|
||||
inp.rho_liquid,
|
||||
inp.mu_liquid,
|
||||
);
|
||||
assert!((msh_gradient(&inp) - a).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msh_matches_vapor_only_at_x1() {
|
||||
let mut inp = r134a_like();
|
||||
inp.quality = 1.0;
|
||||
let b = vapor_only_gradient(inp.mass_flux, inp.diameter, inp.rho_vapor, inp.mu_vapor);
|
||||
assert!((msh_gradient(&inp) - b).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msh_positive_across_quality() {
|
||||
for q in [0.05, 0.3, 0.5, 0.7, 0.95] {
|
||||
let inp = FriedelInput {
|
||||
quality: q,
|
||||
..r134a_like()
|
||||
};
|
||||
let g = msh_gradient(&inp);
|
||||
assert!(g.is_finite() && g > 0.0, "q={q} g={g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_phase_dp_enum_dispatches() {
|
||||
let inp = r134a_like();
|
||||
let friedel = TwoPhaseDpCorrelation::Friedel1979.gradient(&inp);
|
||||
let msh = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986.gradient(&inp);
|
||||
assert!(friedel.is_finite() && msh.is_finite());
|
||||
assert!(friedel > 0.0 && msh > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msh_domain_accepted_for_smooth_tube() {
|
||||
let assessment = assess_msh_domain(
|
||||
&r134a_like(),
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
FlowRegime::Condensation,
|
||||
Some("R134a"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(assessment.accepted);
|
||||
assert_eq!(assessment.id, CorrelationId::MullerSteinhagenHeck1986);
|
||||
}
|
||||
}
|
||||
393
crates/components/src/heat_source.rs
Normal file
393
crates/components/src/heat_source.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
//! HeatSource — inline heat injection (BOLT `BoundaryNode.Heat.Source` equivalent).
|
||||
//!
|
||||
//! A `HeatSource` is a 2-port inline component that injects a heat rate Q [W]
|
||||
//! into the stream flowing through it (Q < 0 extracts heat):
|
||||
//!
|
||||
//! ```text
|
||||
//! r0: P_out − P_in = 0 (no hydraulic loss)
|
||||
//! r1: ṁ·(h_out − h_in) − Q_total = 0 (energy balance)
|
||||
//! Q_total = q_fixed + Q_ext
|
||||
//! ```
|
||||
//!
|
||||
//! Two heat sources compose:
|
||||
//!
|
||||
//! * **Fixed mode** (`q_fixed`, BOLT `Q_flow_fixed=true`): a constant duty,
|
||||
//! e.g. a known parasitic load or an electric heater.
|
||||
//! * **Linked mode** (`Q_ext`, BOLT `use_Q_flow_in=true` with a
|
||||
//! `RealExpression` such as `Q = Comp_A.summary.Q_motorCooling`): the solver
|
||||
//! wires a per-coupling unknown via
|
||||
//! [`Component::set_external_heat_index`] whose value is closed against
|
||||
//! another component's measured output through a `thermal_couplings` entry
|
||||
//! (`hot_component` = duty provider, `cold_component` = this heat source).
|
||||
//!
|
||||
//! The Jacobian is exact (the bilinear term contributes
|
||||
//! `∂r1/∂ṁ = h_out − h_in`, `∂r1/∂h = ±ṁ`, `∂r1/∂Q_ext = −1`).
|
||||
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput, ResidualVector,
|
||||
StateSlice,
|
||||
};
|
||||
|
||||
/// Inline heat injection with fixed and/or externally-linked duty (see module docs).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeatSource {
|
||||
name: String,
|
||||
/// Fixed heat rate [W] (positive = into the stream).
|
||||
q_fixed_w: f64,
|
||||
/// State index of the external heat unknown Q [W] (wired by the solver).
|
||||
q_idx: Option<usize>,
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
circuit_id: CircuitId,
|
||||
operational_state: OperationalState,
|
||||
}
|
||||
|
||||
impl HeatSource {
|
||||
/// Creates a heat source with a fixed duty `q_fixed_w` [W]
|
||||
/// (0.0 for a purely coupling-driven source).
|
||||
pub fn new(name: impl Into<String>, q_fixed_w: f64) -> Result<Self, ComponentError> {
|
||||
if !q_fixed_w.is_finite() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"HeatSource: q_fixed_w must be finite".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.into(),
|
||||
q_fixed_w,
|
||||
q_idx: None,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Returns the fixed duty [W].
|
||||
pub fn q_fixed_w(&self) -> f64 {
|
||||
self.q_fixed_w
|
||||
}
|
||||
|
||||
/// Returns the wired external-heat state index, if any.
|
||||
pub fn external_heat_index(&self) -> Option<usize> {
|
||||
self.q_idx
|
||||
}
|
||||
|
||||
/// Total injected heat [W]: fixed + externally-linked.
|
||||
fn q_total(&self, state: &StateSlice) -> f64 {
|
||||
let q_ext = match self.q_idx {
|
||||
Some(i) if i < state.len() && state[i].is_finite() => state[i],
|
||||
_ => 0.0,
|
||||
};
|
||||
self.q_fixed_w + q_ext
|
||||
}
|
||||
|
||||
fn wired(&self) -> Option<(usize, usize, usize, usize, usize)> {
|
||||
Some((
|
||||
self.inlet_m_idx?,
|
||||
self.inlet_p_idx?,
|
||||
self.inlet_h_idx?,
|
||||
self.outlet_p_idx?,
|
||||
self.outlet_h_idx?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for HeatSource {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// [0] = incoming edge (inlet), [1] = outgoing edge (outlet).
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
let (m, p, h) = external_edge_state_indices[0];
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
let (_, p, h) = external_edge_state_indices[1];
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_external_heat_index(&mut self, idx: usize) {
|
||||
self.q_idx = Some(idx);
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let Some((m_idx, in_p, in_h, out_p, out_h)) = self.wired() else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"HeatSource requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
};
|
||||
residuals[0] = state[out_p] - state[in_p];
|
||||
match self.operational_state {
|
||||
// Off / Bypass: adiabatic pass-through.
|
||||
OperationalState::Off | OperationalState::Bypass => {
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
}
|
||||
OperationalState::On => {
|
||||
let m_dot = state[m_idx];
|
||||
residuals[1] = m_dot * (state[out_h] - state[in_h]) - self.q_total(state);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let Some((m_idx, in_p, in_h, out_p, out_h)) = self.wired() else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"HeatSource Jacobian requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
};
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
match self.operational_state {
|
||||
OperationalState::Off | OperationalState::Bypass => {
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
}
|
||||
OperationalState::On => {
|
||||
let m_dot = state[m_idx];
|
||||
let dh = state[out_h] - state[in_h];
|
||||
if dh.abs() > 1e-14 {
|
||||
jacobian.add_entry(1, m_idx, dh);
|
||||
}
|
||||
jacobian.add_entry(1, out_h, m_dot);
|
||||
jacobian.add_entry(1, in_h, -m_dot);
|
||||
if let Some(q_idx) = self.q_idx {
|
||||
jacobian.add_entry(1, q_idx, -1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
let Some(m_idx) = self.inlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"HeatSource mass-flow reporting requires a live inlet mass-flow index".to_string(),
|
||||
));
|
||||
};
|
||||
let m = state[m_idx];
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(m),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m),
|
||||
])
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"HeatSource: not wired to system context".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(vec![
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[in_h]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[out_h]),
|
||||
])
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
let q = match self.operational_state {
|
||||
OperationalState::On => self.q_total(state),
|
||||
_ => 0.0,
|
||||
};
|
||||
Some((
|
||||
entropyk_core::Power::from_watts(q),
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn counts_in_cycle_performance(&self) -> bool {
|
||||
// Parasitic/auxiliary heat (e.g. motor cooling) must not inflate the
|
||||
// aggregated cooling capacity; it still enters First Law validation.
|
||||
false
|
||||
}
|
||||
|
||||
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
match kind {
|
||||
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
|
||||
Some(self.q_total(state).abs())
|
||||
}
|
||||
MeasuredOutput::MassFlowRate => {
|
||||
let m_idx = self.inlet_m_idx?;
|
||||
(m_idx < state.len()).then(|| state[m_idx])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"HeatSource(name={}, q_fixed={:.1} W, circuit={})",
|
||||
self.name, self.q_fixed_w, self.circuit_id.0
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("HeatSource")
|
||||
.with_param("name", self.name.as_str())
|
||||
.with_param("qFixedW", self.q_fixed_w)
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for HeatSource {
|
||||
fn state(&self) -> OperationalState {
|
||||
self.operational_state
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
if self.operational_state.can_transition_to(state) {
|
||||
self.operational_state = state;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ComponentError::InvalidStateTransition {
|
||||
from: self.operational_state,
|
||||
to: state,
|
||||
reason: "Transition not allowed".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||
self.operational_state.can_transition_to(target)
|
||||
}
|
||||
|
||||
fn circuit_id(&self) -> &CircuitId {
|
||||
&self.circuit_id
|
||||
}
|
||||
|
||||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||
self.circuit_id = circuit_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Inlet edge (m=0, p=1, h=2), outlet edge (p=3, h=4), external Q at 5.
|
||||
fn wired(q_fixed: f64, external: bool) -> HeatSource {
|
||||
let mut hs = HeatSource::new("hs", q_fixed).unwrap();
|
||||
hs.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
if external {
|
||||
hs.set_external_heat_index(5);
|
||||
}
|
||||
hs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_nonfinite_q() {
|
||||
assert!(HeatSource::new("x", f64::NAN).is_err());
|
||||
assert!(HeatSource::new("x", f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_duty_energy_balance() {
|
||||
let hs = wired(5_000.0, false);
|
||||
// ṁ = 0.5, Δh = 10 kJ/kg ⇒ ṁ·Δh = 5 kW = q_fixed.
|
||||
let state = vec![0.5, 1.0e6, 400_000.0, 1.0e6, 410_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
hs.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[0].abs() < 1e-12 && r[1].abs() < 1e-9, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixed_plus_external_compose() {
|
||||
let hs = wired(5_000.0, true);
|
||||
// Q_total = 5 kW + 3 kW ⇒ Δh = 16 kJ/kg at ṁ = 0.5.
|
||||
let state = vec![0.5, 1.0e6, 400_000.0, 1.0e6, 416_000.0, 3_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
hs.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[1].abs() < 1e-9, "r1 = {}", r[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_q_extracts_heat() {
|
||||
let hs = wired(-2_000.0, false);
|
||||
// Δh = −4 kJ/kg at ṁ = 0.5.
|
||||
let state = vec![0.5, 1.0e6, 400_000.0, 1.0e6, 396_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
hs.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[1].abs() < 1e-9, "r1 = {}", r[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_matches_fd() {
|
||||
let hs = wired(5_000.0, true);
|
||||
let state = vec![0.45, 1.0e6, 401_000.0, 0.98e6, 413_000.0, 2_500.0];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
hs.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
let eps = 1e-3;
|
||||
for col in 0..6usize {
|
||||
let mut sp = state.clone();
|
||||
let mut sm = state.clone();
|
||||
sp[col] += eps;
|
||||
sm[col] -= eps;
|
||||
let (mut rp, mut rm) = (vec![0.0; 2], vec![0.0; 2]);
|
||||
hs.compute_residuals(&sp, &mut rp).unwrap();
|
||||
hs.compute_residuals(&sm, &mut rm).unwrap();
|
||||
for row in 0..2 {
|
||||
let fd = (rp[row] - rm[row]) / (2.0 * eps);
|
||||
let analytic: f64 = jac
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|(r, c, _)| *r == row && *c == col)
|
||||
.map(|(_, _, v)| *v)
|
||||
.sum();
|
||||
assert!(
|
||||
(fd - analytic).abs() < 1e-6 * (1.0 + fd.abs()),
|
||||
"row {row} col {col}: fd {fd} vs analytic {analytic}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
663
crates/components/src/isenthalpic_expansion_valve.rs
Normal file
663
crates/components/src/isenthalpic_expansion_valve.rs
Normal file
@@ -0,0 +1,663 @@
|
||||
//! IsenthalpicExpansionValve Component
|
||||
//!
|
||||
//! A thermostatic/electronic expansion valve model for vapor-compression cycles.
|
||||
//! Implements the isenthalpic throttling process: the refrigerant expands from
|
||||
//! condensing pressure to evaporating pressure at constant enthalpy.
|
||||
//!
|
||||
//! ## Model
|
||||
//!
|
||||
//! Given:
|
||||
//! - Inlet state (P_in, H_in) from the high-pressure side (condenser outlet)
|
||||
//! - Target evaporating temperature `t_evap_k` for P_evap_sat computation
|
||||
//!
|
||||
//! Residuals (2 equations, constrain the EXV outlet edge):
|
||||
//!
|
||||
//! - r0 = P_out_state - P_evap_sat(T_evap) [drive outlet P to evaporating pressure]
|
||||
//! - r1 = H_out_state - H_in_state [isenthalpic throttling: H_out = H_in]
|
||||
//!
|
||||
//! ## Jacobian
|
||||
//!
|
||||
//! - ∂r0/∂P_out = 1
|
||||
//! - ∂r1/∂H_out = 1
|
||||
//! - ∂r1/∂H_in = -1
|
||||
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{CalibIndices, Enthalpy, Pressure};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Isenthalpic expansion valve (EXV / thermostatic valve).
|
||||
///
|
||||
/// Constrains the outlet edge state to:
|
||||
/// - Evaporating saturation pressure
|
||||
/// - Same enthalpy as the inlet (isenthalpic throttling)
|
||||
#[derive(Clone)]
|
||||
pub struct IsenthalpicExpansionValve {
|
||||
/// Target evaporating temperature [K] — used to compute P_evap_sat
|
||||
t_evap_k: f64,
|
||||
/// Refrigerant identifier (e.g. "R410A")
|
||||
refrigerant_id: String,
|
||||
/// CoolProp (or other) fluid property backend
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
|
||||
// State indices (set by set_system_context)
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Inlet (high-side) pressure state index — needed for the orifice ΔP.
|
||||
inlet_p_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Mass-flow state index of the inlet edge (ṁ unknown, CM1.3).
|
||||
inlet_m_idx: Option<usize>,
|
||||
/// Mass-flow state index of the outlet edge (ṁ unknown, CM1.3).
|
||||
outlet_m_idx: Option<usize>,
|
||||
/// True when inlet and outlet share the same ṁ state index (CM1.4).
|
||||
same_branch_m: bool,
|
||||
/// When `true`, the evaporating pressure is NOT imposed here. The valve only
|
||||
/// enforces isenthalpic throttling; the low-side pressure is set by the
|
||||
/// downstream evaporator outlet closure (emergent-pressure mode).
|
||||
emergent_pressure: bool,
|
||||
/// Physical orifice flow coefficient `Kv` [m²] (effective max flow area).
|
||||
/// When set (orifice mode, emergent-only) the valve emits an extra
|
||||
/// mass-flow residual `ṁ − Kv·opening·√(2·ρ_in·max(ΔP,0))` and the fractional
|
||||
/// `opening` becomes a free-actuator solver unknown (arch-6).
|
||||
orifice_kv: Option<f64>,
|
||||
/// Solver-provided calibration/actuator indices. The generic `actuator`
|
||||
/// slot carries the orifice opening state index in orifice mode.
|
||||
calib_indices: CalibIndices,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IsenthalpicExpansionValve {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("IsenthalpicExpansionValve")
|
||||
.field("t_evap_k", &self.t_evap_k)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("inlet_h_idx", &self.inlet_h_idx)
|
||||
.field("outlet_p_idx", &self.outlet_p_idx)
|
||||
.field("outlet_h_idx", &self.outlet_h_idx)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl IsenthalpicExpansionValve {
|
||||
/// Create a new EXV for the given evaporating temperature.
|
||||
pub fn new(t_evap_k: f64) -> Self {
|
||||
Self {
|
||||
t_evap_k,
|
||||
refrigerant_id: String::new(),
|
||||
fluid_backend: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
inlet_m_idx: None,
|
||||
outlet_m_idx: None,
|
||||
same_branch_m: false,
|
||||
emergent_pressure: false,
|
||||
inlet_p_idx: None,
|
||||
orifice_kv: None,
|
||||
calib_indices: CalibIndices::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the refrigerant identifier (e.g. "R410A").
|
||||
pub fn with_refrigerant(mut self, id: &str) -> Self {
|
||||
self.refrigerant_id = id.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables emergent-pressure mode: the valve stops pinning the outlet to
|
||||
/// `P_sat(t_evap_k)` and only enforces isenthalpic throttling (`H_out = H_in`).
|
||||
/// The evaporating pressure then emerges from the downstream evaporator
|
||||
/// outlet closure. Reduces the equation count by one (the dropped P-fix).
|
||||
pub fn with_emergent_pressure(mut self) -> Self {
|
||||
self.emergent_pressure = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a fluid property backend.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables the physical orifice-flow model (arch-6 physical actuator).
|
||||
///
|
||||
/// `kv` is the effective maximum orifice flow area `Kv` [m²] such that the
|
||||
/// mass flow obeys `ṁ = Kv·opening·√(2·ρ_in·max(P_in − P_out, 0))`, where
|
||||
/// `opening ∈ [0, 1]` is a free-actuator solver unknown. The orifice model is
|
||||
/// **emergent-only** (it would over-constrain the fixed-pressure path), so this
|
||||
/// builder also enables emergent-pressure mode. The valve gains one equation
|
||||
/// (the orifice residual); the system must register a matching free-actuator
|
||||
/// bounded variable so the extra `opening` unknown keeps the DoF balanced.
|
||||
pub fn with_orifice(mut self, kv: f64) -> Self {
|
||||
self.orifice_kv = Some(kv);
|
||||
self.emergent_pressure = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the configured orifice flow coefficient `Kv` [m²], if any.
|
||||
pub fn orifice_kv(&self) -> Option<f64> {
|
||||
self.orifice_kv
|
||||
}
|
||||
|
||||
/// True when the physical orifice residual is configured (Kv set + emergent).
|
||||
/// The extra equation is counted whenever this is true so the DoF matches the
|
||||
/// registered free-actuator unknown, even before indices resolve.
|
||||
fn orifice_configured(&self) -> bool {
|
||||
self.emergent_pressure && self.orifice_kv.is_some()
|
||||
}
|
||||
|
||||
/// True when the orifice residual can actually be evaluated (all indices,
|
||||
/// backend, refrigerant and the actuator opening index are wired).
|
||||
fn orifice_ready(&self) -> bool {
|
||||
self.orifice_configured()
|
||||
&& self.fluid_backend.is_some()
|
||||
&& !self.refrigerant_id.is_empty()
|
||||
&& self.inlet_p_idx.is_some()
|
||||
&& self.outlet_p_idx.is_some()
|
||||
&& self.inlet_h_idx.is_some()
|
||||
&& self.calib_indices.actuator.is_some()
|
||||
&& (self.outlet_m_idx.is_some() || self.inlet_m_idx.is_some())
|
||||
}
|
||||
|
||||
/// Inlet-density [kg/m³] from (P_in, h_in) via the fluid backend.
|
||||
fn inlet_density(&self, p_in_pa: f64, h_in_jkg: f64) -> Result<f64, ComponentError> {
|
||||
let backend = self
|
||||
.fluid_backend
|
||||
.as_ref()
|
||||
.ok_or_else(|| ComponentError::CalculationFailed("no fluid backend".into()))?;
|
||||
backend
|
||||
.property(
|
||||
FluidId::new(&self.refrigerant_id),
|
||||
Property::Density,
|
||||
FluidState::PressureEnthalpy(
|
||||
Pressure::from_pascals(p_in_pa),
|
||||
Enthalpy::from_joules_per_kg(h_in_jkg),
|
||||
),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("rho_in: {e}")))
|
||||
}
|
||||
|
||||
/// Evaluates the orifice mass-flow closure `f = Kv·opening·√(2·ρ_in·max(ΔP,0))`
|
||||
/// [kg/s] and returns `(f, rho_in, sqrt_term, delta_p)` for reuse by the
|
||||
/// Jacobian. Returns `None` when the flux is degenerate (ΔP ≤ 0 or ρ ≤ 0).
|
||||
fn orifice_flow(&self, state: &StateSlice) -> Option<(f64, f64, f64, f64)> {
|
||||
let (kv, p_in_idx, out_p, in_h, open_idx) = (
|
||||
self.orifice_kv?,
|
||||
self.inlet_p_idx?,
|
||||
self.outlet_p_idx?,
|
||||
self.inlet_h_idx?,
|
||||
self.calib_indices.actuator?,
|
||||
);
|
||||
let p_in = state[p_in_idx];
|
||||
let p_out = state[out_p];
|
||||
let delta_p = p_in - p_out;
|
||||
if delta_p <= 0.0 || p_in <= 0.0 {
|
||||
return Some((0.0, 0.0, 0.0, delta_p));
|
||||
}
|
||||
let rho = self.inlet_density(p_in, state[in_h]).ok()?;
|
||||
if rho <= 0.0 {
|
||||
return Some((0.0, rho, 0.0, delta_p));
|
||||
}
|
||||
let opening = state[open_idx].clamp(0.0, 1.0);
|
||||
let sqrt_term = (2.0 * rho * delta_p).sqrt();
|
||||
Some((kv * opening * sqrt_term, rho, sqrt_term, delta_p))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for IsenthalpicExpansionValve {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge (cond→exv), [1] = outgoing edge (exv→evap)
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_m_idx = Some(external_edge_state_indices[1].0);
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
// CM1.4: detect same-branch topology.
|
||||
self.same_branch_m = matches!(
|
||||
(self.inlet_m_idx, self.outlet_m_idx),
|
||||
(Some(m_in), Some(m_out)) if m_in == m_out
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Emergent-pressure mode: only isenthalpic throttling; the low-side
|
||||
// pressure is set by the downstream evaporator outlet closure.
|
||||
if self.emergent_pressure {
|
||||
if let (Some(inlet_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) {
|
||||
// r0: isenthalpic throttling — outlet enthalpy equals inlet enthalpy.
|
||||
residuals[0] = state[out_h] - state[inlet_h];
|
||||
// r1: mass conservation (CM1.3), dropped when same_branch_m (CM1.4).
|
||||
let mut next = 1;
|
||||
if !self.same_branch_m {
|
||||
let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Expansion valve mass conservation requires live inlet/outlet mass-flow indices".to_string(),
|
||||
));
|
||||
};
|
||||
residuals[next] = state[m_out] - state[m_in];
|
||||
next += 1;
|
||||
}
|
||||
// Physical orifice closure (arch-6): ṁ = Kv·opening·√(2·ρ_in·ΔP).
|
||||
if self.orifice_configured() {
|
||||
residuals[next] = if self.orifice_ready() {
|
||||
let Some(m_idx) = self.outlet_m_idx.or(self.inlet_m_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Expansion valve orifice closure requires a live mass-flow index"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let Some((flow, _, _, _)) = self.orifice_flow(state) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Expansion valve orifice closure could not evaluate from live state"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
state[m_idx] - flow
|
||||
} else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Expansion valve orifice closure requires pressure, enthalpy, and opening context"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(backend), Some(inlet_h), Some(out_p), Some(out_h)) = (
|
||||
&self.fluid_backend,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if !self.refrigerant_id.is_empty() {
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
|
||||
let p_evap_sat = backend
|
||||
.saturation_pressure_t(fluid, self.t_evap_k)
|
||||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
|
||||
|
||||
// r0: drive outlet pressure to evaporating saturation pressure
|
||||
residuals[0] = state[out_p] - p_evap_sat;
|
||||
// r1: isenthalpic throttling — outlet enthalpy equals inlet enthalpy
|
||||
residuals[1] = state[out_h] - state[inlet_h];
|
||||
// r2: mass conservation (CM1.3)
|
||||
// CM1.4: skip when same_branch_m — trivially zero.
|
||||
if !self.same_branch_m {
|
||||
let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Expansion valve mass conservation requires live inlet/outlet mass-flow indices".to_string(),
|
||||
));
|
||||
};
|
||||
residuals[2] = state[m_out] - state[m_in];
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ComponentError::InvalidState(
|
||||
"Expansion valve requires live state indices and fluid backend context; refusing zero-residual fallback".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Emergent-pressure mode: only the isenthalpic residual (+ mass conservation).
|
||||
if self.emergent_pressure {
|
||||
if let (Some(inlet_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) {
|
||||
jacobian.add_entry(0, out_h, 1.0);
|
||||
jacobian.add_entry(0, inlet_h, -1.0);
|
||||
let mut next = 1;
|
||||
if !self.same_branch_m {
|
||||
if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
|
||||
jacobian.add_entry(1, m_out, 1.0);
|
||||
jacobian.add_entry(1, m_in, -1.0);
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
// Orifice residual r = ṁ − Kv·opening·√(2·ρ_in(P_in,h_in)·ΔP).
|
||||
// Exact analytic partials; ρ derivatives via central finite diff
|
||||
// (consistent with the dT/dP FD used elsewhere).
|
||||
if self.orifice_configured() && self.orifice_ready() {
|
||||
if let (Some(kv), Some(p_in_idx), Some(out_p), Some(in_h), Some(open_idx)) = (
|
||||
self.orifice_kv,
|
||||
self.inlet_p_idx,
|
||||
self.outlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.calib_indices.actuator,
|
||||
) {
|
||||
let m_idx = self.outlet_m_idx.or(self.inlet_m_idx);
|
||||
if let (Some(mi), Some((_flow, rho, sqrt_term, delta_p))) =
|
||||
(m_idx, self.orifice_flow(_state))
|
||||
{
|
||||
// ∂r/∂ṁ = 1
|
||||
jacobian.add_entry(next, mi, 1.0);
|
||||
if sqrt_term > 0.0 && delta_p > 0.0 {
|
||||
let opening = _state[open_idx].clamp(0.0, 1.0);
|
||||
let p_in = _state[p_in_idx];
|
||||
let h_in = _state[in_h];
|
||||
// Central FD of ρ(P_in,h_in).
|
||||
let dp = p_in * 1e-6 + 1.0;
|
||||
let dh = h_in.abs() * 1e-6 + 1.0;
|
||||
let drho_dp = match (
|
||||
self.inlet_density(p_in + dp, h_in),
|
||||
self.inlet_density(p_in - dp, h_in),
|
||||
) {
|
||||
(Ok(a), Ok(b)) => (a - b) / (2.0 * dp),
|
||||
_ => 0.0,
|
||||
};
|
||||
let drho_dh = match (
|
||||
self.inlet_density(p_in, h_in + dh),
|
||||
self.inlet_density(p_in, h_in - dh),
|
||||
) {
|
||||
(Ok(a), Ok(b)) => (a - b) / (2.0 * dh),
|
||||
_ => 0.0,
|
||||
};
|
||||
let inv = kv * opening / sqrt_term;
|
||||
// ∂r/∂opening = −Kv·√(2ρΔP)
|
||||
jacobian.add_entry(next, open_idx, -kv * sqrt_term);
|
||||
// ∂r/∂P_out = +Kv·opening·ρ/√(2ρΔP)
|
||||
jacobian.add_entry(next, out_p, inv * rho);
|
||||
// ∂r/∂P_in = −Kv·opening·(∂ρ/∂P·ΔP + ρ)/√(2ρΔP)
|
||||
jacobian.add_entry(
|
||||
next,
|
||||
p_in_idx,
|
||||
-inv * (drho_dp * delta_p + rho),
|
||||
);
|
||||
// ∂r/∂h_in = −Kv·opening·(∂ρ/∂h·ΔP)/√(2ρΔP)
|
||||
jacobian.add_entry(next, in_h, -inv * (drho_dh * delta_p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(_), Some(inlet_h), Some(out_p), Some(out_h)) = (
|
||||
&self.fluid_backend,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if !self.refrigerant_id.is_empty() {
|
||||
// ∂r0/∂P_out = 1
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
// ∂r1/∂H_out = 1, ∂r1/∂H_in = -1
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, inlet_h, -1.0);
|
||||
// r2 = ṁ_outlet − ṁ_inlet → exact analytic entries (CM1.3)
|
||||
// CM1.4: omit when same_branch_m.
|
||||
if !self.same_branch_m {
|
||||
if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
|
||||
jacobian.add_entry(2, m_out, 1.0);
|
||||
jacobian.add_entry(2, m_in, -1.0);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
// Emergent mode drops the evaporating-pressure fix: 1 thermo eq (isenthalpic).
|
||||
let thermo = if self.emergent_pressure { 1 } else { 2 };
|
||||
// CM1.4: drop conservation equation when in same series branch.
|
||||
let base = if self.same_branch_m {
|
||||
thermo
|
||||
} else {
|
||||
thermo + 1
|
||||
};
|
||||
// arch-6: the physical orifice closure adds one equation (balanced by the
|
||||
// free-actuator opening unknown). Emergent-only.
|
||||
if self.orifice_configured() {
|
||||
base + 1
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn equation_roles(&self) -> Vec<crate::EquationRole> {
|
||||
let mut roles = Vec::new();
|
||||
if !self.emergent_pressure {
|
||||
roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "P" });
|
||||
}
|
||||
roles.push(crate::EquationRole::EnergyBalance {
|
||||
stream: "refrigerant",
|
||||
});
|
||||
if !self.same_branch_m {
|
||||
roles.push(crate::EquationRole::MassConservation {
|
||||
stream: "refrigerant",
|
||||
});
|
||||
}
|
||||
if self.orifice_configured() {
|
||||
roles.push(crate::EquationRole::ActuatorClosure {
|
||||
name: "orifice",
|
||||
});
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"IsenthalpicExpansionValve(t_evap_k={:.2}, fluid={})",
|
||||
self.t_evap_k, self.refrigerant_id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_and_defaults() {
|
||||
let exv = IsenthalpicExpansionValve::new(275.15);
|
||||
assert_eq!(exv.t_evap_k, 275.15);
|
||||
// CM1.3: 2 thermo + 1 mass-flow conservation = 3
|
||||
assert_eq!(exv.n_equations(), 3);
|
||||
assert!(exv.fluid_backend.is_none());
|
||||
assert!(exv.inlet_h_idx.is_none());
|
||||
assert!(exv.outlet_p_idx.is_none());
|
||||
assert!(exv.outlet_h_idx.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_system_context() {
|
||||
let mut exv = IsenthalpicExpansionValve::new(275.15);
|
||||
// CM1.3 stride-3: incoming (ṁ@1, P@2, h@3), outgoing (ṁ@4, P@5, h@6)
|
||||
exv.set_system_context(0, &[(1, 2, 3), (4, 5, 6)]);
|
||||
assert_eq!(exv.inlet_h_idx, Some(3));
|
||||
assert_eq!(exv.outlet_p_idx, Some(5));
|
||||
assert_eq!(exv.outlet_h_idx, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_context_errors_instead_of_zero_fallback() {
|
||||
let exv = IsenthalpicExpansionValve::new(275.15);
|
||||
let state = vec![1.0_f64; 10];
|
||||
let mut residuals = vec![99.0_f64; 2];
|
||||
let result = exv.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_refrigerant() {
|
||||
let exv = IsenthalpicExpansionValve::new(275.15).with_refrigerant("R410A");
|
||||
assert_eq!(exv.refrigerant_id, "R410A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emergent_mode_drops_pressure_fix() {
|
||||
let mut exv = IsenthalpicExpansionValve::new(275.15)
|
||||
.with_refrigerant("R410A")
|
||||
.with_emergent_pressure();
|
||||
// Same-branch: inlet (0,1,2), outlet (0,3,4). Only the isenthalpic eq remains.
|
||||
exv.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
assert_eq!(exv.n_equations(), 1);
|
||||
|
||||
let mut state = vec![0.0; 5];
|
||||
state[2] = 260_000.0; // h_in
|
||||
state[4] = 250_000.0; // h_out (≠ h_in)
|
||||
let mut r = vec![0.0; 1];
|
||||
exv.compute_residuals(&state, &mut r).unwrap();
|
||||
// r0 = h_out − h_in, no pressure residual is emitted.
|
||||
assert!(
|
||||
(r[0] - (-10_000.0)).abs() < 1e-6,
|
||||
"isenthalpic residual: {}",
|
||||
r[0]
|
||||
);
|
||||
}
|
||||
|
||||
// ── arch-6 physical orifice actuator ────────────────────────────────────
|
||||
|
||||
fn orifice_valve() -> IsenthalpicExpansionValve {
|
||||
use entropyk_fluids::TestBackend;
|
||||
let mut exv = IsenthalpicExpansionValve::new(275.15)
|
||||
.with_refrigerant("R134a")
|
||||
.with_orifice(3.0e-6)
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
// Distinct branches: inlet (m0,p1,h2), outlet (m3,p4,h5); opening @ idx 6.
|
||||
exv.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
|
||||
exv.set_calib_indices(CalibIndices {
|
||||
actuator: Some(6),
|
||||
..Default::default()
|
||||
});
|
||||
exv
|
||||
}
|
||||
|
||||
/// State layout for `orifice_valve`: subcooled-liquid inlet, opening 0.5.
|
||||
fn orifice_state(opening: f64) -> Vec<f64> {
|
||||
let mut s = vec![0.0; 7];
|
||||
s[0] = 0.2; // m_in
|
||||
s[1] = 1.2e6; // p_in
|
||||
s[2] = 2.0e5; // h_in (subcooled liquid)
|
||||
s[3] = 0.2; // m_out
|
||||
s[4] = 3.5e5; // p_out
|
||||
s[5] = 2.0e5; // h_out (isenthalpic)
|
||||
s[6] = opening; // orifice opening
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orifice_adds_one_equation() {
|
||||
// emergent isenthalpic (1) + mass conservation (1) + orifice (1) = 3.
|
||||
let exv = orifice_valve();
|
||||
assert_eq!(exv.n_equations(), 3);
|
||||
assert!(exv.orifice_ready());
|
||||
assert_eq!(exv.orifice_kv(), Some(3.0e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orifice_residual_reacts_to_opening() {
|
||||
let exv = orifice_valve();
|
||||
// Larger opening ⇒ larger orifice flow ⇒ residual (ṁ − flow) decreases.
|
||||
let mut r_small = vec![0.0; 3];
|
||||
let mut r_large = vec![0.0; 3];
|
||||
exv.compute_residuals(&orifice_state(0.3), &mut r_small)
|
||||
.unwrap();
|
||||
exv.compute_residuals(&orifice_state(0.9), &mut r_large)
|
||||
.unwrap();
|
||||
assert!(
|
||||
r_large[2] < r_small[2],
|
||||
"opening 0.9 must pass more flow than 0.3: {} !< {}",
|
||||
r_large[2],
|
||||
r_small[2]
|
||||
);
|
||||
// At the physical opening the orifice flow matches ṁ (residual ≈ 0).
|
||||
// Compute the opening that closes the residual and confirm sign change.
|
||||
let mut r_closed = vec![0.0; 3];
|
||||
exv.compute_residuals(&orifice_state(0.05), &mut r_closed)
|
||||
.unwrap();
|
||||
assert!(r_closed[2] > 0.0, "tiny opening under-passes flow (r>0)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orifice_jacobian_matches_finite_difference() {
|
||||
let exv = orifice_valve();
|
||||
let state = orifice_state(0.5);
|
||||
|
||||
// Analytic entries for the orifice row (row index 2).
|
||||
let mut jb = JacobianBuilder::new();
|
||||
exv.jacobian_entries(&state, &mut jb).unwrap();
|
||||
let mut analytic = std::collections::HashMap::new();
|
||||
for &(row, col, val) in jb.entries() {
|
||||
if row == 2 {
|
||||
*analytic.entry(col).or_insert(0.0) += val;
|
||||
}
|
||||
}
|
||||
|
||||
let residual_row2 = |s: &[f64]| -> f64 {
|
||||
let mut r = vec![0.0; 3];
|
||||
exv.compute_residuals(s, &mut r).unwrap();
|
||||
r[2]
|
||||
};
|
||||
|
||||
// Central finite difference for each dependent column.
|
||||
for &(col, eps) in &[
|
||||
(1usize, 50.0), // p_in
|
||||
(2usize, 20.0), // h_in
|
||||
(3usize, 1e-4), // m_out
|
||||
(4usize, 50.0), // p_out
|
||||
(6usize, 1e-4), // opening
|
||||
] {
|
||||
let mut sp = state.clone();
|
||||
let mut sm = state.clone();
|
||||
sp[col] += eps;
|
||||
sm[col] -= eps;
|
||||
let fd = (residual_row2(&sp) - residual_row2(&sm)) / (2.0 * eps);
|
||||
let an = *analytic.get(&col).unwrap_or(&0.0);
|
||||
let tol = 1e-4 * an.abs().max(1.0);
|
||||
assert!(
|
||||
(fd - an).abs() <= tol,
|
||||
"col {col}: analytic {an} vs FD {fd} (tol {tol})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orifice_without_actuator_index_errors() {
|
||||
use entropyk_fluids::TestBackend;
|
||||
let mut exv = IsenthalpicExpansionValve::new(275.15)
|
||||
.with_refrigerant("R134a")
|
||||
.with_orifice(3.0e-6)
|
||||
.with_fluid_backend(Arc::new(TestBackend::new()));
|
||||
exv.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
|
||||
assert!(!exv.orifice_ready());
|
||||
assert_eq!(exv.n_equations(), 3);
|
||||
let mut r = vec![0.0; 3];
|
||||
let result = exv.compute_residuals(&orifice_state(0.5), &mut r);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
1710
crates/components/src/isentropic_compressor.rs
Normal file
1710
crates/components/src/isentropic_compressor.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,11 @@
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
pub mod air_boundary;
|
||||
pub mod anchor;
|
||||
pub mod brine_boundary;
|
||||
pub mod capillary_tube;
|
||||
pub mod centrifugal_compressor;
|
||||
pub mod dof;
|
||||
pub mod bypass_valve;
|
||||
pub mod compressor;
|
||||
pub mod curves;
|
||||
@@ -67,67 +71,83 @@ pub mod fan;
|
||||
pub mod flow_junction;
|
||||
pub mod free_cooling_exchanger;
|
||||
pub mod heat_exchanger;
|
||||
pub mod heat_source;
|
||||
pub mod isenthalpic_expansion_valve;
|
||||
pub mod isentropic_compressor;
|
||||
pub mod node;
|
||||
pub mod params;
|
||||
pub mod pipe;
|
||||
pub mod polynomials;
|
||||
pub mod port;
|
||||
pub mod pump;
|
||||
pub mod registry;
|
||||
pub mod python_components;
|
||||
pub mod refrigerant_boundary;
|
||||
pub mod registry;
|
||||
pub mod reversing_valve;
|
||||
pub mod screw_economizer_compressor;
|
||||
pub mod state_machine;
|
||||
pub mod thermal_load;
|
||||
pub mod valve_flow;
|
||||
|
||||
pub use air_boundary::{AirSink, AirSource};
|
||||
pub use anchor::{Anchor, AnchorConstraint};
|
||||
pub use brine_boundary::{BrineSink, BrineSource};
|
||||
pub use capillary_tube::{CapillaryGeometry, CapillaryTube};
|
||||
pub use centrifugal_compressor::{CentrifugalCompressor, CentrifugalMap, CentrifugalMapPoint};
|
||||
pub use dof::{unspecified_roles, EquationRole};
|
||||
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
|
||||
pub use compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
|
||||
pub use curves::{
|
||||
BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning,
|
||||
};
|
||||
pub use curves::{BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning};
|
||||
pub use drum::Drum;
|
||||
pub use expansion_valve::{ExpansionValve, PhaseRegion};
|
||||
pub use valve_flow::{valve_mass_flow, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel};
|
||||
pub use external_model::{
|
||||
ExternalModel, ExternalModelConfig, ExternalModelError, ExternalModelMetadata,
|
||||
ExternalModelType, MockExternalModel, ThreadSafeExternalModel,
|
||||
};
|
||||
pub use fan::{Fan, FanCurves};
|
||||
pub use free_cooling_exchanger::{
|
||||
FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode,
|
||||
};
|
||||
pub use flow_junction::{
|
||||
CompressibleMerger, CompressibleSplitter, FlowMerger, FlowSplitter, FluidKind,
|
||||
IncompressibleMerger, IncompressibleSplitter,
|
||||
};
|
||||
pub use free_cooling_exchanger::{
|
||||
FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode,
|
||||
};
|
||||
pub use heat_exchanger::model::FluidState;
|
||||
pub use heat_exchanger::{
|
||||
Condenser, CondenserCoil, Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, ExchangerType,
|
||||
FloodedCondenser, FloodedEvaporator, FlowConfiguration, HeatExchanger, HeatExchangerBuilder,
|
||||
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil,
|
||||
AirCooledCondenser, CoilGeometry, Condenser, CondenserCoil, CondenserRating, Economizer,
|
||||
EpsNtuModel, Evaporator, EvaporatorCoil, EvaporatorRating, ExchangerType, FanCoilUnit,
|
||||
FinCoilCondenser, FinType, FloodedCondenser, FloodedEvaporator, FloodedPoolBoilingConfig,
|
||||
FlowConfiguration, GasCooler, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
|
||||
HxSideConditions, LmtdModel, MchxCondenserCoil, ShellAndTubeHx, UaMode,
|
||||
};
|
||||
pub use heat_source::HeatSource;
|
||||
pub use isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
||||
pub use isentropic_compressor::IsentropicCompressor;
|
||||
pub use isentropic_compressor::{VolumetricEfficiency, VsdSpeedMap};
|
||||
pub use node::{Node, NodeMeasurements, NodePhase};
|
||||
pub use params::ComponentParams;
|
||||
pub use registry::{RegistryError, create_component};
|
||||
pub use pipe::{friction_factor, roughness, Pipe, PipeGeometry};
|
||||
pub use polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D, Polynomial2D};
|
||||
pub use port::{
|
||||
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
|
||||
Port,
|
||||
Port, PortKind,
|
||||
};
|
||||
pub use pump::{Pump, PumpCurves};
|
||||
pub use python_components::{
|
||||
PyCompressorReal, PyExpansionValveReal, PyFlowMergerReal, PyFlowSinkReal, PyFlowSourceReal,
|
||||
PyFlowSplitterReal, PyHeatExchangerReal, PyPipeReal,
|
||||
PyRefrigerantSourceReal, PyRefrigerantSinkReal, PyBrineSourceReal, PyBrineSinkReal,
|
||||
PyAirSourceReal, PyAirSinkReal,
|
||||
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,
|
||||
PyExpansionValveReal, PyFlowMergerReal, PyFlowSinkReal, PyFlowSourceReal, PyFlowSplitterReal,
|
||||
PyHeatExchangerReal, PyPipeReal, PyRefrigerantSinkReal, PyRefrigerantSourceReal,
|
||||
};
|
||||
pub use refrigerant_boundary::{RefrigerantSink, RefrigerantSource};
|
||||
pub use registry::{create_component, RegistryError};
|
||||
pub use reversing_valve::{ReversingMode, ReversingValve};
|
||||
pub use screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
pub use state_machine::{
|
||||
CircuitId, OperationalState, StateHistory, StateManageable, StateTransitionError,
|
||||
StateTransitionRecord,
|
||||
};
|
||||
pub use thermal_load::ThermalLoad;
|
||||
|
||||
use entropyk_core::{MassFlow, Power};
|
||||
use thiserror::Error;
|
||||
@@ -386,6 +406,32 @@ impl JacobianBuilder {
|
||||
///
|
||||
/// Both computation methods return [`Result`] to allow components to report
|
||||
/// errors such as invalid state dimensions, numerical issues, or invalid
|
||||
/// Physical output that can be measured from a component's converged state.
|
||||
///
|
||||
/// Used by the inverse-control layer to evaluate constraint residuals from
|
||||
/// *real* thermodynamics (via [`Component::measure_output`]) instead of
|
||||
/// placeholder formulas. Each variant maps to a solver-side `ComponentOutput`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MeasuredOutput {
|
||||
/// Refrigerant-side heat/duty [W] (evaporator/condenser capacity).
|
||||
Capacity,
|
||||
/// Heat-transfer rate [W] (alias of capacity for HX duty).
|
||||
HeatTransferRate,
|
||||
/// Suction/outlet superheat above saturation [K].
|
||||
Superheat,
|
||||
/// Liquid-line subcooling below saturation [K].
|
||||
Subcooling,
|
||||
/// Refrigerant mass flow rate [kg/s].
|
||||
MassFlowRate,
|
||||
/// Absolute pressure at the component [Pa].
|
||||
Pressure,
|
||||
/// Refrigerant temperature at the component [K].
|
||||
Temperature,
|
||||
/// Saturation temperature at the component's reference pressure [K]
|
||||
/// (SST for an evaporator, SDT for a condenser).
|
||||
SaturationTemperature,
|
||||
}
|
||||
|
||||
/// component configuration.
|
||||
///
|
||||
/// # Type Parameters
|
||||
@@ -519,6 +565,32 @@ pub trait Component {
|
||||
/// ```
|
||||
fn n_equations(&self) -> usize;
|
||||
|
||||
/// Semantic roles for each residual equation contributed by this component.
|
||||
///
|
||||
/// Used by the system-wide DoF ledger (`entropyk_solver::dof`) to audit that
|
||||
/// the algebraic system is square and that every fix has a corresponding free
|
||||
/// unknown (or an intentional residual drop).
|
||||
///
|
||||
/// # Contract
|
||||
///
|
||||
/// - Prefer returning exactly [`n_equations`](Self::n_equations) roles.
|
||||
/// - An empty default is allowed for legacy components; the solver then
|
||||
/// labels rows as `Unspecified`.
|
||||
/// - **Never** add a residual "to make it converge" without declaring its
|
||||
/// role and the free unknown it closes.
|
||||
///
|
||||
/// # Fix / Free discipline
|
||||
///
|
||||
/// | Role | DoF effect |
|
||||
/// |------|------------|
|
||||
/// | `BoundaryDirichlet` | Fixes a boundary state (machine input) |
|
||||
/// | `OutletClosure` | Consumes one DoF — pair with a free actuator or drop another residual |
|
||||
/// | `EnergyBalance` / `Momentum…` | Closes a physics residual on live ports |
|
||||
/// | `ActuatorClosure` | Closes a free actuator unknown |
|
||||
fn equation_roles(&self) -> Vec<EquationRole> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Returns the connected ports of this component.
|
||||
///
|
||||
/// This method provides access to the component's ports for topology
|
||||
@@ -625,18 +697,49 @@ pub trait Component {
|
||||
/// *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.
|
||||
/// * `external_edge_state_indices` — A slice of `(m_idx, p_idx, h_idx)` triples for
|
||||
/// every edge incident to this component's node in the parent graph (incoming and
|
||||
/// outgoing), in traversal order. The `m_idx` is the mass-flow state index added
|
||||
/// by CM1.2; components use it to contribute mass-flow residuals (CM1.3).
|
||||
/// `MacroComponent` uses all three indices to emit port-coupling residuals.
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
_external_edge_state_indices: &[(usize, usize)],
|
||||
_external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Default: no-op for all ordinary leaf components.
|
||||
}
|
||||
|
||||
/// Injects the per-port edge state indices resolved by the solver.
|
||||
///
|
||||
/// `port_edges[i]` is `Some((m_idx, p_idx, h_idx))` when a flow edge is
|
||||
/// connected at this component's local port index `i` (the same index used
|
||||
/// by `System::add_edge_with_ports`), or `None` when the port is
|
||||
/// unconnected. Unlike [`set_system_context`](Self::set_system_context),
|
||||
/// whose incident-edge ordering depends on graph traversal, this mapping is
|
||||
/// deterministic — multi-port components (Modelica-style 4-port heat
|
||||
/// exchangers, economizer compressors, drums…) should prefer it.
|
||||
///
|
||||
/// Called by `System::finalize()` right after `set_system_context`.
|
||||
fn set_port_context(&mut self, _port_edges: &[Option<(usize, usize, usize)>]) {
|
||||
// Default: no-op — positional wiring via set_system_context stays valid.
|
||||
}
|
||||
|
||||
/// Declares the internal series flow paths of a multi-port component as
|
||||
/// `(inlet_port, outlet_port)` pairs.
|
||||
///
|
||||
/// The mass-flow topology presolve uses these to walk *through* the
|
||||
/// component: an edge entering at `inlet_port` and the edge leaving at
|
||||
/// `outlet_port` belong to the same series branch (they share one ṁ
|
||||
/// unknown), exactly like Modelica's `port_a.m_flow + port_b.m_flow = 0`.
|
||||
///
|
||||
/// A Modelica-style 4-port heat exchanger returns `[(0, 1), (2, 3)]`
|
||||
/// (refrigerant path + secondary path). Genuine junctions (splitters,
|
||||
/// mergers, drums) keep the empty default so they stay branch boundaries.
|
||||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Returns the number of internal state variables this component maintains.
|
||||
///
|
||||
/// The default implementation returns 0, which is correct for all ordinary
|
||||
@@ -695,6 +798,28 @@ pub trait Component {
|
||||
// Default: no-op for components that don't support inverse calibration
|
||||
}
|
||||
|
||||
/// Injects the state index of an externally-determined heat rate Q [W]
|
||||
/// (an inter-circuit thermal-coupling unknown) into this component.
|
||||
///
|
||||
/// Called by the solver's `System::finalize()` for the cold-side receiver
|
||||
/// of a physical thermal coupling (e.g. [`ThermalLoad`]), which then reads
|
||||
/// `Q = state[idx]` in its energy-balance residual.
|
||||
fn set_external_heat_index(&mut self, _idx: usize) {
|
||||
// Default: no-op for components that don't consume external heat
|
||||
}
|
||||
|
||||
/// Whether this component's [`energy_transfers`](Self::energy_transfers)
|
||||
/// contribute to the refrigerant-cycle performance aggregation
|
||||
/// (cooling/heating capacity, COP).
|
||||
///
|
||||
/// Secondary-side receivers such as [`ThermalLoad`] return `false`: the
|
||||
/// heat they absorb is the *rejected* duty of the primary cycle and must
|
||||
/// not be double-counted as cooling capacity. They still participate in
|
||||
/// per-component First Law validation.
|
||||
fn counts_in_cycle_performance(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Updates a single calibration factor on this component.
|
||||
///
|
||||
/// Returns `true` if the factor was recognized and updated. The default
|
||||
@@ -713,7 +838,10 @@ pub trait Component {
|
||||
///
|
||||
/// The default implementation is a no-op — components that don't use fluid backends
|
||||
/// silently ignore this.
|
||||
fn set_fluid_backend_from_builder(&mut self, _backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
_backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
// Default: no-op for components that don't use fluid backends
|
||||
}
|
||||
|
||||
@@ -730,6 +858,27 @@ pub trait Component {
|
||||
None
|
||||
}
|
||||
|
||||
/// Measures a physical output from the current state for inverse control.
|
||||
///
|
||||
/// Returns the *real* value of the requested [`MeasuredOutput`] using this
|
||||
/// component's thermodynamics, or `None` when the component cannot provide
|
||||
/// it. This replaces the placeholder constraint formulas: the inverse-control
|
||||
/// solver calls this to form genuine constraint residuals `measure − target`.
|
||||
///
|
||||
/// The default implementation derives `Capacity`/`HeatTransferRate` from
|
||||
/// [`energy_transfers`](Self::energy_transfers) (the refrigerant-side heat,
|
||||
/// as an absolute duty in Watts). All other outputs return `None`; heat
|
||||
/// exchangers and the compressor override this to add superheat, subcooling,
|
||||
/// mass flow, pressure and temperature.
|
||||
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
match kind {
|
||||
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => self
|
||||
.energy_transfers(state)
|
||||
.map(|(heat, _work)| heat.to_watts().abs()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a string signature of the component's configuration (parameters, fluid, etc.).
|
||||
/// Used for simulation traceability (input hashing).
|
||||
/// Default implementation is provided, but components should override this to include
|
||||
|
||||
@@ -414,7 +414,10 @@ impl Component for Node<Connected> {
|
||||
])
|
||||
}
|
||||
|
||||
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
|
||||
fn set_fluid_backend_from_builder(
|
||||
&mut self,
|
||||
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
|
||||
) {
|
||||
if self.fluid_backend.is_none() {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
|
||||
@@ -140,11 +140,34 @@ impl PipeGeometry {
|
||||
|
||||
/// Friction factor calculation methods.
|
||||
pub mod friction_factor {
|
||||
use entropyk_core::smoothing::cubic_blend;
|
||||
|
||||
/// Calculates the Haaland friction factor for turbulent flow.
|
||||
/// Reynolds number below which the flow is treated as fully laminar.
|
||||
pub const RE_LAMINAR: f64 = 2300.0;
|
||||
|
||||
/// Reynolds number above which the flow is treated as fully turbulent.
|
||||
pub const RE_TURBULENT: f64 = 4000.0;
|
||||
|
||||
/// Pure turbulent Haaland friction factor (no transition handling).
|
||||
///
|
||||
/// The Haaland equation is an approximation of the Colebrook-White equation
|
||||
/// that can be solved explicitly without iteration.
|
||||
/// 1/√f = -1.8 × log10[(ε/D / 3.7)^1.11 + 6.9/Re]
|
||||
fn haaland_turbulent(relative_roughness: f64, reynolds: f64) -> f64 {
|
||||
let re_clamped = reynolds.max(1.0);
|
||||
let term1 = (relative_roughness / 3.7).powf(1.11);
|
||||
let term2 = 6.9 / re_clamped;
|
||||
let inv_sqrt_f = -1.8 * (term1 + term2).log10();
|
||||
1.0 / (inv_sqrt_f * inv_sqrt_f)
|
||||
}
|
||||
|
||||
/// Calculates the Darcy friction factor with a C¹ laminar→turbulent blend.
|
||||
///
|
||||
/// Below [`RE_LAMINAR`] the laminar law `f = 64/Re` is used; above
|
||||
/// [`RE_TURBULENT`] the explicit Haaland turbulent correlation is used. In
|
||||
/// the transition band `[RE_LAMINAR, RE_TURBULENT]` the two are blended with
|
||||
/// a C¹ [`cubic_blend`], so the friction factor — and therefore the analytic
|
||||
/// pressure-drop Jacobian — has **no slope discontinuity** at `Re = 2300`.
|
||||
/// The previous hard `if Re < 2300` switch introduced a kink that made the
|
||||
/// Newton Jacobian jump (the PR#563-style failure mode).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -159,22 +182,22 @@ pub mod friction_factor {
|
||||
return 0.02; // Default for invalid input
|
||||
}
|
||||
|
||||
// Laminar flow: f = 64/Re
|
||||
// Do not clamp Reynolds number here to preserve linear pressure drop near zero flow.
|
||||
if reynolds < 2300.0 {
|
||||
return 64.0 / reynolds;
|
||||
// Laminar law. Reynolds is not clamped here so the pressure drop stays
|
||||
// linear near zero flow (Story 3.5 zero-flow regularization).
|
||||
let f_laminar = 64.0 / reynolds;
|
||||
if reynolds <= RE_LAMINAR {
|
||||
return f_laminar;
|
||||
}
|
||||
|
||||
// Prevent division by zero or negative values in log
|
||||
let re_clamped = reynolds.max(1.0);
|
||||
let f_turbulent = haaland_turbulent(relative_roughness, reynolds);
|
||||
if reynolds >= RE_TURBULENT {
|
||||
return f_turbulent;
|
||||
}
|
||||
|
||||
// Haaland equation (turbulent)
|
||||
// 1/√f = -1.8 × log10[(ε/D/3.7)^1.11 + 6.9/Re]
|
||||
let term1 = (relative_roughness / 3.7).powf(1.11);
|
||||
let term2 = 6.9 / re_clamped;
|
||||
let inv_sqrt_f = -1.8 * (term1 + term2).log10();
|
||||
|
||||
1.0 / (inv_sqrt_f * inv_sqrt_f)
|
||||
// Transition band: C¹ blend. At both edges the blend weight and its
|
||||
// derivative are zero, so the slope matches the pure laminar / turbulent
|
||||
// branches there — the join is C¹.
|
||||
cubic_blend(f_laminar, f_turbulent, reynolds, RE_LAMINAR, RE_TURBULENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +251,21 @@ pub struct Pipe<State> {
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
/// Design-point pressure drop (Pa) for the edge-coupled (P,h) solver model.
|
||||
/// The (P,h) refrigeration solver has no mass-flow unknown, so the pressure
|
||||
/// drop is imposed as a design-point value rather than computed from Darcy.
|
||||
design_dp_pa: f64,
|
||||
/// When true, the component uses the edge-coupled (P,h) solver model
|
||||
/// (`n_equations() == 2`) instead of the legacy mass-flow model.
|
||||
edge_coupled: bool,
|
||||
/// Inlet edge pressure index in the global state vector (edge-coupled mode).
|
||||
inlet_p_idx: Option<usize>,
|
||||
/// Inlet edge enthalpy index in the global state vector (edge-coupled mode).
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Outlet edge pressure index in the global state vector (edge-coupled mode).
|
||||
outlet_p_idx: Option<usize>,
|
||||
/// Outlet edge enthalpy index in the global state vector (edge-coupled mode).
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Phantom data for type state
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
@@ -276,6 +314,12 @@ impl Pipe<Disconnected> {
|
||||
calib: Calib::default(),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
design_dp_pa: 0.0,
|
||||
edge_coupled: false,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -431,12 +475,29 @@ impl Pipe<Disconnected> {
|
||||
calib: self.calib,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
design_dp_pa: self.design_dp_pa,
|
||||
edge_coupled: self.edge_coupled,
|
||||
inlet_p_idx: self.inlet_p_idx,
|
||||
inlet_h_idx: self.inlet_h_idx,
|
||||
outlet_p_idx: self.outlet_p_idx,
|
||||
outlet_h_idx: self.outlet_h_idx,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Pipe<Connected> {
|
||||
/// Enables the edge-coupled (P,h) solver model with a design-point pressure
|
||||
/// drop, so the pipe participates in the refrigeration/hydronic graph solver.
|
||||
///
|
||||
/// In this mode the pipe owns 2 equations on its outlet edge:
|
||||
/// - `r0 = P_out - (P_in - design_dp_pa)` (imposed pressure drop)
|
||||
/// - `r1 = h_out - h_in` (adiabatic pass-through)
|
||||
pub fn with_design_pressure_drop_pa(mut self, design_dp_pa: f64) -> Self {
|
||||
self.design_dp_pa = design_dp_pa.max(0.0);
|
||||
self.edge_coupled = true;
|
||||
self
|
||||
}
|
||||
/// Returns the inlet port.
|
||||
pub fn port_inlet(&self) -> &Port<Connected> {
|
||||
&self.port_inlet
|
||||
@@ -504,7 +565,7 @@ impl Pipe<Connected> {
|
||||
|
||||
// Darcy-Weisbach nominal: ΔP_nominal = f × (L/D) × (ρ × v² / 2); ΔP_eff = f_dp × ΔP_nominal
|
||||
let dp_nominal = f * ld * self.fluid_density_kg_per_m3 * velocity * velocity / 2.0;
|
||||
let dp = dp_nominal * self.calib.f_dp;
|
||||
let dp = dp_nominal * self.calib.z_dp;
|
||||
|
||||
if flow_m3_per_s < 0.0 {
|
||||
-dp
|
||||
@@ -555,11 +616,57 @@ impl Pipe<Connected> {
|
||||
}
|
||||
|
||||
impl Component for Pipe<Connected> {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge (upstream→pipe), [1] = outgoing edge (pipe→downstream)
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Edge-coupled (P,h) model: constrain the outlet edge.
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let dp = match self.operational_state {
|
||||
OperationalState::Bypass => 0.0,
|
||||
_ => self.calib.z_dp * self.design_dp_pa,
|
||||
};
|
||||
// r0: imposed pressure drop across the pipe
|
||||
residuals[0] = state[out_p] - (state[in_p] - dp);
|
||||
// r1: adiabatic pass-through (enthalpy conserved)
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pipe edge-coupled model requires live inlet/outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if residuals.len() != self.n_equations() {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: self.n_equations(),
|
||||
@@ -618,6 +725,24 @@ impl Component for Pipe<Connected> {
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Edge-coupled (P,h) model.
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
// r0 = P_out - (P_in - dp) → ∂r0/∂P_out = 1, ∂r0/∂P_in = -1
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
// r1 = h_out - h_in → ∂r1/∂h_out = 1, ∂r1/∂h_in = -1
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
@@ -652,7 +777,11 @@ impl Component for Pipe<Connected> {
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
1
|
||||
if self.edge_coupled {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
@@ -713,7 +842,10 @@ impl Component for Pipe<Connected> {
|
||||
.with_param("roughnessM", self.geometry.roughness_m)
|
||||
.with_param("fluidDensityKgPerM3", self.fluid_density_kg_per_m3)
|
||||
.with_param("fluidViscosityPaS", self.fluid_viscosity_pa_s)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -795,6 +927,12 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
design_dp_pa: 0.0,
|
||||
edge_coupled: false,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -849,6 +987,44 @@ mod tests {
|
||||
assert!(f_turb > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_friction_factor_transition_is_c1() {
|
||||
// The laminar→turbulent blend must be continuous in value AND slope
|
||||
// across the whole transition band [2300, 4000] — no PR#563-style kink.
|
||||
let rel = 0.001;
|
||||
let f = |re: f64| friction_factor::haaland(rel, re);
|
||||
let dfd = |re: f64| (f(re + 0.5) - f(re - 0.5)) / 1.0;
|
||||
|
||||
// Value continuity across the band, including the former jump at 2300.
|
||||
let mut prev_v = f(2299.0);
|
||||
let mut prev_s = dfd(2299.0);
|
||||
for re_i in (2300..=4000).step_by(20) {
|
||||
let re = re_i as f64;
|
||||
let v = f(re);
|
||||
let s = dfd(re);
|
||||
assert!(v > 0.0 && v.is_finite());
|
||||
assert!(
|
||||
(v - prev_v).abs() < 1e-3,
|
||||
"friction value jump near Re={}: {} vs {}",
|
||||
re,
|
||||
v,
|
||||
prev_v
|
||||
);
|
||||
assert!(
|
||||
(s - prev_s).abs() < 1e-4,
|
||||
"friction slope jump near Re={}: {} vs {}",
|
||||
re,
|
||||
s,
|
||||
prev_s
|
||||
);
|
||||
prev_v = v;
|
||||
prev_s = s;
|
||||
}
|
||||
|
||||
// Endpoints match the pure branches.
|
||||
assert_relative_eq!(f(2300.0), 64.0 / 2300.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_friction_factor_zero_flow_regularization() {
|
||||
// Re = 0 or very small must not cause division by zero (Story 3.5)
|
||||
@@ -946,7 +1122,7 @@ mod tests {
|
||||
let flow = 0.005;
|
||||
let dp_default = pipe.pressure_drop(flow);
|
||||
pipe.set_calib(Calib {
|
||||
f_dp: 1.1,
|
||||
z_dp: 1.1,
|
||||
..Calib::default()
|
||||
});
|
||||
let dp_calib = pipe.pressure_drop(flow);
|
||||
|
||||
@@ -68,6 +68,15 @@ pub enum ConnectionError {
|
||||
to: String,
|
||||
},
|
||||
|
||||
/// Attempted to connect ports of incompatible semantic kinds.
|
||||
#[error("Incompatible port kinds: cannot connect {from:?} to {to:?}")]
|
||||
IncompatiblePortKind {
|
||||
/// Source port kind
|
||||
from: PortKind,
|
||||
/// Target port kind
|
||||
to: PortKind,
|
||||
},
|
||||
|
||||
/// Pressure mismatch at connection point.
|
||||
#[error(
|
||||
"Pressure mismatch: {from_pressure} Pa vs {to_pressure} Pa (tolerance: {tolerance} Pa)"
|
||||
@@ -118,6 +127,39 @@ pub enum ConnectionError {
|
||||
InvalidNodeIndex(usize),
|
||||
}
|
||||
|
||||
/// Semantic kind of a port, used to validate that only compatible ports are
|
||||
/// wired together and to distinguish physical (acausal) from control (causal)
|
||||
/// connections.
|
||||
///
|
||||
/// - [`PortKind::Refrigerant`] / [`PortKind::Secondary`] are **acausal physical**
|
||||
/// ports: an edge asserts thermodynamic continuity/coupling (P, h).
|
||||
/// - [`PortKind::Mechanical`] carries shaft power / speed.
|
||||
/// - [`PortKind::Signal`] is a **causal** control connection (measurement →
|
||||
/// controller → actuator); it does not carry a thermodynamic state.
|
||||
///
|
||||
/// Only ports of the same kind may be connected. Refrigerant and Secondary are
|
||||
/// deliberately distinct so a working-fluid line cannot be wired to a
|
||||
/// water/brine/air line by mistake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum PortKind {
|
||||
/// Primary working fluid (the refrigerant loop).
|
||||
#[default]
|
||||
Refrigerant,
|
||||
/// Secondary heat-transfer fluid (water, brine, or air).
|
||||
Secondary,
|
||||
/// Mechanical connection (shaft power / rotational speed).
|
||||
Mechanical,
|
||||
/// Causal control signal (no thermodynamic state).
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl PortKind {
|
||||
/// Returns `true` if the two kinds may be connected together.
|
||||
pub fn is_compatible_with(self, other: PortKind) -> bool {
|
||||
self == other
|
||||
}
|
||||
}
|
||||
|
||||
/// Type-state marker for disconnected ports.
|
||||
///
|
||||
/// Ports in this state cannot be used in the solver until connected.
|
||||
@@ -158,6 +200,7 @@ pub struct Port<State> {
|
||||
fluid_id: FluidId,
|
||||
pressure: Pressure,
|
||||
enthalpy: Enthalpy,
|
||||
kind: PortKind,
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
|
||||
@@ -166,10 +209,19 @@ fn validate_connection_params(
|
||||
from_fluid: &FluidId,
|
||||
from_p: Pressure,
|
||||
from_h: Enthalpy,
|
||||
from_kind: PortKind,
|
||||
to_fluid: &FluidId,
|
||||
to_p: Pressure,
|
||||
to_h: Enthalpy,
|
||||
to_kind: PortKind,
|
||||
) -> Result<(), ConnectionError> {
|
||||
if !from_kind.is_compatible_with(to_kind) {
|
||||
return Err(ConnectionError::IncompatiblePortKind {
|
||||
from: from_kind,
|
||||
to: to_kind,
|
||||
});
|
||||
}
|
||||
|
||||
if from_fluid != to_fluid {
|
||||
return Err(ConnectionError::IncompatibleFluid {
|
||||
from: from_fluid.to_string(),
|
||||
@@ -226,15 +278,28 @@ impl Port<Disconnected> {
|
||||
fluid_id,
|
||||
pressure,
|
||||
enthalpy,
|
||||
kind: PortKind::Refrigerant,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the semantic [`PortKind`] of this port (default is
|
||||
/// [`PortKind::Refrigerant`]).
|
||||
pub fn with_kind(mut self, kind: PortKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the fluid identifier.
|
||||
pub fn fluid_id(&self) -> &FluidId {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Returns the semantic kind of this port.
|
||||
pub fn kind(&self) -> PortKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Returns the current pressure.
|
||||
pub fn pressure(&self) -> Pressure {
|
||||
self.pressure
|
||||
@@ -292,9 +357,11 @@ impl Port<Disconnected> {
|
||||
&self.fluid_id,
|
||||
self.pressure,
|
||||
self.enthalpy,
|
||||
self.kind,
|
||||
&other.fluid_id,
|
||||
other.pressure,
|
||||
other.enthalpy,
|
||||
other.kind,
|
||||
)?;
|
||||
|
||||
let avg_pressure = Pressure::from_pascals(
|
||||
@@ -308,6 +375,7 @@ impl Port<Disconnected> {
|
||||
fluid_id: self.fluid_id,
|
||||
pressure: avg_pressure,
|
||||
enthalpy: avg_enthalpy,
|
||||
kind: self.kind,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
@@ -315,6 +383,7 @@ impl Port<Disconnected> {
|
||||
fluid_id: other.fluid_id,
|
||||
pressure: avg_pressure,
|
||||
enthalpy: avg_enthalpy,
|
||||
kind: other.kind,
|
||||
_state: PhantomData,
|
||||
};
|
||||
|
||||
@@ -328,6 +397,11 @@ impl Port<Connected> {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Returns the semantic kind of this port.
|
||||
pub fn kind(&self) -> PortKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Returns the current pressure.
|
||||
pub fn pressure(&self) -> Pressure {
|
||||
self.pressure
|
||||
@@ -384,9 +458,11 @@ pub fn validate_port_continuity(
|
||||
&outlet.fluid_id,
|
||||
outlet.pressure,
|
||||
outlet.enthalpy,
|
||||
outlet.kind,
|
||||
&inlet.fluid_id,
|
||||
inlet.pressure,
|
||||
inlet.enthalpy,
|
||||
inlet.kind,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -395,6 +471,62 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn test_port_default_kind_is_refrigerant() {
|
||||
let port = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
assert_eq!(port.kind(), PortKind::Refrigerant);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_kind_sets_and_propagates_through_connect() {
|
||||
let sec1 = Port::new(
|
||||
FluidId::new("Water"),
|
||||
Pressure::from_bar(2.0),
|
||||
Enthalpy::from_joules_per_kg(100000.0),
|
||||
)
|
||||
.with_kind(PortKind::Secondary);
|
||||
let sec2 = Port::new(
|
||||
FluidId::new("Water"),
|
||||
Pressure::from_bar(2.0),
|
||||
Enthalpy::from_joules_per_kg(100000.0),
|
||||
)
|
||||
.with_kind(PortKind::Secondary);
|
||||
|
||||
let (c1, c2) = sec1
|
||||
.connect(sec2)
|
||||
.expect("matching secondary ports connect");
|
||||
assert_eq!(c1.kind(), PortKind::Secondary);
|
||||
assert_eq!(c2.kind(), PortKind::Secondary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_incompatible_port_kinds_rejected() {
|
||||
let refrigerant = Port::new(
|
||||
FluidId::new("Water"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(100000.0),
|
||||
); // default Refrigerant
|
||||
let secondary = Port::new(
|
||||
FluidId::new("Water"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(100000.0),
|
||||
)
|
||||
.with_kind(PortKind::Secondary);
|
||||
|
||||
let err = refrigerant.connect(secondary).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
ConnectionError::IncompatiblePortKind {
|
||||
from: PortKind::Refrigerant,
|
||||
to: PortKind::Secondary,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_creation() {
|
||||
let port = Port::new(
|
||||
|
||||
@@ -183,6 +183,12 @@ pub struct Pump<State> {
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
outlet_m_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Phantom data for type state
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
@@ -228,6 +234,12 @@ impl Pump<Disconnected> {
|
||||
speed_ratio: 1.0,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -299,6 +311,12 @@ impl Pump<Disconnected> {
|
||||
speed_ratio: self.speed_ratio,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -460,6 +478,23 @@ impl Pump<Connected> {
|
||||
}
|
||||
|
||||
impl Component for Pump<Connected> {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.get(1) {
|
||||
self.outlet_m_idx = Some(m);
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
@@ -472,55 +507,55 @@ impl Component for Pump<Connected> {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle operational states
|
||||
let (in_m, in_p, in_h, out_p, out_h) = match (
|
||||
self.inlet_m_idx,
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
(Some(in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) => {
|
||||
(in_m, in_p, in_h, out_p, out_h)
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pump requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let max_idx = in_m.max(in_p).max(in_h).max(out_p).max(out_h);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
residuals[0] = state[0]; // Mass flow = 0
|
||||
residuals[1] = 0.0; // No energy transfer
|
||||
residuals[0] = state[out_p] - state[in_p];
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::Bypass => {
|
||||
// Behaves as a pipe: no pressure rise, no energy change
|
||||
let p_in = self.port_inlet.pressure().to_pascals();
|
||||
let p_out = self.port_outlet.pressure().to_pascals();
|
||||
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||||
|
||||
residuals[0] = p_in - p_out;
|
||||
residuals[1] = h_in - h_out;
|
||||
residuals[0] = state[out_p] - state[in_p];
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::On => {}
|
||||
}
|
||||
|
||||
if state.len() < 2 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 2,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// State: [mass_flow_kg_s, power_w]
|
||||
let mass_flow_kg_s = state[0];
|
||||
let _power_w = state[1];
|
||||
|
||||
// Convert to volumetric flow
|
||||
let mass_flow_kg_s = state[in_m];
|
||||
let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3;
|
||||
|
||||
// Calculate pressure rise from curves
|
||||
let delta_p_calc = self.pressure_rise(flow_m3_s);
|
||||
|
||||
// Get port pressures
|
||||
let p_in = self.port_inlet.pressure().to_pascals();
|
||||
let p_out = self.port_outlet.pressure().to_pascals();
|
||||
let delta_p_actual = p_out - p_in;
|
||||
|
||||
// Residual 0: Pressure balance
|
||||
residuals[0] = delta_p_calc - delta_p_actual;
|
||||
|
||||
// Residual 1: Power balance
|
||||
residuals[0] = state[out_p] - (state[in_p] + delta_p_calc);
|
||||
let power_calc = self.hydraulic_power(flow_m3_s).to_watts();
|
||||
residuals[1] = power_calc - _power_w;
|
||||
let enthalpy_rise_j_kg = if mass_flow_kg_s.abs() > 1e-12 {
|
||||
power_calc / mass_flow_kg_s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
residuals[1] = state[out_h] - (state[in_h] + enthalpy_rise_j_kg);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -530,14 +565,29 @@ impl Component for Pump<Connected> {
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
if state.len() < 2 {
|
||||
let (in_m, in_p, in_h, out_p, out_h) = match (
|
||||
self.inlet_m_idx,
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
(Some(in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) => {
|
||||
(in_m, in_p, in_h, out_p, out_h)
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pump Jacobian requires live inlet and outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if in_m >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 2,
|
||||
expected: in_m + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mass_flow_kg_s = state[0];
|
||||
let mass_flow_kg_s = state[in_m];
|
||||
let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3;
|
||||
|
||||
// Numerical derivative of pressure with respect to mass flow
|
||||
@@ -547,10 +597,9 @@ impl Component for Pump<Connected> {
|
||||
let dp_dm = (p_plus - p_minus) / (2.0 * h);
|
||||
|
||||
// ∂r₀/∂ṁ = dΔP/dṁ
|
||||
jacobian.add_entry(0, 0, dp_dm);
|
||||
|
||||
// ∂r₀/∂P = -1 (constant)
|
||||
jacobian.add_entry(0, 1, 0.0);
|
||||
jacobian.add_entry(0, in_m, -dp_dm);
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
|
||||
// Numerical derivative of power with respect to mass flow
|
||||
let pow_plus = self
|
||||
@@ -561,11 +610,15 @@ impl Component for Pump<Connected> {
|
||||
.to_watts();
|
||||
let dpow_dm = (pow_plus - pow_minus) / (2.0 * h);
|
||||
|
||||
// ∂r₁/∂ṁ
|
||||
jacobian.add_entry(1, 0, dpow_dm);
|
||||
|
||||
// ∂r₁/∂P = -1
|
||||
jacobian.add_entry(1, 1, -1.0);
|
||||
let dh_dm = if mass_flow_kg_s.abs() > 1e-12 {
|
||||
(dpow_dm * mass_flow_kg_s - self.hydraulic_power(flow_m3_s).to_watts())
|
||||
/ (mass_flow_kg_s * mass_flow_kg_s)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
jacobian.add_entry(1, in_m, -dh_dm);
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -582,18 +635,22 @@ impl Component for Pump<Connected> {
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
if state.len() < 1 {
|
||||
let (Some(in_m), Some(out_m)) = (self.inlet_m_idx, self.outlet_m_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pump mass-flow reporting requires live inlet and outlet mass-flow indices"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let max_idx = in_m.max(out_m);
|
||||
if max_idx >= state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: 1,
|
||||
expected: max_idx + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
// Pump has inlet and outlet with same mass flow (incompressible)
|
||||
let m = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||||
// Inlet (positive = entering), Outlet (negative = leaving)
|
||||
Ok(vec![
|
||||
m,
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()),
|
||||
entropyk_core::MassFlow::from_kg_per_s(state[in_m]),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-state[out_m]),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -601,10 +658,22 @@ impl Component for Pump<Connected> {
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
// Pump uses internally simulated enthalpies
|
||||
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pump enthalpy reporting requires live inlet and outlet enthalpy indices"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
let max_idx = in_h.max(out_h);
|
||||
if max_idx >= _state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: max_idx + 1,
|
||||
actual: _state.len(),
|
||||
});
|
||||
}
|
||||
Ok(vec![
|
||||
self.port_inlet.enthalpy(),
|
||||
self.port_outlet.enthalpy(),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(_state[in_h]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(_state[out_h]),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -618,10 +687,10 @@ impl Component for Pump<Connected> {
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
)),
|
||||
OperationalState::On => {
|
||||
if state.is_empty() {
|
||||
let Some(in_m) = self.inlet_m_idx else {
|
||||
return None;
|
||||
}
|
||||
let mass_flow_kg_s = state[0];
|
||||
};
|
||||
let mass_flow_kg_s = *state.get(in_m)?;
|
||||
let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3;
|
||||
let power_calc = self.hydraulic_power(flow_m3_s).to_watts();
|
||||
Some((
|
||||
@@ -728,6 +797,12 @@ mod tests {
|
||||
speed_ratio: 1.0,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
inlet_m_idx: None,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -871,8 +946,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pump_component_compute_residuals() {
|
||||
let pump = create_test_pump_connected();
|
||||
let state = vec![50.0, 2000.0]; // mass flow, power
|
||||
let mut pump = create_test_pump_connected();
|
||||
pump.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
|
||||
let state = vec![50.0, 1.0e5, 100_000.0, 50.0, 4.0e5, 105_000.0];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
|
||||
let result = pump.compute_residuals(&state, &mut residuals);
|
||||
|
||||
@@ -104,9 +104,9 @@ impl Component for PyRefrigerantSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +195,9 @@ impl Component for PyRefrigerantSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,9 +299,9 @@ impl Component for PyBrineSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,9 +365,9 @@ impl Component for PyBrineSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,9 +463,9 @@ impl Component for PyAirSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +529,8 @@ impl Component for PyAirSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//!
|
||||
//! These components don't use the type-state pattern and can be used
|
||||
//! directly from Python bindings.
|
||||
@@ -17,8 +17,8 @@ use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
|
||||
/// Compressor with AHRI 540 performance model.
|
||||
///
|
||||
/// Equations:
|
||||
/// - Mass flow: ṁ = M1 × (1 - (P_suc/P_disc)^(1/M2)) × ρ_suc × V_disp × N/60
|
||||
/// - Power: Ẇ = M3 + M4×Pr + M5×T_suc + M6×T_disc
|
||||
/// - Mass flow: ? = M1 <EFBFBD> (1 - (P_suc/P_disc)^(1/M2)) <EFBFBD> ?_suc <EFBFBD> V_disp <EFBFBD> N/60
|
||||
/// - Power: ? = M3 + M4<EFBFBD>Pr + M5<EFBFBD>T_suc + M6<EFBFBD>T_disc
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyCompressorReal {
|
||||
/// Fluid
|
||||
@@ -112,7 +112,7 @@ impl PyCompressorReal {
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
// AHRI 540 volumetric efficiency: eta_vol = m1 - m2 * (pr - 1)
|
||||
// This stays positive for realistic pressure ratios (pr < 1 + m1/m2 = 1 + 0.85/2.5 = 1.34)
|
||||
// Use clamped version so it’s always positive.
|
||||
// Use clamped version so it<EFBFBD>s always positive.
|
||||
// Better: use simple isentropic clearance model: eta_vol = m1 * (1.0 - c*(pr^(1/gamma)-1))
|
||||
// where c = clearance ratio (~0.05), gamma = 1.15 for R134a.
|
||||
// This gives positive values across all realistic pressure ratios.
|
||||
@@ -132,7 +132,7 @@ impl PyCompressorReal {
|
||||
) -> f64 {
|
||||
// AHRI 540 power polynomial [W]: P = m3 + m4*pr + m5*T_suc[K] + m6*T_disc[K]
|
||||
// With our test coefficients: ~500 + 1500*2.86 + (-2.5)*287.5 + 1.8*322 = 500+4290-719+580 = 4651 W
|
||||
// Power is in Watts, so h_disc_calc = h_suc + P/m_dot (Pa*(m3/s)/kg = J/kg) ✓
|
||||
// Power is in Watts, so h_disc_calc = h_suc + P/m_dot (Pa*(m3/s)/kg = J/kg) ?
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
self.m3 + self.m4 * pr + self.m5 * t_suc.to_kelvin() + self.m6 * t_disc.to_kelvin()
|
||||
}
|
||||
@@ -170,16 +170,16 @@ impl Component for PyCompressorReal {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// -- <20>quations lin<EFBFBD>aires pures (pas de CoolProp) ----------------------
|
||||
// r[0] = p_disc - (p_suc + 1 MPa) gain de pression fixe
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg) travail spécifique isentropique mock
|
||||
// Ces constantes doivent être cohérentes avec la vanne (target_dp=1 MPa)
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg) travail sp<EFBFBD>cifique isentropique mock
|
||||
// Ces constantes doivent <EFBFBD>tre coh<EFBFBD>rentes avec la vanne (target_dp=1 MPa)
|
||||
let p_suc = state[in_idx.0];
|
||||
let h_suc = state[in_idx.1];
|
||||
let p_disc = state[out_idx.0];
|
||||
let h_disc = state[out_idx.1];
|
||||
|
||||
// ── Point 1 : Physique réelle AHRI pour Enthalpie ──
|
||||
// -- Point 1 : Physique r<EFBFBD>elle AHRI pour Enthalpie --
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
|
||||
let suc_state = backend
|
||||
@@ -216,7 +216,7 @@ impl Component for PyCompressorReal {
|
||||
|
||||
let h_disc_calc = h_suc + power / m_dot.max(0.001);
|
||||
|
||||
// Résidus : DeltaP coordonné avec la vanne pour fermer la boucle HP
|
||||
// R<EFBFBD>sidus : DeltaP coordonn<EFBFBD> avec la vanne pour fermer la boucle HP
|
||||
residuals[0] = p_disc - (p_suc + 1_000_000.0); // +1 MPa
|
||||
residuals[1] = h_disc - h_disc_calc;
|
||||
|
||||
@@ -246,9 +246,12 @@ impl Component for PyCompressorReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +323,8 @@ impl Component for PyExpansionValveReal {
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
// ── Point 2 : Expansion Isenthalpique avec DeltaP coordonné ──
|
||||
residuals[0] = p_out - (p_in - 1_000_000.0); // -1 MPa (coordonné avec le compresseur)
|
||||
// -- Point 2 : Expansion Isenthalpique avec DeltaP coordonn<EFBFBD> --
|
||||
residuals[0] = p_out - (p_in - 1_000_000.0); // -1 MPa (coordonn<EFBFBD> avec le compresseur)
|
||||
residuals[1] = h_out - h_in;
|
||||
|
||||
Ok(())
|
||||
@@ -350,9 +353,12 @@ impl Component for PyExpansionValveReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +368,7 @@ impl Component for PyExpansionValveReal {
|
||||
|
||||
/// Heat exchanger with refrigerant and water sides.
|
||||
///
|
||||
/// Uses ε-NTU method for heat transfer.
|
||||
/// Uses e-NTU method for heat transfer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyHeatExchangerReal {
|
||||
/// Name
|
||||
@@ -457,21 +463,21 @@ impl Component for PyHeatExchangerReal {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// Pour ancrer le cycle (éviter la jacobienne singulière par indétermination),
|
||||
// on force l'évaporateur à une sortie fixe.
|
||||
// -- <20>quations lin<EFBFBD>aires pures (pas de CoolProp) ----------------------
|
||||
// Pour ancrer le cycle (<EFBFBD>viter la jacobienne singuli<EFBFBD>re par ind<EFBFBD>termination),
|
||||
// on force l'<EFBFBD>vaporateur <EFBFBD> une sortie fixe.
|
||||
let p_ref = Pressure::from_pascals(state[in_idx.0]);
|
||||
let h_ref_in = Enthalpy::from_joules_per_kg(state[in_idx.1]);
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
if self.is_evaporator {
|
||||
// ── POINT D'ANCRAGE (GROUND NODE) ──────────────────────────────
|
||||
// L'évaporateur force un point absolu pour lever l'indétermination.
|
||||
residuals[0] = p_out - 350_000.0; // Fixe la BP à 3.5 bar
|
||||
residuals[1] = h_out - 410_000.0; // Fixe la Surchauffe (approx) à 410 kJ/kg
|
||||
// -- POINT D'ANCRAGE (GROUND NODE) ------------------------------
|
||||
// L'<EFBFBD>vaporateur force un point absolu pour lever l'ind<EFBFBD>termination.
|
||||
residuals[0] = p_out - 350_000.0; // Fixe la BP <EFBFBD> 3.5 bar
|
||||
residuals[1] = h_out - 410_000.0; // Fixe la Surchauffe (approx) <EFBFBD> 410 kJ/kg
|
||||
} else {
|
||||
// ── Physique réelle ε-NTU pour le Condenseur ────────────────────
|
||||
// -- Physique r<EFBFBD>elle e-NTU pour le Condenseur --------------------
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let ref_state = backend
|
||||
.full_state(self.fluid.clone(), p_ref, h_ref_in)
|
||||
@@ -482,17 +488,17 @@ impl Component for PyHeatExchangerReal {
|
||||
let t_ref_k = ref_state.temperature.to_kelvin();
|
||||
let q_max = c_water * (self.water_inlet_temp.to_kelvin() - t_ref_k).abs();
|
||||
|
||||
let c_ref = 5000.0; // Augmenté pour simuler la condensation (Cp latent dominant)
|
||||
let c_ref = 5000.0; // Augment<EFBFBD> pour simuler la condensation (Cp latent dominant)
|
||||
let c_min = c_water.min(c_ref);
|
||||
let c_max = c_water.max(c_ref);
|
||||
let ntu = self.ua / c_min.max(1.0);
|
||||
let effectiveness = self.compute_effectiveness(c_min, c_max, ntu);
|
||||
let q = effectiveness * q_max;
|
||||
|
||||
// On utilise un m_dot_ref plus réaliste (0.06 kg/s d'après AHRI)
|
||||
// On utilise un m_dot_ref plus r<EFBFBD>aliste (0.06 kg/s d'apr<EFBFBD>s AHRI)
|
||||
let m_dot_ref = 0.06;
|
||||
|
||||
// On sature le delta_h pour éviter les enthalpies négatives absurdes
|
||||
// On sature le delta_h pour <EFBFBD>viter les enthalpies n<EFBFBD>gatives absurdes
|
||||
// Le but ici est de valider le comportement du solveur sur une plage physique.
|
||||
let delta_h = (q / m_dot_ref).min(300_000.0); // Max 300 kJ/kg de rejet
|
||||
let h_out_calc = h_ref_in.to_joules_per_kg() - delta_h;
|
||||
@@ -527,9 +533,12 @@ impl Component for PyHeatExchangerReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
@@ -646,9 +655,12 @@ impl Component for PyPipeReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,9 +753,12 @@ impl Component for PyFlowSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,9 +797,12 @@ impl Component for PyFlowSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,9 +887,12 @@ impl Component for PyFlowSplitterReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,11 +991,13 @@ impl Component for PyFlowMergerReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
|
||||
}
|
||||
// =============================================================================
|
||||
// Python Boundary Types (Refrigerant, Brine, Air)
|
||||
@@ -999,18 +1022,22 @@ impl Component for PyFlowMergerReal {
|
||||
/// where `h(P, x)` is computed via linear interpolation in the two-phase region.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSourceReal {
|
||||
/// Refrigerant fluid identifier.
|
||||
pub fluid: FluidId,
|
||||
/// Imposed source pressure [Pa].
|
||||
pub p_set_pa: f64,
|
||||
/// Imposed source quality [-].
|
||||
pub quality: f64,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSourceReal {
|
||||
/// Create a new refrigerant source.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier, e.g. `"R410A"`.
|
||||
/// * `p_set_pa` – Imposed outlet pressure [Pa].
|
||||
/// * `quality` – Vapor quality at outlet (0 = saturated liquid, 1 = saturated vapour).
|
||||
/// * `fluid` <EFBFBD> CoolProp fluid identifier, e.g. `"R410A"`.
|
||||
/// * `p_set_pa` <EFBFBD> Imposed outlet pressure [Pa].
|
||||
/// * `quality` <EFBFBD> Vapor quality at outlet (0 = saturated liquid, 1 = saturated vapour).
|
||||
pub fn new(fluid: &str, p_set_pa: f64, quality: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
@@ -1057,7 +1084,7 @@ impl Component for PyRefrigerantSourceReal {
|
||||
let p_edge = state[p_idx];
|
||||
let h_edge = state[h_idx];
|
||||
|
||||
// Use tabular backend (no fluid backend stored — use CoolProp via fluids)
|
||||
// Use tabular backend (no fluid backend stored <EFBFBD> use CoolProp via fluids)
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let h_set = self.enthalpy_from_quality(&backend)?;
|
||||
|
||||
@@ -1081,9 +1108,12 @@ impl Component for PyRefrigerantSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1094,18 +1124,22 @@ impl Component for PyRefrigerantSourceReal {
|
||||
/// Python-friendly refrigerant sink: imposes back-pressure (and optional quality).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyRefrigerantSinkReal {
|
||||
/// Refrigerant fluid identifier.
|
||||
pub fluid: FluidId,
|
||||
/// Imposed back-pressure [Pa].
|
||||
pub p_back_pa: f64,
|
||||
/// Optional imposed quality [-]; `None` leaves enthalpy free.
|
||||
pub quality_opt: Option<f64>,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyRefrigerantSinkReal {
|
||||
/// Create a new refrigerant sink.
|
||||
///
|
||||
/// * `fluid` – CoolProp fluid identifier.
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `quality_opt` – Optional vapor quality to fix enthalpy; `None` means free enthalpy.
|
||||
/// * `fluid` <EFBFBD> CoolProp fluid identifier.
|
||||
/// * `p_back_pa` <EFBFBD> Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `quality_opt` <EFBFBD> Optional vapor quality to fix enthalpy; `None` means free enthalpy.
|
||||
pub fn new(fluid: &str, p_back_pa: f64, quality_opt: Option<f64>) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
@@ -1172,9 +1206,12 @@ impl Component for PyRefrigerantSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,20 +1222,25 @@ impl Component for PyRefrigerantSinkReal {
|
||||
/// Python-friendly brine source: imposes pressure + temperature + concentration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSourceReal {
|
||||
/// Brine fluid identifier.
|
||||
pub fluid: FluidId,
|
||||
/// Mass/volume concentration of the secondary fluid [-].
|
||||
pub concentration: f64,
|
||||
/// Imposed source temperature [K].
|
||||
pub temperature_k: f64,
|
||||
/// Imposed source pressure [Pa].
|
||||
pub pressure_pa: f64,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSourceReal {
|
||||
/// Create a new brine source.
|
||||
///
|
||||
/// * `fluid` – Base fluid, e.g. `"MEG"`, `"EthyleneGlycol"`, `"Water"`.
|
||||
/// * `concentration` – Glycol mass fraction \[0, 1\].
|
||||
/// * `temperature_k` – Outlet temperature [K].
|
||||
/// * `pressure_pa` – Outlet pressure [Pa].
|
||||
/// * `fluid` <EFBFBD> Base fluid, e.g. `"MEG"`, `"EthyleneGlycol"`, `"Water"`.
|
||||
/// * `concentration` <EFBFBD> Glycol mass fraction \[0, 1\].
|
||||
/// * `temperature_k` <EFBFBD> Outlet temperature [K].
|
||||
/// * `pressure_pa` <EFBFBD> Outlet pressure [Pa].
|
||||
pub fn new(fluid: &str, concentration: f64, temperature_k: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidId::new(fluid),
|
||||
@@ -1230,9 +1272,7 @@ impl PyBrineSourceReal {
|
||||
let fstate = FState::from_pt(p, t);
|
||||
backend
|
||||
.property(fid, Property::Enthalpy, fstate)
|
||||
.map_err(|e| {
|
||||
ComponentError::CalculationFailed(format!("BrineSource: enthalpy: {}", e))
|
||||
})
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("BrineSource: enthalpy: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,9 +1316,12 @@ impl Component for PyBrineSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,14 +1332,16 @@ impl Component for PyBrineSourceReal {
|
||||
/// Python-friendly brine sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyBrineSinkReal {
|
||||
/// Imposed back-pressure [Pa].
|
||||
pub p_back_pa: f64,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyBrineSinkReal {
|
||||
/// Create a new brine sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `p_back_pa` <EFBFBD> Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
@@ -1342,9 +1387,12 @@ impl Component for PyBrineSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1363,18 +1411,22 @@ impl Component for PyBrineSinkReal {
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSourceReal {
|
||||
/// Imposed dry-bulb temperature [K].
|
||||
pub temperature_k: f64,
|
||||
/// Imposed relative humidity [-] (0..1).
|
||||
pub relative_humidity: f64,
|
||||
/// Imposed atmospheric pressure [Pa].
|
||||
pub pressure_pa: f64,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSourceReal {
|
||||
/// Create a new air source.
|
||||
///
|
||||
/// * `temperature_k` – Dry-bulb temperature [K].
|
||||
/// * `relative_humidity` – Relative humidity \[0, 1\].
|
||||
/// * `pressure_pa` – Total atmospheric pressure [Pa].
|
||||
/// * `temperature_k` <EFBFBD> Dry-bulb temperature [K].
|
||||
/// * `relative_humidity` <EFBFBD> Relative humidity \[0, 1\].
|
||||
/// * `pressure_pa` <EFBFBD> Total atmospheric pressure [Pa].
|
||||
pub fn new(temperature_k: f64, relative_humidity: f64, pressure_pa: f64) -> Self {
|
||||
Self {
|
||||
temperature_k,
|
||||
@@ -1440,9 +1492,12 @@ impl Component for PyAirSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1453,14 +1508,16 @@ impl Component for PyAirSourceReal {
|
||||
/// Python-friendly air sink: imposes back-pressure on the inlet edge.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyAirSinkReal {
|
||||
/// Imposed back-pressure [Pa].
|
||||
pub p_back_pa: f64,
|
||||
/// Connected edge state indices as `(pressure_idx, enthalpy_idx)` pairs.
|
||||
pub edge_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PyAirSinkReal {
|
||||
/// Create a new air sink.
|
||||
///
|
||||
/// * `p_back_pa` – Back-pressure imposed on the inlet edge [Pa].
|
||||
/// * `p_back_pa` <EFBFBD> Back-pressure imposed on the inlet edge [Pa].
|
||||
pub fn new(p_back_pa: f64) -> Self {
|
||||
Self {
|
||||
p_back_pa,
|
||||
@@ -1506,8 +1563,11 @@ impl Component for PyAirSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices
|
||||
.iter()
|
||||
.map(|&(_, p, h)| (p, h))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//! Python-friendly thermodynamic components with real physics.
|
||||
//!
|
||||
//! These components don't use the type-state pattern and can be used
|
||||
//! directly from Python bindings.
|
||||
@@ -17,8 +17,8 @@ use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
|
||||
/// Compressor with AHRI 540 performance model.
|
||||
///
|
||||
/// Equations:
|
||||
/// - Mass flow: ṁ = M1 × (1 - (P_suc/P_disc)^(1/M2)) × ρ_suc × V_disp × N/60
|
||||
/// - Power: Ẇ = M3 + M4×Pr + M5×T_suc + M6×T_disc
|
||||
/// - Mass flow: ? = M1 × (1 - (P_suc/P_disc)^(1/M2)) × ?_suc × V_disp × N/60
|
||||
/// - Power: ? = M3 + M4×Pr + M5×T_suc + M6×T_disc
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyCompressorReal {
|
||||
/// Fluid
|
||||
@@ -112,7 +112,7 @@ impl PyCompressorReal {
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
// AHRI 540 volumetric efficiency: eta_vol = m1 - m2 * (pr - 1)
|
||||
// This stays positive for realistic pressure ratios (pr < 1 + m1/m2 = 1 + 0.85/2.5 = 1.34)
|
||||
// Use clamped version so it’s always positive.
|
||||
// Use clamped version so it’s always positive.
|
||||
// Better: use simple isentropic clearance model: eta_vol = m1 * (1.0 - c*(pr^(1/gamma)-1))
|
||||
// where c = clearance ratio (~0.05), gamma = 1.15 for R134a.
|
||||
// This gives positive values across all realistic pressure ratios.
|
||||
@@ -132,7 +132,7 @@ impl PyCompressorReal {
|
||||
) -> f64 {
|
||||
// AHRI 540 power polynomial [W]: P = m3 + m4*pr + m5*T_suc[K] + m6*T_disc[K]
|
||||
// With our test coefficients: ~500 + 1500*2.86 + (-2.5)*287.5 + 1.8*322 = 500+4290-719+580 = 4651 W
|
||||
// Power is in Watts, so h_disc_calc = h_suc + P/m_dot (Pa*(m3/s)/kg = J/kg) ✓
|
||||
// Power is in Watts, so h_disc_calc = h_suc + P/m_dot (Pa*(m3/s)/kg = J/kg) ?
|
||||
let pr = (p_disc.to_pascals() / p_suc.to_pascals().max(1.0)).max(1.0);
|
||||
self.m3 + self.m4 * pr + self.m5 * t_suc.to_kelvin() + self.m6 * t_disc.to_kelvin()
|
||||
}
|
||||
@@ -170,16 +170,16 @@ impl Component for PyCompressorReal {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// -- Équations linéaires pures (pas de CoolProp) ----------------------
|
||||
// r[0] = p_disc - (p_suc + 1 MPa) gain de pression fixe
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg) travail spécifique isentropique mock
|
||||
// Ces constantes doivent être cohérentes avec la vanne (target_dp=1 MPa)
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg) travail spécifique isentropique mock
|
||||
// Ces constantes doivent être cohérentes avec la vanne (target_dp=1 MPa)
|
||||
let p_suc = state[in_idx.0];
|
||||
let h_suc = state[in_idx.1];
|
||||
let p_disc = state[out_idx.0];
|
||||
let h_disc = state[out_idx.1];
|
||||
|
||||
// ── Point 1 : Physique réelle AHRI pour Enthalpie ──
|
||||
// -- Point 1 : Physique réelle AHRI pour Enthalpie --
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
|
||||
let suc_state = backend
|
||||
@@ -216,7 +216,7 @@ impl Component for PyCompressorReal {
|
||||
|
||||
let h_disc_calc = h_suc + power / m_dot.max(0.001);
|
||||
|
||||
// Résidus : DeltaP coordonné avec la vanne pour fermer la boucle HP
|
||||
// Résidus : DeltaP coordonné avec la vanne pour fermer la boucle HP
|
||||
residuals[0] = p_disc - (p_suc + 1_000_000.0); // +1 MPa
|
||||
residuals[1] = h_disc - h_disc_calc;
|
||||
|
||||
@@ -246,9 +246,9 @@ impl Component for PyCompressorReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ impl Component for PyExpansionValveReal {
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
// ── Point 2 : Expansion Isenthalpique avec DeltaP coordonné ──
|
||||
residuals[0] = p_out - (p_in - 1_000_000.0); // -1 MPa (coordonné avec le compresseur)
|
||||
// -- Point 2 : Expansion Isenthalpique avec DeltaP coordonné --
|
||||
residuals[0] = p_out - (p_in - 1_000_000.0); // -1 MPa (coordonné avec le compresseur)
|
||||
residuals[1] = h_out - h_in;
|
||||
|
||||
Ok(())
|
||||
@@ -350,9 +350,9 @@ impl Component for PyExpansionValveReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ impl Component for PyExpansionValveReal {
|
||||
|
||||
/// Heat exchanger with refrigerant and water sides.
|
||||
///
|
||||
/// Uses ε-NTU method for heat transfer.
|
||||
/// Uses e-NTU method for heat transfer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PyHeatExchangerReal {
|
||||
/// Name
|
||||
@@ -457,21 +457,21 @@ impl Component for PyHeatExchangerReal {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Équations linéaires pures (pas de CoolProp) ──────────────────────
|
||||
// Pour ancrer le cycle (éviter la jacobienne singulière par indétermination),
|
||||
// on force l'évaporateur à une sortie fixe.
|
||||
// -- Équations linéaires pures (pas de CoolProp) ----------------------
|
||||
// Pour ancrer le cycle (éviter la jacobienne singulière par indétermination),
|
||||
// on force l'évaporateur à une sortie fixe.
|
||||
let p_ref = Pressure::from_pascals(state[in_idx.0]);
|
||||
let h_ref_in = Enthalpy::from_joules_per_kg(state[in_idx.1]);
|
||||
let p_out = state[out_idx.0];
|
||||
let h_out = state[out_idx.1];
|
||||
|
||||
if self.is_evaporator {
|
||||
// ── POINT D'ANCRAGE (GROUND NODE) ──────────────────────────────
|
||||
// L'évaporateur force un point absolu pour lever l'indétermination.
|
||||
residuals[0] = p_out - 350_000.0; // Fixe la BP à 3.5 bar
|
||||
residuals[1] = h_out - 410_000.0; // Fixe la Surchauffe (approx) à 410 kJ/kg
|
||||
// -- POINT D'ANCRAGE (GROUND NODE) ------------------------------
|
||||
// L'évaporateur force un point absolu pour lever l'indétermination.
|
||||
residuals[0] = p_out - 350_000.0; // Fixe la BP à 3.5 bar
|
||||
residuals[1] = h_out - 410_000.0; // Fixe la Surchauffe (approx) à 410 kJ/kg
|
||||
} else {
|
||||
// ── Physique réelle ε-NTU pour le Condenseur ────────────────────
|
||||
// -- Physique réelle e-NTU pour le Condenseur --------------------
|
||||
let backend = entropyk_fluids::CoolPropBackend::new();
|
||||
let ref_state = backend
|
||||
.full_state(self.fluid.clone(), p_ref, h_ref_in)
|
||||
@@ -482,17 +482,17 @@ impl Component for PyHeatExchangerReal {
|
||||
let t_ref_k = ref_state.temperature.to_kelvin();
|
||||
let q_max = c_water * (self.water_inlet_temp.to_kelvin() - t_ref_k).abs();
|
||||
|
||||
let c_ref = 5000.0; // Augmenté pour simuler la condensation (Cp latent dominant)
|
||||
let c_ref = 5000.0; // Augmenté pour simuler la condensation (Cp latent dominant)
|
||||
let c_min = c_water.min(c_ref);
|
||||
let c_max = c_water.max(c_ref);
|
||||
let ntu = self.ua / c_min.max(1.0);
|
||||
let effectiveness = self.compute_effectiveness(c_min, c_max, ntu);
|
||||
let q = effectiveness * q_max;
|
||||
|
||||
// On utilise un m_dot_ref plus réaliste (0.06 kg/s d'après AHRI)
|
||||
// On utilise un m_dot_ref plus réaliste (0.06 kg/s d'après AHRI)
|
||||
let m_dot_ref = 0.06;
|
||||
|
||||
// On sature le delta_h pour éviter les enthalpies négatives absurdes
|
||||
// On sature le delta_h pour éviter les enthalpies négatives absurdes
|
||||
// Le but ici est de valider le comportement du solveur sur une plage physique.
|
||||
let delta_h = (q / m_dot_ref).min(300_000.0); // Max 300 kJ/kg de rejet
|
||||
let h_out_calc = h_ref_in.to_joules_per_kg() - delta_h;
|
||||
@@ -527,9 +527,9 @@ impl Component for PyHeatExchangerReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: CalibIndices) {
|
||||
@@ -646,9 +646,9 @@ impl Component for PyPipeReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,9 +741,9 @@ impl Component for PyFlowSourceReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,9 +782,9 @@ impl Component for PyFlowSinkReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,9 +869,9 @@ impl Component for PyFlowSplitterReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ impl Component for PyFlowMergerReal {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.edge_indices = external_edge_state_indices.to_vec();
|
||||
self.edge_indices = external_edge_state_indices.iter().map(|&(_, p, h)| (p, h)).collect();
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ pub struct RefrigerantSource {
|
||||
h_set_jkg: f64,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
outlet: ConnectedPort,
|
||||
outlet_m_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
m_flow_set_kg_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl RefrigerantSource {
|
||||
@@ -163,9 +167,25 @@ impl RefrigerantSource {
|
||||
h_set_jkg: h_set.to_joules_per_kg(),
|
||||
backend,
|
||||
outlet,
|
||||
outlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
m_flow_set_kg_s: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imposes a fixed mass flow on the outlet edge (BOLT `Vd_fixed=true`).
|
||||
/// Adds one equation `ṁ_edge − ṁ_set = 0`, bringing `n_equations` to 3.
|
||||
pub fn with_imposed_mass_flow(mut self, m_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if m_flow_kg_s <= 0.0 || !m_flow_kg_s.is_finite() {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"RefrigerantSource: mass flow must be positive and finite, got {m_flow_kg_s}"
|
||||
)));
|
||||
}
|
||||
self.m_flow_set_kg_s = Some(m_flow_kg_s);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Returns the refrigerant fluid identifier.
|
||||
pub fn fluid_id(&self) -> &str {
|
||||
&self.fluid_id
|
||||
@@ -233,22 +253,61 @@ impl RefrigerantSource {
|
||||
|
||||
impl Component for RefrigerantSource {
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
2 + usize::from(self.m_flow_set_kg_s.is_some())
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
if let Some(&(m, p, h)) = external_edge_state_indices.last() {
|
||||
self.outlet_m_idx = Some(m);
|
||||
self.outlet_p_idx = Some(p);
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < 2 {
|
||||
let n = self.n_equations();
|
||||
if residuals.len() < n {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
expected: n,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
match (self.outlet_p_idx, self.outlet_h_idx) {
|
||||
(Some(p_idx), Some(h_idx)) if p_idx < state.len() && h_idx < state.len() => {
|
||||
residuals[0] = state[p_idx] - self.p_set_pa;
|
||||
residuals[1] = state[h_idx] - self.h_set_jkg;
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
let Some(m_idx) = self.outlet_m_idx.filter(|&i| i < state.len()) else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"RefrigerantSource imposed mass flow requires a live outlet mass-flow index"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
residuals[2] = state[m_idx] - m_set;
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa;
|
||||
residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
|
||||
if let Some(m_set) = self.m_flow_set_kg_s {
|
||||
residuals[2] = -m_set;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"RefrigerantSource requires a live outlet edge state in solver context"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -257,8 +316,32 @@ impl Component for RefrigerantSource {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
let (Some(p_idx), Some(h_idx)) = (self.outlet_p_idx, self.outlet_h_idx) else {
|
||||
if _state.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"RefrigerantSource Jacobian requires live outlet pressure/enthalpy indices"
|
||||
.to_string(),
|
||||
));
|
||||
};
|
||||
if p_idx >= _state.len() || h_idx >= _state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: p_idx.max(h_idx) + 1,
|
||||
actual: _state.len(),
|
||||
});
|
||||
}
|
||||
jacobian.add_entry(0, p_idx, 1.0);
|
||||
jacobian.add_entry(1, h_idx, 1.0);
|
||||
if let (Some(_), Some(m_idx)) = (self.m_flow_set_kg_s, self.outlet_m_idx) {
|
||||
if m_idx >= _state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: m_idx + 1,
|
||||
actual: _state.len(),
|
||||
});
|
||||
}
|
||||
jacobian.add_entry(2, m_idx, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -346,6 +429,8 @@ pub struct RefrigerantSink {
|
||||
h_back_jkg: Option<f64>,
|
||||
backend: Arc<dyn FluidBackend>,
|
||||
inlet: ConnectedPort,
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl RefrigerantSink {
|
||||
@@ -402,6 +487,8 @@ impl RefrigerantSink {
|
||||
h_back_jkg,
|
||||
backend,
|
||||
inlet,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -493,9 +580,20 @@ impl Component for RefrigerantSink {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
if let Some(&(_, p, h)) = external_edge_state_indices.first() {
|
||||
self.inlet_p_idx = Some(p);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
@@ -505,9 +603,27 @@ impl Component for RefrigerantSink {
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[1] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
match self.inlet_p_idx {
|
||||
Some(p_idx) if p_idx < state.len() => {
|
||||
residuals[0] = state[p_idx] - self.p_back_pa;
|
||||
if let (Some(h_back), Some(h_idx)) = (self.h_back_jkg, self.inlet_h_idx) {
|
||||
if h_idx < state.len() {
|
||||
residuals[1] = state[h_idx] - h_back;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if state.is_empty() => {
|
||||
residuals[0] = self.inlet.pressure().to_pascals() - self.p_back_pa;
|
||||
if let Some(h_back) = self.h_back_jkg {
|
||||
residuals[1] = self.inlet.enthalpy().to_joules_per_kg() - h_back;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"RefrigerantSink requires a live inlet edge state in solver context"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -517,9 +633,29 @@ impl Component for RefrigerantSink {
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n = self.n_equations();
|
||||
for i in 0..n {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
let Some(p_idx) = self.inlet_p_idx else {
|
||||
if _state.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"RefrigerantSink Jacobian requires a live inlet pressure index".to_string(),
|
||||
));
|
||||
};
|
||||
if p_idx >= _state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: p_idx + 1,
|
||||
actual: _state.len(),
|
||||
});
|
||||
}
|
||||
jacobian.add_entry(0, p_idx, 1.0);
|
||||
if let Some(h_idx) = self.inlet_h_idx {
|
||||
if h_idx >= _state.len() {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: h_idx + 1,
|
||||
actual: _state.len(),
|
||||
});
|
||||
}
|
||||
jacobian.add_entry(1, h_idx, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -571,7 +707,7 @@ mod tests {
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::Temperature;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidBackend, FluidError, FluidResult, FluidState, Phase, Property, Quality,
|
||||
CriticalPoint, FluidBackend, FluidError, FluidResult, FluidState, Phase, Property,
|
||||
};
|
||||
|
||||
/// Mock refrigerant backend for unit testing.
|
||||
@@ -757,7 +893,7 @@ mod tests {
|
||||
let h_jkg = 420_000.0;
|
||||
let p_pa = 8.5e5;
|
||||
let port = make_port("R410A", p_pa, h_jkg);
|
||||
let source = RefrigerantSource::new(
|
||||
let mut source = RefrigerantSource::new(
|
||||
"R410A",
|
||||
Pressure::from_pascals(p_pa),
|
||||
VaporQuality::SATURATED_VAPOR,
|
||||
@@ -766,7 +902,10 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let state = vec![0.0; 4];
|
||||
source.set_system_context(0, &[(0, 1, 2)]);
|
||||
let mut state = vec![0.0; 4];
|
||||
state[1] = p_pa;
|
||||
state[2] = h_jkg;
|
||||
let mut residuals = vec![0.0; 2];
|
||||
source.compute_residuals(&state, &mut residuals).unwrap();
|
||||
|
||||
@@ -890,10 +1029,13 @@ mod tests {
|
||||
let backend = Arc::new(MockRefrigerantBackend::new());
|
||||
let p_pa = 24.0e5;
|
||||
let port = make_port("R410A", p_pa, 465_000.0);
|
||||
let sink = RefrigerantSink::new("R410A", Pressure::from_pascals(p_pa), None, backend, port)
|
||||
.unwrap();
|
||||
let mut sink =
|
||||
RefrigerantSink::new("R410A", Pressure::from_pascals(p_pa), None, backend, port)
|
||||
.unwrap();
|
||||
|
||||
let state = vec![0.0; 4];
|
||||
sink.set_system_context(0, &[(0, 1, 2)]);
|
||||
let mut state = vec![0.0; 4];
|
||||
state[1] = p_pa;
|
||||
let mut residuals = vec![0.0; 1];
|
||||
sink.compute_residuals(&state, &mut residuals).unwrap();
|
||||
|
||||
|
||||
@@ -15,9 +15,21 @@ pub enum RegistryError {
|
||||
/// Unknown or unsupported component type
|
||||
UnknownComponentType(String),
|
||||
/// Missing required parameter
|
||||
MissingParameter { component: String, parameter: String },
|
||||
MissingParameter {
|
||||
/// Component type that is missing the parameter.
|
||||
component: String,
|
||||
/// Name of the missing parameter.
|
||||
parameter: String,
|
||||
},
|
||||
/// Invalid parameter value
|
||||
InvalidParameter { component: String, parameter: String, reason: String },
|
||||
InvalidParameter {
|
||||
/// Component type that received the invalid parameter.
|
||||
component: String,
|
||||
/// Name of the invalid parameter.
|
||||
parameter: String,
|
||||
/// Human-readable reason the parameter value is invalid.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RegistryError {
|
||||
@@ -26,11 +38,26 @@ impl std::fmt::Display for RegistryError {
|
||||
RegistryError::UnknownComponentType(t) => {
|
||||
write!(f, "Unknown component type: '{}'", t)
|
||||
}
|
||||
RegistryError::MissingParameter { component, parameter } => {
|
||||
write!(f, "Missing parameter '{}' for component type '{}'", parameter, component)
|
||||
RegistryError::MissingParameter {
|
||||
component,
|
||||
parameter,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Missing parameter '{}' for component type '{}'",
|
||||
parameter, component
|
||||
)
|
||||
}
|
||||
RegistryError::InvalidParameter { component, parameter, reason } => {
|
||||
write!(f, "Invalid parameter '{}' for component type '{}': {}", parameter, component, reason)
|
||||
RegistryError::InvalidParameter {
|
||||
component,
|
||||
parameter,
|
||||
reason,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Invalid parameter '{}' for component type '{}': {}",
|
||||
parameter, component, reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +90,11 @@ fn get_f64_or(params: &ComponentParams, key: &str, default: f64) -> f64 {
|
||||
params.get(key).and_then(|v| v.as_f64()).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn get_positive_usize(params: &ComponentParams, key: &str, default: usize) -> Result<usize, RegistryError> {
|
||||
fn get_positive_usize(
|
||||
params: &ComponentParams,
|
||||
key: &str,
|
||||
default: usize,
|
||||
) -> Result<usize, RegistryError> {
|
||||
let val = get_f64_or(params, key, default as f64);
|
||||
if val < 1.0 || val > 100.0 || val.fract() != 0.0 {
|
||||
return Err(RegistryError::InvalidParameter {
|
||||
@@ -76,14 +107,23 @@ fn get_positive_usize(params: &ComponentParams, key: &str, default: usize) -> Re
|
||||
}
|
||||
|
||||
fn get_string_or(params: &ComponentParams, key: &str, default: &str) -> String {
|
||||
params.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| default.to_string())
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn deserialize_field<T: serde::de::DeserializeOwned>(params: &ComponentParams, key: &str) -> Result<T, RegistryError> {
|
||||
let val = params.get(key).ok_or_else(|| RegistryError::MissingParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
})?;
|
||||
fn deserialize_field<T: serde::de::DeserializeOwned>(
|
||||
params: &ComponentParams,
|
||||
key: &str,
|
||||
) -> Result<T, RegistryError> {
|
||||
let val = params
|
||||
.get(key)
|
||||
.ok_or_else(|| RegistryError::MissingParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
})?;
|
||||
serde_json::from_value(val.clone()).map_err(|e| RegistryError::InvalidParameter {
|
||||
component: params.component_type.clone(),
|
||||
parameter: key.to_string(),
|
||||
@@ -94,19 +134,37 @@ fn deserialize_field<T: serde::de::DeserializeOwned>(params: &ComponentParams, k
|
||||
fn default_disconnected_port(fluid: &str) -> crate::port::Port<crate::port::Disconnected> {
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
Port::new(FluidId::new(fluid), Pressure::from_bar(2.0), Enthalpy::from_joules_per_kg(400_000.0))
|
||||
Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_bar(2.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_connected_port(fluid: &str, p_pa: f64, h_jkg: f64) -> crate::ConnectedPort {
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
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).expect("port connect with matching params should succeed").0
|
||||
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)
|
||||
.expect("port connect with matching params should succeed")
|
||||
.0
|
||||
}
|
||||
|
||||
fn reg_err(comp: &str, param: &str, e: impl std::fmt::Display) -> RegistryError {
|
||||
RegistryError::InvalidParameter { component: comp.to_string(), parameter: param.to_string(), reason: e.to_string() }
|
||||
RegistryError::InvalidParameter {
|
||||
component: comp.to_string(),
|
||||
parameter: param.to_string(),
|
||||
reason: e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a component from its serialized parameters.
|
||||
@@ -138,8 +196,12 @@ pub fn create_component(params: &ComponentParams) -> Result<Box<dyn Component>,
|
||||
"FloodedCondenser" => create_flooded_condenser(params),
|
||||
"CondenserCoil" => create_condenser_coil(params),
|
||||
"EvaporatorCoil" => create_evaporator_coil(params),
|
||||
"ScrewEconomizerCompressor" => create_screw_compressor(params),
|
||||
_ => Err(RegistryError::UnknownComponentType(params.component_type.clone())),
|
||||
"ScrewEconomizerCompressor" | "ScrewCompressor" => create_screw_compressor(params),
|
||||
"CapillaryTube" => create_capillary_tube(params),
|
||||
"CentrifugalCompressor" => create_centrifugal_compressor(params),
|
||||
_ => Err(RegistryError::UnknownComponentType(
|
||||
params.component_type.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,18 +214,44 @@ fn create_compressor(params: &ComponentParams) -> Result<Box<dyn Component>, Reg
|
||||
let model_type = get_string_or(params, "modelType", "Ahri540");
|
||||
let model = match model_type.as_str() {
|
||||
"Ahri540" => CompressorModel::Ahri540(Ahri540Coefficients::new(
|
||||
get_f64(params, "m1")?, get_f64(params, "m2")?, get_f64(params, "m3")?, get_f64(params, "m4")?,
|
||||
get_f64(params, "m5")?, get_f64(params, "m6")?, get_f64(params, "m7")?, get_f64(params, "m8")?,
|
||||
get_f64(params, "m9")?, get_f64(params, "m10")?,
|
||||
get_f64(params, "m1")?,
|
||||
get_f64(params, "m2")?,
|
||||
get_f64(params, "m3")?,
|
||||
get_f64(params, "m4")?,
|
||||
get_f64(params, "m5")?,
|
||||
get_f64(params, "m6")?,
|
||||
get_f64(params, "m7")?,
|
||||
get_f64(params, "m8")?,
|
||||
get_f64(params, "m9")?,
|
||||
get_f64(params, "m10")?,
|
||||
)),
|
||||
"SstSdt" => CompressorModel::SstSdt(SstSdtCoefficients::new(
|
||||
deserialize_field(params, "massFlowCurve")?,
|
||||
deserialize_field(params, "powerCurve")?,
|
||||
)),
|
||||
other => return Err(reg_err("Compressor", "modelType", format!("Unknown model type '{other}'"))),
|
||||
other => {
|
||||
return Err(reg_err(
|
||||
"Compressor",
|
||||
"modelType",
|
||||
format!("Unknown model type '{other}'"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let disc = Compressor::with_model(model, default_disconnected_port(&fluid), default_disconnected_port(&fluid), speed_rpm, displacement, mech_eff).map_err(|e| reg_err("Compressor", "constructor", e))?;
|
||||
let comp = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Compressor", "connect", e))?;
|
||||
let disc = Compressor::with_model(
|
||||
model,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
speed_rpm,
|
||||
displacement,
|
||||
mech_eff,
|
||||
)
|
||||
.map_err(|e| reg_err("Compressor", "constructor", e))?;
|
||||
let comp = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("Compressor", "connect", e))?;
|
||||
Ok(Box::new(comp))
|
||||
}
|
||||
|
||||
@@ -171,33 +259,75 @@ fn create_expansion_valve(params: &ComponentParams) -> Result<Box<dyn Component>
|
||||
use crate::expansion_valve::ExpansionValve;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let opening = params.get("opening").and_then(|v| v.as_f64());
|
||||
let disc = ExpansionValve::new(default_disconnected_port(&fluid), default_disconnected_port(&fluid), opening).map_err(|e| reg_err("ExpansionValve", "constructor", e))?;
|
||||
let valve = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("ExpansionValve", "connect", e))?;
|
||||
let disc = ExpansionValve::new(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
opening,
|
||||
)
|
||||
.map_err(|e| reg_err("ExpansionValve", "constructor", e))?;
|
||||
let valve = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("ExpansionValve", "connect", e))?;
|
||||
Ok(Box::new(valve))
|
||||
}
|
||||
|
||||
fn create_pump(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::pump::Pump;
|
||||
use crate::polynomials::PerformanceCurves;
|
||||
use crate::pump::Pump;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let density = get_f64_or(params, "fluidDensityKgPerM3", 1000.0);
|
||||
let speed_ratio = get_f64_or(params, "speedRatio", 1.0);
|
||||
let curves = if let Some(v) = params.get("curves") {
|
||||
let perf: PerformanceCurves = serde_json::from_value(v.clone()).map_err(|e| reg_err("Pump", "curves", e))?;
|
||||
let perf: PerformanceCurves =
|
||||
serde_json::from_value(v.clone()).map_err(|e| reg_err("Pump", "curves", e))?;
|
||||
crate::pump::PumpCurves::new(perf).map_err(|e| reg_err("Pump", "curves", e))?
|
||||
} else { crate::pump::PumpCurves::default() };
|
||||
let disc = Pump::new(curves, default_disconnected_port(&fluid), default_disconnected_port(&fluid), density).map_err(|e| reg_err("Pump", "constructor", e))?;
|
||||
let mut pump = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Pump", "connect", e))?;
|
||||
pump.set_speed_ratio(speed_ratio).map_err(|e| reg_err("Pump", "speedRatio", e))?;
|
||||
} else {
|
||||
crate::pump::PumpCurves::default()
|
||||
};
|
||||
let disc = Pump::new(
|
||||
curves,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
density,
|
||||
)
|
||||
.map_err(|e| reg_err("Pump", "constructor", e))?;
|
||||
let mut pump = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("Pump", "connect", e))?;
|
||||
pump.set_speed_ratio(speed_ratio)
|
||||
.map_err(|e| reg_err("Pump", "speedRatio", e))?;
|
||||
Ok(Box::new(pump))
|
||||
}
|
||||
|
||||
fn create_pipe(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::pipe::{Pipe, PipeGeometry};
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let geo = PipeGeometry::new(get_f64_or(params, "lengthM", 1.0), get_f64_or(params, "diameterM", 0.02), get_f64_or(params, "roughnessM", 0.000045)).map_err(|e| reg_err("Pipe", "geometry", e))?;
|
||||
let disc = Pipe::new(geo, default_disconnected_port(&fluid), default_disconnected_port(&fluid), get_f64_or(params, "fluidDensityKgPerM3", 1000.0), get_f64_or(params, "fluidViscosityPas", 0.001)).map_err(|e| reg_err("Pipe", "constructor", e))?;
|
||||
let pipe = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Pipe", "connect", e))?;
|
||||
let geo = PipeGeometry::new(
|
||||
get_f64_or(params, "lengthM", 1.0),
|
||||
get_f64_or(params, "diameterM", 0.02),
|
||||
get_f64_or(params, "roughnessM", 0.000045),
|
||||
)
|
||||
.map_err(|e| reg_err("Pipe", "geometry", e))?;
|
||||
let disc = Pipe::new(
|
||||
geo,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
get_f64_or(params, "fluidDensityKgPerM3", 1000.0),
|
||||
get_f64_or(params, "fluidViscosityPas", 0.001),
|
||||
)
|
||||
.map_err(|e| reg_err("Pipe", "constructor", e))?;
|
||||
let pipe = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("Pipe", "connect", e))?;
|
||||
Ok(Box::new(pipe))
|
||||
}
|
||||
|
||||
@@ -207,14 +337,18 @@ fn create_fan(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryEr
|
||||
let air_density = get_f64_or(params, "airDensityKgPerM3", 1.2);
|
||||
let speed_ratio = get_f64_or(params, "speedRatio", 1.0);
|
||||
let curves = if let Some(v) = params.get("curves") {
|
||||
let perf: PerformanceCurves = serde_json::from_value(v.clone()).map_err(|e| reg_err("Fan", "curves", e))?;
|
||||
let perf: PerformanceCurves =
|
||||
serde_json::from_value(v.clone()).map_err(|e| reg_err("Fan", "curves", e))?;
|
||||
FanCurves::new(perf).map_err(|e| reg_err("Fan", "curves", e))?
|
||||
} else { FanCurves::default() };
|
||||
} else {
|
||||
FanCurves::default()
|
||||
};
|
||||
let inlet_c = make_connected_port("Air", 101_325.0, 300_000.0);
|
||||
let outlet_c = make_connected_port("Air", 101_325.0, 300_000.0);
|
||||
let mut fan = Fan::from_connected_parts(curves, inlet_c, outlet_c, air_density)
|
||||
.map_err(|e| reg_err("Fan", "constructor", e))?;
|
||||
fan.set_speed_ratio(speed_ratio).map_err(|e| reg_err("Fan", "speedRatio", e))?;
|
||||
fan.set_speed_ratio(speed_ratio)
|
||||
.map_err(|e| reg_err("Fan", "speedRatio", e))?;
|
||||
Ok(Box::new(fan))
|
||||
}
|
||||
|
||||
@@ -222,7 +356,11 @@ fn create_condenser(params: &ComponentParams) -> Result<Box<dyn Component>, Regi
|
||||
use crate::heat_exchanger::condenser::Condenser;
|
||||
let ua = get_f64_or(params, "ua", 5000.0);
|
||||
let sat = params.get("saturationTempK").and_then(|v| v.as_f64());
|
||||
Ok(Box::new(if let Some(s) = sat { Condenser::with_saturation_temp(ua, s) } else { Condenser::new(ua) }))
|
||||
Ok(Box::new(if let Some(s) = sat {
|
||||
Condenser::with_saturation_temp(ua, s)
|
||||
} else {
|
||||
Condenser::new(ua)
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_evaporator(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
@@ -230,7 +368,11 @@ fn create_evaporator(params: &ComponentParams) -> Result<Box<dyn Component>, Reg
|
||||
let ua = get_f64_or(params, "ua", 5000.0);
|
||||
let sat = params.get("saturationTempK").and_then(|v| v.as_f64());
|
||||
let sh = params.get("superheatTargetK").and_then(|v| v.as_f64());
|
||||
Ok(Box::new(match (sat, sh) { (Some(s), Some(h)) => Evaporator::with_superheat(ua, s, h), (Some(s), _) => Evaporator::with_superheat(ua, s, 5.0), _ => Evaporator::new(ua) }))
|
||||
Ok(Box::new(match (sat, sh) {
|
||||
(Some(s), Some(h)) => Evaporator::with_superheat(ua, s, h),
|
||||
(Some(s), _) => Evaporator::with_superheat(ua, s, 5.0),
|
||||
_ => Evaporator::new(ua),
|
||||
}))
|
||||
}
|
||||
|
||||
fn create_economizer(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
@@ -247,15 +389,27 @@ fn create_heat_exchanger(params: &ComponentParams) -> Result<Box<dyn Component>,
|
||||
"ParallelFlow" => FlowConfiguration::ParallelFlow,
|
||||
_ => FlowConfiguration::CounterFlow,
|
||||
};
|
||||
Ok(Box::new(HeatExchanger::new(LmtdModel::new(ua, flow_config), name)))
|
||||
Ok(Box::new(HeatExchanger::new(
|
||||
LmtdModel::new(ua, flow_config),
|
||||
name,
|
||||
)))
|
||||
}
|
||||
|
||||
fn create_node(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::node::Node;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let name = get_string_or(params, "name", "node");
|
||||
let disc = Node::new(name, default_disconnected_port(&fluid), default_disconnected_port(&fluid));
|
||||
let node = disc.connect(default_disconnected_port(&fluid), default_disconnected_port(&fluid)).map_err(|e| reg_err("Node", "connect", e))?;
|
||||
let disc = Node::new(
|
||||
name,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
);
|
||||
let node = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("Node", "connect", e))?;
|
||||
Ok(Box::new(node))
|
||||
}
|
||||
|
||||
@@ -265,7 +419,15 @@ fn create_drum(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryE
|
||||
use std::sync::Arc;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let drum = Drum::new(&fluid, make_connected_port(&fluid, 200_000.0, 400_000.0), make_connected_port(&fluid, 150_000.0, 410_000.0), make_connected_port(&fluid, 150_000.0, 200_000.0), make_connected_port(&fluid, 150_000.0, 420_000.0), backend).map_err(|e| reg_err("Drum", "constructor", e))?;
|
||||
let drum = Drum::new(
|
||||
&fluid,
|
||||
make_connected_port(&fluid, 200_000.0, 400_000.0),
|
||||
make_connected_port(&fluid, 150_000.0, 410_000.0),
|
||||
make_connected_port(&fluid, 150_000.0, 200_000.0),
|
||||
make_connected_port(&fluid, 150_000.0, 420_000.0),
|
||||
backend,
|
||||
)
|
||||
.map_err(|e| reg_err("Drum", "constructor", e))?;
|
||||
Ok(Box::new(drum))
|
||||
}
|
||||
|
||||
@@ -274,18 +436,26 @@ fn create_flow_splitter(params: &ComponentParams) -> Result<Box<dyn Component>,
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let n = get_positive_usize(params, "outletCount", 2)?;
|
||||
let inlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let outlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let s = CompressibleSplitter::compressible(&fluid, inlet, outlets).map_err(|e| reg_err("FlowSplitter", "constructor", e))?;
|
||||
let outlets: Vec<_> = (0..n)
|
||||
.map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0))
|
||||
.collect();
|
||||
let s = CompressibleSplitter::compressible(&fluid, inlet, outlets)
|
||||
.map_err(|e| reg_err("FlowSplitter", "constructor", e))?;
|
||||
Ok(Box::new(s))
|
||||
}
|
||||
|
||||
fn create_incompressible_splitter(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
fn create_incompressible_splitter(
|
||||
params: &ComponentParams,
|
||||
) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::IncompressibleSplitter;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let n = get_positive_usize(params, "outletCount", 2)?;
|
||||
let inlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let outlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let s = IncompressibleSplitter::incompressible(&fluid, inlet, outlets).map_err(|e| reg_err("IncompressibleSplitter", "constructor", e))?;
|
||||
let outlets: Vec<_> = (0..n)
|
||||
.map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0))
|
||||
.collect();
|
||||
let s = IncompressibleSplitter::incompressible(&fluid, inlet, outlets)
|
||||
.map_err(|e| reg_err("IncompressibleSplitter", "constructor", e))?;
|
||||
Ok(Box::new(s))
|
||||
}
|
||||
|
||||
@@ -293,19 +463,27 @@ fn create_flow_merger(params: &ComponentParams) -> Result<Box<dyn Component>, Re
|
||||
use crate::flow_junction::CompressibleMerger;
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let n = get_positive_usize(params, "inletCount", 2)?;
|
||||
let inlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let inlets: Vec<_> = (0..n)
|
||||
.map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0))
|
||||
.collect();
|
||||
let outlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let m = CompressibleMerger::compressible(&fluid, inlets, outlet).map_err(|e| reg_err("FlowMerger", "constructor", e))?;
|
||||
let m = CompressibleMerger::compressible(&fluid, inlets, outlet)
|
||||
.map_err(|e| reg_err("FlowMerger", "constructor", e))?;
|
||||
Ok(Box::new(m))
|
||||
}
|
||||
|
||||
fn create_incompressible_merger(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
fn create_incompressible_merger(
|
||||
params: &ComponentParams,
|
||||
) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::flow_junction::IncompressibleMerger;
|
||||
let fluid = get_string_or(params, "fluid", "Water");
|
||||
let n = get_positive_usize(params, "inletCount", 2)?;
|
||||
let inlets: Vec<_> = (0..n).map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0)).collect();
|
||||
let inlets: Vec<_> = (0..n)
|
||||
.map(|_| make_connected_port(&fluid, 200_000.0, 400_000.0))
|
||||
.collect();
|
||||
let outlet = make_connected_port(&fluid, 200_000.0, 400_000.0);
|
||||
let m = IncompressibleMerger::incompressible(&fluid, inlets, outlet).map_err(|e| reg_err("IncompressibleMerger", "constructor", e))?;
|
||||
let m = IncompressibleMerger::incompressible(&fluid, inlets, outlet)
|
||||
.map_err(|e| reg_err("IncompressibleMerger", "constructor", e))?;
|
||||
Ok(Box::new(m))
|
||||
}
|
||||
|
||||
@@ -317,7 +495,10 @@ fn create_bypass_valve(params: &ComponentParams) -> Result<Box<dyn Component>, R
|
||||
return Err(RegistryError::InvalidParameter {
|
||||
component: "BypassValve".to_string(),
|
||||
parameter: "minPosition/maxPosition".to_string(),
|
||||
reason: format!("minPosition ({}) must be less than maxPosition ({})", min_pos, max_pos),
|
||||
reason: format!(
|
||||
"minPosition ({}) must be less than maxPosition ({})",
|
||||
min_pos, max_pos
|
||||
),
|
||||
});
|
||||
}
|
||||
let characteristics = match get_string_or(params, "characteristics", "Linear").as_str() {
|
||||
@@ -331,10 +512,15 @@ fn create_bypass_valve(params: &ComponentParams) -> Result<Box<dyn Component>, R
|
||||
max_position: max_pos,
|
||||
nominal_pressure_drop_pa: get_f64_or(params, "nominalPressureDropPa", 10_000.0),
|
||||
};
|
||||
Ok(Box::new(BypassValve::new(&get_string_or(params, "id", "bypass"), config)))
|
||||
Ok(Box::new(BypassValve::new(
|
||||
&get_string_or(params, "id", "bypass"),
|
||||
config,
|
||||
)))
|
||||
}
|
||||
|
||||
fn create_refrigerant_source(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
fn create_refrigerant_source(
|
||||
params: &ComponentParams,
|
||||
) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::refrigerant_boundary::RefrigerantSource;
|
||||
use entropyk_core::{Pressure, VaporQuality};
|
||||
use entropyk_fluids::TestBackend;
|
||||
@@ -344,7 +530,14 @@ fn create_refrigerant_source(params: &ComponentParams) -> Result<Box<dyn Compone
|
||||
let q = get_f64_or(params, "quality", 0.5).clamp(0.0, 1.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let outlet = make_connected_port(&fluid, p, 400_000.0);
|
||||
let src = RefrigerantSource::new(&fluid, Pressure::from_pascals(p), VaporQuality::from_fraction(q), backend, outlet).map_err(|e| reg_err("RefrigerantSource", "constructor", e))?;
|
||||
let src = RefrigerantSource::new(
|
||||
&fluid,
|
||||
Pressure::from_pascals(p),
|
||||
VaporQuality::from_fraction(q),
|
||||
backend,
|
||||
outlet,
|
||||
)
|
||||
.map_err(|e| reg_err("RefrigerantSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
@@ -357,7 +550,8 @@ fn create_refrigerant_sink(params: &ComponentParams) -> Result<Box<dyn Component
|
||||
let p = get_f64_or(params, "pBackPa", 200_000.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let inlet = make_connected_port(&fluid, p, 400_000.0);
|
||||
let sink = RefrigerantSink::new(&fluid, Pressure::from_pascals(p), None, backend, inlet).map_err(|e| reg_err("RefrigerantSink", "constructor", e))?;
|
||||
let sink = RefrigerantSink::new(&fluid, Pressure::from_pascals(p), None, backend, inlet)
|
||||
.map_err(|e| reg_err("RefrigerantSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
@@ -372,7 +566,15 @@ fn create_brine_source(params: &ComponentParams) -> Result<Box<dyn Component>, R
|
||||
let c = get_f64_or(params, "concentration", 0.0).clamp(0.0, 1.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let outlet = make_connected_port(&fluid, p, 50_000.0);
|
||||
let src = BrineSource::new(&fluid, Pressure::from_pascals(p), Temperature::from_kelvin(t), Concentration::from_fraction(c), backend, outlet).map_err(|e| reg_err("BrineSource", "constructor", e))?;
|
||||
let src = BrineSource::new(
|
||||
&fluid,
|
||||
Pressure::from_pascals(p),
|
||||
Temperature::from_kelvin(t),
|
||||
Concentration::from_fraction(c),
|
||||
backend,
|
||||
outlet,
|
||||
)
|
||||
.map_err(|e| reg_err("BrineSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
@@ -385,7 +587,15 @@ fn create_brine_sink(params: &ComponentParams) -> Result<Box<dyn Component>, Reg
|
||||
let p = get_f64_or(params, "pressurePa", 200_000.0);
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
|
||||
let inlet = make_connected_port(&fluid, p, 50_000.0);
|
||||
let sink = BrineSink::new(&fluid, Pressure::from_pascals(p), None, None, backend, inlet).map_err(|e| reg_err("BrineSink", "constructor", e))?;
|
||||
let sink = BrineSink::new(
|
||||
&fluid,
|
||||
Pressure::from_pascals(p),
|
||||
None,
|
||||
None,
|
||||
backend,
|
||||
inlet,
|
||||
)
|
||||
.map_err(|e| reg_err("BrineSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
@@ -396,7 +606,13 @@ fn create_air_source(params: &ComponentParams) -> Result<Box<dyn Component>, Reg
|
||||
let rh = get_f64_or(params, "relativeHumidity", 0.5).clamp(0.0, 1.0);
|
||||
let p = get_f64_or(params, "pressurePa", 101_325.0);
|
||||
let outlet = make_connected_port("Air", p, 50_000.0);
|
||||
let src = AirSource::from_dry_bulb_rh(Temperature::from_kelvin(t), RelativeHumidity::from_fraction(rh), Pressure::from_pascals(p), outlet).map_err(|e| reg_err("AirSource", "constructor", e))?;
|
||||
let src = AirSource::from_dry_bulb_rh(
|
||||
Temperature::from_kelvin(t),
|
||||
RelativeHumidity::from_fraction(rh),
|
||||
Pressure::from_pascals(p),
|
||||
outlet,
|
||||
)
|
||||
.map_err(|e| reg_err("AirSource", "constructor", e))?;
|
||||
Ok(Box::new(src))
|
||||
}
|
||||
|
||||
@@ -405,11 +621,14 @@ fn create_air_sink(params: &ComponentParams) -> Result<Box<dyn Component>, Regis
|
||||
use entropyk_core::Pressure;
|
||||
let p = get_f64_or(params, "pressurePa", 101_325.0);
|
||||
let inlet = make_connected_port("Air", p, 50_000.0);
|
||||
let sink = AirSink::new(Pressure::from_pascals(p), inlet).map_err(|e| reg_err("AirSink", "constructor", e))?;
|
||||
let sink = AirSink::new(Pressure::from_pascals(p), inlet)
|
||||
.map_err(|e| reg_err("AirSink", "constructor", e))?;
|
||||
Ok(Box::new(sink))
|
||||
}
|
||||
|
||||
fn create_flooded_evaporator(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
fn create_flooded_evaporator(
|
||||
params: &ComponentParams,
|
||||
) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::flooded_evaporator::FloodedEvaporator;
|
||||
let ua = get_f64(params, "ua")?;
|
||||
Ok(Box::new(FloodedEvaporator::new(ua)))
|
||||
@@ -423,30 +642,117 @@ fn create_flooded_condenser(params: &ComponentParams) -> Result<Box<dyn Componen
|
||||
|
||||
fn create_condenser_coil(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::condenser_coil::CondenserCoil;
|
||||
Ok(Box::new(CondenserCoil::new(get_f64_or(params, "ua", 5000.0))))
|
||||
Ok(Box::new(CondenserCoil::new(get_f64_or(
|
||||
params, "ua", 5000.0,
|
||||
))))
|
||||
}
|
||||
|
||||
fn create_evaporator_coil(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::heat_exchanger::evaporator_coil::EvaporatorCoil;
|
||||
Ok(Box::new(EvaporatorCoil::new(get_f64_or(params, "ua", 5000.0))))
|
||||
Ok(Box::new(EvaporatorCoil::new(get_f64_or(
|
||||
params, "ua", 5000.0,
|
||||
))))
|
||||
}
|
||||
|
||||
fn create_screw_compressor(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let freq = get_f64_or(params, "nominalFrequencyHz", 50.0);
|
||||
let eff = get_f64_or(params, "mechanicalEfficiency", 0.85);
|
||||
let nominal_freq = params
|
||||
.get("nominal_frequency_hz")
|
||||
.or_else(|| params.get("nominalFrequencyHz"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(50.0);
|
||||
let frequency_hz = params
|
||||
.get("frequency_hz")
|
||||
.or_else(|| params.get("frequencyHz"))
|
||||
.and_then(|v| v.as_f64());
|
||||
let eff = params
|
||||
.get("mechanical_efficiency")
|
||||
.or_else(|| params.get("mechanicalEfficiency"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.85);
|
||||
let curves: ScrewPerformanceCurves = if params.get("curves").is_some() {
|
||||
deserialize_field(params, "curves")?
|
||||
} else {
|
||||
use crate::Polynomial2D;
|
||||
ScrewPerformanceCurves {
|
||||
mass_flow_curve: Polynomial2D::default(),
|
||||
power_curve: Polynomial2D::default(),
|
||||
eco_flow_fraction_curve: Polynomial2D::default(),
|
||||
}
|
||||
let eco_fraction = get_f64_or(params, "economizer_fraction", 0.12);
|
||||
ScrewPerformanceCurves::with_fixed_eco_fraction(
|
||||
Polynomial2D::bilinear(
|
||||
get_f64_or(params, "mf_a00", 1.2),
|
||||
get_f64_or(params, "mf_a10", 0.003),
|
||||
get_f64_or(params, "mf_a01", -0.002),
|
||||
get_f64_or(params, "mf_a11", 0.000_01),
|
||||
),
|
||||
Polynomial2D::bilinear(
|
||||
get_f64_or(params, "pw_b00", 55_000.0),
|
||||
get_f64_or(params, "pw_b10", 200.0),
|
||||
get_f64_or(params, "pw_b01", -300.0),
|
||||
get_f64_or(params, "pw_b11", 0.5),
|
||||
),
|
||||
eco_fraction,
|
||||
)
|
||||
};
|
||||
let comp = ScrewEconomizerCompressor::new(curves, &fluid, freq, eff, make_connected_port(&fluid, 200_000.0, 400_000.0), make_connected_port(&fluid, 800_000.0, 440_000.0), make_connected_port(&fluid, 400_000.0, 420_000.0)).map_err(|e| reg_err("ScrewEconomizerCompressor", "constructor", e))?;
|
||||
let mut comp = ScrewEconomizerCompressor::new(
|
||||
curves,
|
||||
&fluid,
|
||||
nominal_freq,
|
||||
eff,
|
||||
make_connected_port(&fluid, 200_000.0, 400_000.0),
|
||||
make_connected_port(&fluid, 800_000.0, 440_000.0),
|
||||
make_connected_port(&fluid, 400_000.0, 420_000.0),
|
||||
)
|
||||
.map_err(|e| reg_err("ScrewEconomizerCompressor", "constructor", e))?;
|
||||
if let Some(freq) = frequency_hz {
|
||||
comp.set_frequency_hz(freq)
|
||||
.map_err(|e| reg_err("ScrewEconomizerCompressor", "frequency_hz", e))?;
|
||||
}
|
||||
Ok(Box::new(comp))
|
||||
}
|
||||
|
||||
fn create_capillary_tube(params: &ComponentParams) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::capillary_tube::{CapillaryGeometry, CapillaryTube};
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let geom = CapillaryGeometry {
|
||||
diameter_m: get_f64_or(params, "diameterM", 0.001),
|
||||
length_m: get_f64_or(params, "lengthM", 2.0),
|
||||
n_segments: get_f64_or(params, "nSegments", 20.0) as usize,
|
||||
};
|
||||
let disc = CapillaryTube::new(
|
||||
geom,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("CapillaryTube", "constructor", e))?;
|
||||
let tube = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("CapillaryTube", "connect", e))?;
|
||||
Ok(Box::new(tube))
|
||||
}
|
||||
|
||||
fn create_centrifugal_compressor(
|
||||
params: &ComponentParams,
|
||||
) -> Result<Box<dyn Component>, RegistryError> {
|
||||
use crate::centrifugal_compressor::{CentrifugalCompressor, CentrifugalMap};
|
||||
let fluid = get_string_or(params, "fluid", "R134a");
|
||||
let diameter = get_f64_or(params, "diameterM", 0.25);
|
||||
let speed = get_f64_or(params, "speedRpm", 9000.0);
|
||||
let disc = CentrifugalCompressor::new(
|
||||
CentrifugalMap::default_chiller_map(),
|
||||
diameter,
|
||||
speed,
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("CentrifugalCompressor", "constructor", e))?;
|
||||
let comp = disc
|
||||
.connect(
|
||||
default_disconnected_port(&fluid),
|
||||
default_disconnected_port(&fluid),
|
||||
)
|
||||
.map_err(|e| reg_err("CentrifugalCompressor", "connect", e))?;
|
||||
Ok(Box::new(comp))
|
||||
}
|
||||
|
||||
@@ -462,7 +768,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_component_type_name() {
|
||||
assert_eq!(component_type_name(&ComponentParams::new("Compressor")), "Compressor");
|
||||
assert_eq!(
|
||||
component_type_name(&ComponentParams::new("Compressor")),
|
||||
"Compressor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -473,11 +782,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_create_expansion_valve() {
|
||||
let params = ComponentParams::new("ExpansionValve").with_param("fluid", "R134a").with_param("opening", 0.8);
|
||||
let params = ComponentParams::new("ExpansionValve")
|
||||
.with_param("fluid", "R134a")
|
||||
.with_param("opening", 0.8);
|
||||
let comp = create_component(¶ms).unwrap();
|
||||
assert_eq!(comp.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_capillary_and_centrifugal() {
|
||||
let cap = create_component(&ComponentParams::new("CapillaryTube")).unwrap();
|
||||
assert_eq!(cap.n_equations(), 2);
|
||||
let cent = create_component(&ComponentParams::new("CentrifugalCompressor")).unwrap();
|
||||
assert_eq!(cent.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_condenser() {
|
||||
let params = ComponentParams::new("Condenser").with_param("ua", 5000.0);
|
||||
@@ -492,7 +811,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_create_node() {
|
||||
let params = ComponentParams::new("Node").with_param("name", "n1").with_param("fluid", "R134a");
|
||||
let params = ComponentParams::new("Node")
|
||||
.with_param("name", "n1")
|
||||
.with_param("fluid", "R134a");
|
||||
let comp = create_component(¶ms).unwrap();
|
||||
assert_eq!(comp.n_equations(), 0);
|
||||
}
|
||||
@@ -500,11 +821,50 @@ mod tests {
|
||||
#[test]
|
||||
fn test_create_compressor_ahri540() {
|
||||
let params = ComponentParams::new("Compressor")
|
||||
.with_param("fluid", "R134a").with_param("speedRpm", 2900.0).with_param("displacementM3PerRev", 0.0001)
|
||||
.with_param("mechanicalEfficiency", 0.85).with_param("modelType", "Ahri540")
|
||||
.with_param("m1", 0.85).with_param("m2", 2.5).with_param("m3", 500.0).with_param("m4", 1500.0)
|
||||
.with_param("m5", -2.5).with_param("m6", 1.8).with_param("m7", 600.0).with_param("m8", 1600.0)
|
||||
.with_param("m9", -3.0).with_param("m10", 2.0);
|
||||
.with_param("fluid", "R134a")
|
||||
.with_param("speedRpm", 2900.0)
|
||||
.with_param("displacementM3PerRev", 0.0001)
|
||||
.with_param("mechanicalEfficiency", 0.85)
|
||||
.with_param("modelType", "Ahri540")
|
||||
.with_param("m1", 0.85)
|
||||
.with_param("m2", 2.5)
|
||||
.with_param("m3", 500.0)
|
||||
.with_param("m4", 1500.0)
|
||||
.with_param("m5", -2.5)
|
||||
.with_param("m6", 1.8)
|
||||
.with_param("m7", 600.0)
|
||||
.with_param("m8", 1600.0)
|
||||
.with_param("m9", -3.0)
|
||||
.with_param("m10", 2.0);
|
||||
assert!(create_component(¶ms).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_screw_compressor_accepts_cli_params() {
|
||||
let params = ComponentParams::new("ScrewCompressor")
|
||||
.with_param("fluid", "R134a")
|
||||
.with_param("nominal_frequency_hz", 50.0)
|
||||
.with_param("frequency_hz", 40.0)
|
||||
.with_param("mechanical_efficiency", 0.92)
|
||||
.with_param("economizer_fraction", 0.12)
|
||||
.with_param("mf_a00", 1.2)
|
||||
.with_param("mf_a10", 0.003)
|
||||
.with_param("mf_a01", -0.002)
|
||||
.with_param("mf_a11", 0.000_01)
|
||||
.with_param("pw_b00", 55_000.0)
|
||||
.with_param("pw_b10", 200.0)
|
||||
.with_param("pw_b01", -300.0)
|
||||
.with_param("pw_b11", 0.5);
|
||||
|
||||
let comp = create_component(¶ms).unwrap();
|
||||
assert_eq!(comp.n_equations(), 6);
|
||||
assert_eq!(
|
||||
comp.port_names(),
|
||||
vec![
|
||||
"suction".to_string(),
|
||||
"discharge".to_string(),
|
||||
"economizer".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
419
crates/components/src/reversing_valve.rs
Normal file
419
crates/components/src/reversing_valve.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! ReversingValve Component (four-way / cycle-reversing valve)
|
||||
//!
|
||||
//! Models a four-way reversing valve used in reversible heat pumps to switch the
|
||||
//! machine between **cooling** and **heating** operation. In a steady-state
|
||||
//! (P, h)-per-edge cycle solver the valve is represented as a deterministic inline
|
||||
//! 2-port element placed on a refrigerant line. It imposes two genuinely physical
|
||||
//! effects (no artificial derating):
|
||||
//!
|
||||
//! 1. **Pressure drop** across the valve body — the dominant real penalty of a
|
||||
//! four-way valve (typically 0.1–0.3 bar at rated flow, which alone reduces
|
||||
//! capacity/COP by ~1–3 %). Modeled as `ΔP = ΔP_base + k·ṁ·|ṁ|` (a fixed
|
||||
//! baseline plus a quadratic flow term), exactly like the lumped two-phase
|
||||
//! pressure drop used elsewhere.
|
||||
//! 2. **Internal leakage** — a four-way valve leaks a small amount of hot
|
||||
//! discharge gas across the slide to the suction port. When the valve is placed
|
||||
//! on the suction line this manifests as a measurable suction-superheat gain;
|
||||
//! it is modeled as a specific-enthalpy rise `Δh_leak` [J/kg] added to the
|
||||
//! stream. `Δh_leak = 0` reproduces an adiabatic valve exactly.
|
||||
//!
|
||||
//! **Energy-conservation caveat:** a non-zero `Δh_leak` injects `ṁ·Δh_leak` of
|
||||
//! enthalpy on a *single* stream. Physically the leaked gas recirculates from
|
||||
//! the high side to the low side, so a rigorous representation needs an explicit
|
||||
//! bypass **mass** split (two streams). In a single-stream lumped model the added
|
||||
//! enthalpy has no matching source and would break machine-level First-Law
|
||||
//! closure. The CLI examples therefore keep `Δh_leak = 0` (isenthalpic, exact
|
||||
//! First Law); the pressure drop alone already reproduces the ~1–3 % penalty.
|
||||
//! The parameter is retained for a future bypass-mass topology.
|
||||
//!
|
||||
//! ## Residuals (2 thermodynamic equations, + 1 mass conservation when the inlet
|
||||
//! and outlet edges do not share a mass-flow index):
|
||||
//!
|
||||
//! - `r0 = P_out − (P_in − ΔP)` (pressure drop)
|
||||
//! - `r1 = h_out − (h_in + Δh_leak)` (leakage preheat / adiabatic)
|
||||
//! - `r2 = ṁ_out − ṁ_in` (mass conservation, CM1.3/CM1.4)
|
||||
//!
|
||||
//! The valve fully determines its outlet edge from its inlet edge, so it is
|
||||
//! degree-of-freedom neutral: inserting it adds one edge (2 unknowns) and two
|
||||
//! equations. It therefore works unchanged in both the fixed-pressure and the
|
||||
//! emergent-pressure cycle topologies.
|
||||
//!
|
||||
//! ## Mode selector
|
||||
//!
|
||||
//! [`ReversingMode`] records whether the valve is set to `Cooling` or `Heating`.
|
||||
//! The physical penalty (ΔP + leakage) is identical in both modes; the mode is
|
||||
//! reported for diagnostics and drives which coil the configuration wires as the
|
||||
//! condenser/evaporator. Full automatic role-swapping of the two coils from a
|
||||
//! single configuration is intentionally left to the topology layer.
|
||||
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
|
||||
/// Operating mode of a four-way reversing valve.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReversingMode {
|
||||
/// Cooling mode: compressor discharge routed to the outdoor coil (condenser).
|
||||
Cooling,
|
||||
/// Heating mode: compressor discharge routed to the indoor coil (condenser).
|
||||
Heating,
|
||||
}
|
||||
|
||||
impl ReversingMode {
|
||||
/// Parse a mode from a case-insensitive string (`"cooling"` / `"heating"`).
|
||||
/// Also accepts `"cool"` / `"heat"`. Returns `None` for anything else.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"cooling" | "cool" | "c" => Some(Self::Cooling),
|
||||
"heating" | "heat" | "h" => Some(Self::Heating),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower-case string representation (`"cooling"` / `"heating"`).
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Cooling => "cooling",
|
||||
Self::Heating => "heating",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-way reversing valve: a deterministic inline 2-port pressure-drop +
|
||||
/// internal-leakage element with a cooling/heating mode selector.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReversingValve {
|
||||
/// Operating mode (cooling / heating).
|
||||
mode: ReversingMode,
|
||||
/// Fixed baseline pressure drop across the valve body [Pa]. Default 0.
|
||||
pressure_drop_pa: f64,
|
||||
/// Optional quadratic flow coefficient `k` [Pa·s²/kg²] so that
|
||||
/// `ΔP = pressure_drop_pa + k·ṁ·|ṁ|`. `None` uses only the fixed baseline.
|
||||
pressure_drop_coeff: Option<f64>,
|
||||
/// Internal-leakage specific-enthalpy rise added to the stream [J/kg].
|
||||
/// Default 0 (adiabatic valve).
|
||||
leak_enthalpy_j_kg: f64,
|
||||
|
||||
// State indices (set by set_system_context).
|
||||
inlet_p_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
inlet_m_idx: Option<usize>,
|
||||
outlet_p_idx: Option<usize>,
|
||||
outlet_h_idx: Option<usize>,
|
||||
outlet_m_idx: Option<usize>,
|
||||
/// True when inlet and outlet share the same ṁ state index (CM1.4).
|
||||
same_branch_m: bool,
|
||||
}
|
||||
|
||||
impl ReversingValve {
|
||||
/// Create a new reversing valve in the given mode with no pressure drop and
|
||||
/// no leakage (an ideal pass-through until parameters are configured).
|
||||
pub fn new(mode: ReversingMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
pressure_drop_pa: 0.0,
|
||||
pressure_drop_coeff: None,
|
||||
leak_enthalpy_j_kg: 0.0,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
inlet_m_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
outlet_m_idx: None,
|
||||
same_branch_m: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the fixed baseline pressure drop [Pa].
|
||||
pub fn with_pressure_drop_pa(mut self, dp_pa: f64) -> Self {
|
||||
self.pressure_drop_pa = dp_pa.max(0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the quadratic flow pressure-drop coefficient `k` [Pa·s²/kg²]
|
||||
/// (`ΔP = pressure_drop_pa + k·ṁ·|ṁ|`).
|
||||
pub fn with_pressure_drop_coeff(mut self, k: f64) -> Self {
|
||||
self.pressure_drop_coeff = Some(k.max(0.0));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the internal-leakage specific-enthalpy rise [J/kg] (suction preheat).
|
||||
pub fn with_leak_enthalpy(mut self, dh_j_kg: f64) -> Self {
|
||||
self.leak_enthalpy_j_kg = dh_j_kg;
|
||||
self
|
||||
}
|
||||
|
||||
/// Current operating mode.
|
||||
pub fn mode(&self) -> ReversingMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Set the operating mode.
|
||||
pub fn set_mode(&mut self, mode: ReversingMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Effective pressure drop [Pa] at the given mass flow `ṁ` [kg/s].
|
||||
fn delta_p(&self, m_dot: f64) -> f64 {
|
||||
self.pressure_drop_pa + self.pressure_drop_coeff.unwrap_or(0.0) * m_dot * m_dot.abs()
|
||||
}
|
||||
|
||||
/// Mass-flow value used for the quadratic ΔP term (outlet, else inlet).
|
||||
fn mass_flow(&self, state: &StateSlice) -> Option<f64> {
|
||||
self.outlet_m_idx.or(self.inlet_m_idx).map(|idx| state[idx])
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ReversingValve {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge, [1] = outgoing edge. Triple (m, p, h).
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_m_idx = Some(external_edge_state_indices[1].0);
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
self.same_branch_m = matches!(
|
||||
(self.inlet_m_idx, self.outlet_m_idx),
|
||||
(Some(m_in), Some(m_out)) if m_in == m_out
|
||||
);
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() != self.n_equations() {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: self.n_equations(),
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
let m_dot = self.mass_flow(state).unwrap_or(0.0);
|
||||
let dp = self.delta_p(m_dot);
|
||||
// r0: pressure drop P_out = P_in − ΔP
|
||||
residuals[0] = state[out_p] - (state[in_p] - dp);
|
||||
// r1: leakage preheat h_out = h_in + Δh_leak
|
||||
residuals[1] = state[out_h] - (state[in_h] + self.leak_enthalpy_j_kg);
|
||||
// r2: mass conservation (CM1.3), dropped when same_branch_m (CM1.4).
|
||||
if !self.same_branch_m {
|
||||
residuals[2] = match (self.inlet_m_idx, self.outlet_m_idx) {
|
||||
(Some(m_in), Some(m_out)) => state[m_out] - state[m_in],
|
||||
_ => 0.0,
|
||||
};
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(ComponentError::InvalidState(
|
||||
"ReversingValve requires live inlet/outlet edge state indices".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
// r0 = P_out − P_in + ΔP(ṁ)
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
// ∂ΔP/∂ṁ = k · 2|ṁ| (d/dm of m·|m| = 2|m|)
|
||||
if let (Some(k), Some(m_idx)) = (
|
||||
self.pressure_drop_coeff,
|
||||
self.outlet_m_idx.or(self.inlet_m_idx),
|
||||
) {
|
||||
let m_dot = state[m_idx];
|
||||
jacobian.add_entry(0, m_idx, k * 2.0 * m_dot.abs());
|
||||
}
|
||||
// r1 = h_out − h_in − Δh_leak
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
// r2 = ṁ_out − ṁ_in
|
||||
if !self.same_branch_m {
|
||||
if let (Some(m_in), Some(m_out)) = (self.inlet_m_idx, self.outlet_m_idx) {
|
||||
jacobian.add_entry(2, m_out, 1.0);
|
||||
jacobian.add_entry(2, m_in, -1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
// 2 thermodynamic equations (ΔP + leakage); + 1 mass conservation unless
|
||||
// the inlet and outlet edges are collapsed onto a single ṁ index (CM1.4).
|
||||
if self.same_branch_m {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
// The valve does no shaft work. Any leakage enthalpy is an internal
|
||||
// redistribution within the refrigerant stream, not an external heat
|
||||
// input, so it is not reported as Q here (that would double-count it in
|
||||
// the cycle energy balance).
|
||||
Some((
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"ReversingValve(mode={}, dp_pa={:.1}, dp_k={:?}, leak_j_kg={:.1})",
|
||||
self.mode.as_str(),
|
||||
self.pressure_drop_pa,
|
||||
self.pressure_drop_coeff,
|
||||
self.leak_enthalpy_j_kg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wired(mode: ReversingMode) -> ReversingValve {
|
||||
let mut v = ReversingValve::new(mode);
|
||||
// Distinct branches: inlet (m0, p1, h2), outlet (m3, p4, h5).
|
||||
v.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_parsing_and_roundtrip() {
|
||||
assert_eq!(
|
||||
ReversingMode::from_str("Cooling"),
|
||||
Some(ReversingMode::Cooling)
|
||||
);
|
||||
assert_eq!(
|
||||
ReversingMode::from_str("HEAT"),
|
||||
Some(ReversingMode::Heating)
|
||||
);
|
||||
assert_eq!(ReversingMode::from_str("bogus"), None);
|
||||
assert_eq!(ReversingMode::Cooling.as_str(), "cooling");
|
||||
assert_eq!(ReversingMode::Heating.as_str(), "heating");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_equation_count_branches() {
|
||||
// Distinct branches → 3 equations (thermo + mass).
|
||||
assert_eq!(wired(ReversingMode::Cooling).n_equations(), 3);
|
||||
// Same-branch (collapsed ṁ) → 2 equations.
|
||||
let mut same = ReversingValve::new(ReversingMode::Cooling);
|
||||
same.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
assert_eq!(same.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ideal_passthrough_is_zero_residual_when_consistent() {
|
||||
let v = wired(ReversingMode::Cooling);
|
||||
// state: m0,p1,h2 (in) ; m3,p4,h5 (out). Consistent pass-through.
|
||||
let mut state = vec![0.0; 6];
|
||||
state[0] = 0.1; // m_in
|
||||
state[1] = 12.0e5; // p_in
|
||||
state[2] = 430_000.0; // h_in
|
||||
state[3] = 0.1; // m_out
|
||||
state[4] = 12.0e5; // p_out (no ΔP)
|
||||
state[5] = 430_000.0; // h_out (no leak)
|
||||
let mut r = vec![9.0; 3];
|
||||
v.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r.iter().all(|x| x.abs() < 1e-9), "residuals: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_residual_reacts_to_pressure_drop_and_leak() {
|
||||
let v = wired(ReversingMode::Heating)
|
||||
.with_pressure_drop_pa(20_000.0) // 0.2 bar
|
||||
.with_leak_enthalpy(1_500.0); // 1.5 kJ/kg suction preheat
|
||||
let mut state = vec![0.0; 6];
|
||||
state[0] = 0.1;
|
||||
state[1] = 3.5e5; // p_in
|
||||
state[2] = 400_000.0; // h_in
|
||||
state[3] = 0.1;
|
||||
state[4] = 3.5e5; // p_out (unchanged → violates ΔP)
|
||||
state[5] = 400_000.0; // h_out (unchanged → violates leak)
|
||||
let mut r = vec![0.0; 3];
|
||||
v.compute_residuals(&state, &mut r).unwrap();
|
||||
// r0 = p_out − (p_in − ΔP) = 0 − (−20000) = +20000
|
||||
assert!((r[0] - 20_000.0).abs() < 1e-6, "r0={}", r[0]);
|
||||
// r1 = h_out − (h_in + leak) = −1500
|
||||
assert!((r[1] + 1_500.0).abs() < 1e-6, "r1={}", r[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_matches_finite_difference() {
|
||||
let v = wired(ReversingMode::Cooling)
|
||||
.with_pressure_drop_coeff(1.0e6) // quadratic flow term
|
||||
.with_pressure_drop_pa(5_000.0)
|
||||
.with_leak_enthalpy(800.0);
|
||||
let state = vec![0.15_f64, 3.4e5, 405_000.0, 0.15, 3.3e5, 404_000.0];
|
||||
|
||||
// Analytic entries.
|
||||
let mut jb = JacobianBuilder::new();
|
||||
v.jacobian_entries(&state, &mut jb).unwrap();
|
||||
let neq = v.n_equations();
|
||||
let ncol = state.len();
|
||||
let mut jac = vec![vec![0.0; ncol]; neq];
|
||||
for &(row, col, val) in jb.entries() {
|
||||
jac[row][col] += val;
|
||||
}
|
||||
|
||||
// Central finite difference of the residuals.
|
||||
let eval = |s: &[f64]| {
|
||||
let mut r = vec![0.0; neq];
|
||||
v.compute_residuals(s, &mut r).unwrap();
|
||||
r
|
||||
};
|
||||
for col in 0..ncol {
|
||||
let h = state[col].abs() * 1e-6 + 1e-3;
|
||||
let mut sp = state.clone();
|
||||
let mut sm = state.clone();
|
||||
sp[col] += h;
|
||||
sm[col] -= h;
|
||||
let rp = eval(&sp);
|
||||
let rm = eval(&sm);
|
||||
for row in 0..neq {
|
||||
let fd = (rp[row] - rm[row]) / (2.0 * h);
|
||||
assert!(
|
||||
(fd - jac[row][col]).abs() < 1e-3 * (1.0 + fd.abs()),
|
||||
"J[{row}][{col}] analytic={} fd={}",
|
||||
jac[row][col],
|
||||
fd
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,8 @@
|
||||
//! mass flow fraction curve:
|
||||
//!
|
||||
//! ```text
|
||||
//! ṁ_suction = f_m × Σ a_ij × SST^i × SDT^j (polynomial, manufacturer data)
|
||||
//! ṁ_eco = x_eco × ṁ_suction (economizer fraction, 0.05–0.20)
|
||||
//! ṁ_suction = z_flow × Σ a_ij × SST^i × SDT^j (polynomial, manufacturer data)
|
||||
//! ṁ_eco = z_flow_eco × x_eco × ṁ_suction,nom (economizer fraction, 0.05–0.20)
|
||||
//! ṁ_total = ṁ_suction + ṁ_eco
|
||||
//! W_comp = f_pwr × Σ b_ij × SST^i × SDT^j (shaft power, manufacturer data)
|
||||
//!
|
||||
@@ -81,7 +81,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, CalibIndices, MassFlow, Power};
|
||||
use entropyk_core::{Calib, CalibIndices, Enthalpy, MassFlow, Power};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -179,6 +179,10 @@ pub struct ScrewEconomizerCompressor {
|
||||
frequency_hz: f64,
|
||||
/// Mechanical efficiency (0.0–1.0)
|
||||
mechanical_efficiency: f64,
|
||||
/// Built-in volume index Vi = V_s / V_d (typically 2.2–5.0).
|
||||
volume_index: f64,
|
||||
/// Slide-valve position in [0, 1] (1 = full load). Reduces effective Vi.
|
||||
slide_valve_position: f64,
|
||||
/// Circuit identifier for multi-circuit topology
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state: On / Off / Bypass
|
||||
@@ -192,9 +196,15 @@ pub struct ScrewEconomizerCompressor {
|
||||
ports: Vec<ConnectedPort>,
|
||||
/// Offset of this component's internal state block in the global state vector.
|
||||
/// Set by `System::finalize()` via `set_system_context()`.
|
||||
/// The 5 internal variables at `state[offset..offset+5]` are:
|
||||
/// [ṁ_suc, ṁ_eco, h_suc, h_dis, W_shaft]
|
||||
/// After CM1.3: 1 internal variable: [W_shaft] at `state[offset]`.
|
||||
/// ṁ_suc and ṁ_eco are now edge unknowns accessed via m_idx fields.
|
||||
global_state_offset: usize,
|
||||
/// State-vector index of the suction edge ṁ unknown (CM1.3).
|
||||
suction_m_idx: Option<usize>,
|
||||
/// State-vector index of the economizer edge ṁ unknown (CM1.3).
|
||||
eco_m_idx: Option<usize>,
|
||||
/// State-vector index of the discharge edge ṁ unknown (CM1.3).
|
||||
discharge_m_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ScrewEconomizerCompressor {
|
||||
@@ -256,15 +266,56 @@ impl ScrewEconomizerCompressor {
|
||||
nominal_frequency_hz,
|
||||
frequency_hz: nominal_frequency_hz,
|
||||
mechanical_efficiency,
|
||||
volume_index: 2.8,
|
||||
slide_valve_position: 1.0,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::On,
|
||||
calib: Calib::default(),
|
||||
calib_indices: CalibIndices::default(),
|
||||
ports: vec![port_suction, port_discharge, port_economizer],
|
||||
global_state_offset: 0,
|
||||
suction_m_idx: None,
|
||||
eco_m_idx: None,
|
||||
discharge_m_idx: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets built-in volume index Vi (typical 2.2–5.0).
|
||||
pub fn with_volume_index(mut self, vi: f64) -> Self {
|
||||
self.volume_index = vi.max(1.1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets slide-valve position (1 = full load, 0.5 ≈ 50 % capacity).
|
||||
pub fn with_slide_valve(mut self, position: f64) -> Self {
|
||||
self.slide_valve_position = position.clamp(0.05, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Effective Vi after slide-valve unloading (Shaw / Manske simplified).
|
||||
///
|
||||
/// `Vi_eff ≈ 1 + (Vi − 1) · slide` — port geometry fixed, trapped volume shrinks.
|
||||
pub fn effective_volume_index(&self) -> f64 {
|
||||
1.0 + (self.volume_index - 1.0) * self.slide_valve_position
|
||||
}
|
||||
|
||||
/// Ideal Vi for a polytropic compression: `CR^(1/k)`.
|
||||
pub fn ideal_volume_index(pressure_ratio: f64, kappa: f64) -> f64 {
|
||||
let cr = pressure_ratio.max(1.01);
|
||||
let k = kappa.clamp(1.05, 1.4);
|
||||
cr.powf(1.0 / k)
|
||||
}
|
||||
|
||||
/// Power / efficiency penalty when Vi_eff ≠ Vi_ideal (Manske/Shaw style).
|
||||
///
|
||||
/// Returns a multiplier ≥ 1 on shaft power. Quadratic in relative mismatch,
|
||||
/// capped at 1.25 (25 % over-power at extreme mismatch).
|
||||
pub fn vi_power_penalty(vi_eff: f64, vi_ideal: f64) -> f64 {
|
||||
let denom = vi_ideal.max(1.1);
|
||||
let mismatch = ((vi_eff - vi_ideal) / denom).abs();
|
||||
(1.0 + 0.35 * mismatch * mismatch).min(1.25)
|
||||
}
|
||||
|
||||
// ─── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the refrigerant fluid identifier.
|
||||
@@ -272,6 +323,26 @@ impl ScrewEconomizerCompressor {
|
||||
&self.fluid_id
|
||||
}
|
||||
|
||||
/// Returns the built-in volume index.
|
||||
pub fn volume_index(&self) -> f64 {
|
||||
self.volume_index
|
||||
}
|
||||
|
||||
/// Returns the slide-valve position.
|
||||
pub fn slide_valve_position(&self) -> f64 {
|
||||
self.slide_valve_position
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_volume_index_for_test(&mut self, vi: f64) {
|
||||
self.volume_index = vi.max(1.1);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_slide_for_test(&mut self, pos: f64) {
|
||||
self.slide_valve_position = pos.clamp(0.05, 1.0);
|
||||
}
|
||||
|
||||
/// Returns the current operating frequency [Hz].
|
||||
pub fn frequency_hz(&self) -> f64 {
|
||||
self.frequency_hz
|
||||
@@ -368,25 +439,45 @@ impl ScrewEconomizerCompressor {
|
||||
}
|
||||
|
||||
/// Computes nominal suction mass flow [kg/s] at current SST/SDT, scaled by VFD.
|
||||
fn calc_mass_flow_suction(&self, sst_k: f64, sdt_k: f64, state: Option<&StateSlice>) -> f64 {
|
||||
fn calc_mass_flow_suction_nominal(&self, sst_k: f64, sdt_k: f64) -> f64 {
|
||||
let m_nominal = self.curves.mass_flow_curve.evaluate(sst_k, sdt_k);
|
||||
|
||||
// VFD scaling: ṁ ∝ frequency (volumetric machine)
|
||||
let m_scaled = m_nominal * self.frequency_ratio();
|
||||
m_nominal * self.frequency_ratio()
|
||||
}
|
||||
|
||||
/// Computes calibrated suction mass flow [kg/s].
|
||||
fn calc_mass_flow_suction(&self, sst_k: f64, sdt_k: f64, state: Option<&StateSlice>) -> f64 {
|
||||
let m_scaled = self.calc_mass_flow_suction_nominal(sst_k, sdt_k);
|
||||
|
||||
// Calibration factor f_m
|
||||
let f_m = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.f_m
|
||||
.z_flow
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.f_m)
|
||||
.unwrap_or(self.calib.z_flow)
|
||||
} else {
|
||||
self.calib.f_m
|
||||
self.calib.z_flow
|
||||
};
|
||||
|
||||
m_scaled * f_m
|
||||
}
|
||||
|
||||
/// Computes calibrated economizer injection mass flow [kg/s].
|
||||
fn calc_mass_flow_economizer(&self, sst_k: f64, sdt_k: f64, state: Option<&StateSlice>) -> f64 {
|
||||
let m_suc_nominal = self.calc_mass_flow_suction_nominal(sst_k, sdt_k);
|
||||
let x_eco = self.calc_eco_fraction(sst_k, sdt_k);
|
||||
let z_flow_eco = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.z_flow_eco
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.z_flow_eco)
|
||||
} else {
|
||||
self.calib.z_flow_eco
|
||||
};
|
||||
m_suc_nominal * x_eco * z_flow_eco
|
||||
}
|
||||
|
||||
/// Computes economizer mass flow fraction at current SST/SDT.
|
||||
fn calc_eco_fraction(&self, sst_k: f64, sdt_k: f64) -> f64 {
|
||||
self.curves
|
||||
@@ -395,24 +486,30 @@ impl ScrewEconomizerCompressor {
|
||||
.clamp(0.0, 0.4) // physical bound: eco fraction 0–40%
|
||||
}
|
||||
|
||||
/// Computes nominal shaft power [W] scaled by VFD and calibration.
|
||||
/// Computes nominal shaft power [W] scaled by VFD, Vi mismatch, and calibration.
|
||||
fn calc_power(&self, sst_k: f64, sdt_k: f64, state: Option<&StateSlice>) -> f64 {
|
||||
let w_nominal = self.curves.power_curve.evaluate(sst_k, sdt_k);
|
||||
|
||||
// VFD scaling: power ∝ frequency (approximately, for screw compressors)
|
||||
let w_scaled = w_nominal * self.frequency_ratio();
|
||||
|
||||
// Vi mismatch penalty (built-in volume ratio vs ideal polytropic).
|
||||
let p_suc = self.ports[0].pressure().to_pascals().max(1.0);
|
||||
let p_dis = self.ports[1].pressure().to_pascals().max(p_suc * 1.01);
|
||||
let vi_ideal = Self::ideal_volume_index(p_dis / p_suc, 1.14);
|
||||
let vi_pen = Self::vi_power_penalty(self.effective_volume_index(), vi_ideal);
|
||||
|
||||
// Calibration factor f_power
|
||||
let f_power = if let Some(st) = state {
|
||||
self.calib_indices
|
||||
.f_power
|
||||
.z_power
|
||||
.map(|idx| st[idx])
|
||||
.unwrap_or(self.calib.f_power)
|
||||
.unwrap_or(self.calib.z_power)
|
||||
} else {
|
||||
self.calib.f_power
|
||||
self.calib.z_power
|
||||
};
|
||||
|
||||
w_scaled * f_power
|
||||
w_scaled * vi_pen * f_power
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,7 +525,7 @@ impl Component for ScrewEconomizerCompressor {
|
||||
/// 4. Economizer pressure constraint
|
||||
/// 5. Shaft power balance
|
||||
fn n_equations(&self) -> usize {
|
||||
5
|
||||
6 // r0: ṁ_suc, r1: ṁ_eco, r2: energy, r3: P_eco, r4: W_shaft, r5: mass balance (CM1.3)
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
@@ -443,92 +540,98 @@ impl Component for ScrewEconomizerCompressor {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Operational state shortcuts ──────────────────────────────────────
|
||||
// ── Resolve edge ṁ indices (CM1.3) — fallback to internal slots for isolated tests ──
|
||||
let off = self.global_state_offset;
|
||||
// W_shaft is the only remaining internal variable (at offset 0 after CM1.3).
|
||||
// Fallback m_indices point into the old internal layout so isolated unit tests
|
||||
// (which call set_system_context with empty slice) still produce numeric residuals.
|
||||
let suc_m_idx = self.suction_m_idx.unwrap_or(off);
|
||||
let eco_m_idx = self.eco_m_idx.unwrap_or(off + 1);
|
||||
let dis_m_idx = self.discharge_m_idx.unwrap_or(off + 2);
|
||||
let w_off = off; // W_shaft at internal offset 0
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
// Zero flow, zero power
|
||||
residuals[0] = state.get(off).copied().unwrap_or(0.0); // ṁ_suc = 0
|
||||
residuals[1] = state.get(off + 1).copied().unwrap_or(0.0); // ṁ_eco = 0
|
||||
residuals[0] = state.get(suc_m_idx).copied().unwrap_or(0.0); // ṁ_suc = 0
|
||||
residuals[1] = state.get(eco_m_idx).copied().unwrap_or(0.0); // ṁ_eco = 0
|
||||
residuals[2] = 0.0;
|
||||
residuals[3] = 0.0;
|
||||
residuals[4] = state.get(off + 2).copied().unwrap_or(0.0); // W = 0
|
||||
residuals[4] = state.get(w_off).copied().unwrap_or(0.0); // W = 0
|
||||
// r5: ṁ_dis = ṁ_suc + ṁ_eco (all zero)
|
||||
residuals[5] = state.get(dis_m_idx).copied().unwrap_or(0.0)
|
||||
- state.get(suc_m_idx).copied().unwrap_or(0.0)
|
||||
- state.get(eco_m_idx).copied().unwrap_or(0.0);
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::Bypass => {
|
||||
// Adiabatic pass-through: P_dis = P_suc, h_dis = h_suc, no eco flow
|
||||
let p_suc = self.ports[0].pressure().to_pascals();
|
||||
let p_dis = self.ports[1].pressure().to_pascals();
|
||||
let h_suc = self.ports[0].enthalpy().to_joules_per_kg();
|
||||
let h_dis = self.ports[1].enthalpy().to_joules_per_kg();
|
||||
residuals[0] = p_suc - p_dis;
|
||||
residuals[1] = h_suc - h_dis;
|
||||
residuals[2] = state.get(off + 1).copied().unwrap_or(0.0); // ṁ_eco = 0
|
||||
residuals[2] = state.get(eco_m_idx).copied().unwrap_or(0.0); // ṁ_eco = 0
|
||||
residuals[3] = 0.0;
|
||||
residuals[4] = state.get(off + 2).copied().unwrap_or(0.0); // W = 0
|
||||
residuals[4] = state.get(w_off).copied().unwrap_or(0.0); // W = 0
|
||||
residuals[5] = state.get(dis_m_idx).copied().unwrap_or(0.0)
|
||||
- state.get(suc_m_idx).copied().unwrap_or(0.0)
|
||||
- state.get(eco_m_idx).copied().unwrap_or(0.0);
|
||||
return Ok(());
|
||||
}
|
||||
OperationalState::On => {}
|
||||
}
|
||||
|
||||
// ── Validate state vector ────────────────────────────────────────────
|
||||
if state.len() < off + 3 {
|
||||
let required_len = [suc_m_idx, eco_m_idx, dis_m_idx, w_off]
|
||||
.into_iter()
|
||||
.max()
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
if state.len() < required_len {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: off + 3,
|
||||
expected: required_len,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let m_suc_state = state[off]; // kg/s — solver variable
|
||||
let m_eco_state = state[off + 1]; // kg/s — solver variable
|
||||
let m_suc_state = state[suc_m_idx]; // kg/s — edge unknown (CM1.3)
|
||||
let m_eco_state = state[eco_m_idx]; // kg/s — edge unknown (CM1.3)
|
||||
let h_suc = self.ports[0].enthalpy().to_joules_per_kg();
|
||||
let h_dis = self.ports[1].enthalpy().to_joules_per_kg();
|
||||
let h_eco = self.ports[2].enthalpy().to_joules_per_kg();
|
||||
let w_state = state[off + 2]; // W — solver variable
|
||||
let w_state = state[w_off]; // W — sole internal variable after CM1.3
|
||||
|
||||
// ── Compute performance from curves ──────────────────────────────────
|
||||
let (sst_k, sdt_k) = self.estimate_sst_sdt_k();
|
||||
let m_suc_calc = self.calc_mass_flow_suction(sst_k, sdt_k, Some(state));
|
||||
let x_eco = self.calc_eco_fraction(sst_k, sdt_k);
|
||||
let m_eco_calc = m_suc_calc * x_eco;
|
||||
let m_eco_calc = self.calc_mass_flow_economizer(sst_k, sdt_k, Some(state));
|
||||
let w_calc = self.calc_power(sst_k, sdt_k, Some(state));
|
||||
|
||||
// ── Residual 0: Suction mass flow ────────────────────────────────────
|
||||
// r₀ = ṁ_suc_calc − ṁ_suc_state = 0
|
||||
residuals[0] = m_suc_calc - m_suc_state;
|
||||
|
||||
// ── Residual 1: Economizer mass flow ─────────────────────────────────
|
||||
// r₁ = ṁ_eco_calc − ṁ_eco_state = 0
|
||||
residuals[1] = m_eco_calc - m_eco_state;
|
||||
|
||||
// ── Residual 2: First-law energy balance ─────────────────────────────
|
||||
// ṁ_suc × h_suc + ṁ_eco × h_eco + W_shaft × η_mech = ṁ_total × h_dis
|
||||
// r₂ = (ṁ_suc × h_suc + ṁ_eco × h_eco + W_shaft × η) − ṁ_total × h_dis = 0
|
||||
//
|
||||
// W_shaft is the shaft (input) power. Only W_shaft × η_mech reaches the fluid;
|
||||
// the rest (1 - η_mech) is lost to bearing friction and motor heat.
|
||||
let energy_in =
|
||||
m_suc_state * h_suc + m_eco_state * h_eco + w_state * self.mechanical_efficiency;
|
||||
let energy_out = (m_suc_state + m_eco_state) * h_dis;
|
||||
residuals[2] = energy_in - energy_out;
|
||||
|
||||
// ── Residual 3: Economizer pressure (geometric mean) ─────────────────
|
||||
// The economizer injection pressure is typically the geometric mean of
|
||||
// suction and discharge pressures for optimal performance.
|
||||
// P_eco_set = sqrt(P_suc × P_dis)
|
||||
// r₃ = P_eco_port − P_eco_set = 0
|
||||
let p_suc = self.ports[0].pressure().to_pascals();
|
||||
let p_dis = self.ports[1].pressure().to_pascals();
|
||||
let p_eco_port = self.ports[2].pressure().to_pascals();
|
||||
let p_eco_set = (p_suc * p_dis).sqrt();
|
||||
// Scale residual to Pa (same order of magnitude as pressures in system)
|
||||
residuals[3] = p_eco_port - p_eco_set;
|
||||
|
||||
// ── Residual 4: Shaft power ──────────────────────────────────────────
|
||||
// r₄ = W_calc − W_state = 0
|
||||
residuals[4] = w_calc - w_state;
|
||||
|
||||
// ── Residual 5: Mass balance ṁ_dis = ṁ_suc + ṁ_eco (CM1.3) ────────
|
||||
residuals[5] = state[dis_m_idx] - m_suc_state - m_eco_state;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -538,60 +641,57 @@ impl Component for ScrewEconomizerCompressor {
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let off = self.global_state_offset;
|
||||
let suc_m_idx = self.suction_m_idx.unwrap_or(off);
|
||||
let eco_m_idx = self.eco_m_idx.unwrap_or(off + 1);
|
||||
let dis_m_idx = self.discharge_m_idx.unwrap_or(off + 2);
|
||||
let w_off = off; // W_shaft at internal offset 0
|
||||
|
||||
if state.len() < off + 3 {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: off + 3,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let m_suc_state = state[off];
|
||||
let m_eco_state = state[off + 1];
|
||||
let h_suc = self.ports[0].enthalpy().to_joules_per_kg();
|
||||
let h_dis = self.ports[1].enthalpy().to_joules_per_kg();
|
||||
let h_eco = self.ports[2].enthalpy().to_joules_per_kg();
|
||||
|
||||
// Row 0: ∂r₀/∂ṁ_suc = -1
|
||||
jacobian.add_entry(0, off, -1.0);
|
||||
// Row 0: ∂r₀/∂ṁ_suc = -1 (exact, CM1.3)
|
||||
jacobian.add_entry(0, suc_m_idx, -1.0);
|
||||
|
||||
// Row 1: ∂r₁/∂ṁ_eco = -1
|
||||
jacobian.add_entry(1, off + 1, -1.0);
|
||||
// Row 1: ∂r₁/∂ṁ_eco = -1 (exact, CM1.3)
|
||||
jacobian.add_entry(1, eco_m_idx, -1.0);
|
||||
|
||||
// Row 2: energy balance partial derivatives
|
||||
// ∂r₂/∂ṁ_suc = h_suc − h_dis
|
||||
jacobian.add_entry(2, off, h_suc - h_dis);
|
||||
// ∂r₂/∂ṁ_eco = h_eco − h_dis
|
||||
jacobian.add_entry(2, off + 1, h_eco - h_dis);
|
||||
// ∂r₂/∂W = 1 / η_mech
|
||||
jacobian.add_entry(2, off + 2, 1.0 / self.mechanical_efficiency);
|
||||
jacobian.add_entry(2, suc_m_idx, h_suc - h_dis);
|
||||
jacobian.add_entry(2, eco_m_idx, h_eco - h_dis);
|
||||
jacobian.add_entry(2, w_off, self.mechanical_efficiency);
|
||||
|
||||
// Row 3: economizer pressure — no dependency on mass flows or power
|
||||
// (depends on port pressures which are graph state, not component state)
|
||||
jacobian.add_entry(3, off, 0.0);
|
||||
// Row 3: economizer pressure — no mass-flow dependency
|
||||
// (depends only on port pressures, which are edge P unknowns, not captured here)
|
||||
|
||||
// Row 4: ∂r₄/∂W_state = -1
|
||||
jacobian.add_entry(4, off + 2, -1.0);
|
||||
jacobian.add_entry(4, w_off, -1.0);
|
||||
|
||||
// Row 5: mass balance r5 = ṁ_dis − ṁ_suc − ṁ_eco (exact, CM1.3)
|
||||
jacobian.add_entry(5, dis_m_idx, 1.0);
|
||||
jacobian.add_entry(5, suc_m_idx, -1.0);
|
||||
jacobian.add_entry(5, eco_m_idx, -1.0);
|
||||
|
||||
// Calibration Jacobian entries
|
||||
if let Some(f_m_idx) = self.calib_indices.f_m {
|
||||
if let Some(z_flow_idx) = self.calib_indices.z_flow {
|
||||
let (sst_k, sdt_k) = self.estimate_sst_sdt_k();
|
||||
let m_nominal =
|
||||
self.curves.mass_flow_curve.evaluate(sst_k, sdt_k) * self.frequency_ratio();
|
||||
jacobian.add_entry(0, f_m_idx, m_nominal);
|
||||
// eco also depends on f_m via the fraction
|
||||
let x_eco = self.calc_eco_fraction(sst_k, sdt_k);
|
||||
jacobian.add_entry(1, f_m_idx, m_nominal * x_eco);
|
||||
let m_nominal = self.calc_mass_flow_suction_nominal(sst_k, sdt_k);
|
||||
jacobian.add_entry(0, z_flow_idx, m_nominal);
|
||||
}
|
||||
if let Some(f_power_idx) = self.calib_indices.f_power {
|
||||
if let Some(z_flow_eco_idx) = self.calib_indices.z_flow_eco {
|
||||
let (sst_k, sdt_k) = self.estimate_sst_sdt_k();
|
||||
let m_nominal = self.calc_mass_flow_suction_nominal(sst_k, sdt_k);
|
||||
let x_eco = self.calc_eco_fraction(sst_k, sdt_k);
|
||||
jacobian.add_entry(1, z_flow_eco_idx, m_nominal * x_eco);
|
||||
}
|
||||
if let Some(z_power_idx) = self.calib_indices.z_power {
|
||||
let (sst_k, sdt_k) = self.estimate_sst_sdt_k();
|
||||
let w_nominal = self.curves.power_curve.evaluate(sst_k, sdt_k) * self.frequency_ratio();
|
||||
jacobian.add_entry(4, f_power_idx, w_nominal);
|
||||
jacobian.add_entry(4, z_power_idx, w_nominal);
|
||||
}
|
||||
|
||||
// Suppress unused variable warnings for state variables used only for
|
||||
// context (not in simplified Jacobian above)
|
||||
let _ = (m_suc_state, m_eco_state);
|
||||
// Suppress unused state variable access (edge ṁ and h are read via port values)
|
||||
let _ = state;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -600,30 +700,63 @@ impl Component for ScrewEconomizerCompressor {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
vec![
|
||||
"suction".to_string(),
|
||||
"discharge".to_string(),
|
||||
"economizer".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn internal_state_len(&self) -> usize {
|
||||
// 3 internal variables: [ṁ_suc, ṁ_eco, W_shaft]
|
||||
3
|
||||
// 1 internal variable: [W_shaft] — ṁ_suc and ṁ_eco moved to edge unknowns (CM1.3)
|
||||
1
|
||||
}
|
||||
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
state_offset: usize,
|
||||
_external_edge_state_indices: &[(usize, usize)],
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
self.global_state_offset = state_offset;
|
||||
// Layout: incoming edges first (suction, economizer), then outgoing (discharge).
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
// Typical ordering: [0]=suction(incoming), [1]=discharge(outgoing), [2]=economizer(incoming)
|
||||
// Map by ports order: port[0]=suction, port[1]=discharge, port[2]=economizer
|
||||
let incoming_iter = external_edge_state_indices.iter().take_while(|_| true); // all incoming edges come first in finalize()
|
||||
// The exact ordering depends on graph traversal; use index-based approach:
|
||||
// incoming[0] = suction edge, incoming[1] = economizer edge, outgoing[0] = discharge edge
|
||||
let n = external_edge_state_indices.len();
|
||||
if n >= 1 {
|
||||
self.suction_m_idx = Some(external_edge_state_indices[0].0);
|
||||
}
|
||||
if n >= 2 {
|
||||
// Second incoming is economizer (or outgoing discharge if only 1 incoming)
|
||||
// For 3-port: incoming = [suction, economizer], outgoing = [discharge]
|
||||
// finalize() appends incoming first then outgoing, so:
|
||||
// [0]=suction, [1]=economizer, [2]=discharge
|
||||
self.eco_m_idx = Some(external_edge_state_indices[1].0);
|
||||
}
|
||||
if n >= 3 {
|
||||
self.discharge_m_idx = Some(external_edge_state_indices[2].0);
|
||||
}
|
||||
let _ = incoming_iter; // suppress warning
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
let off = self.global_state_offset;
|
||||
if state.len() < off + 2 {
|
||||
let suc_m_idx = self.suction_m_idx.unwrap_or(off);
|
||||
let eco_m_idx = self.eco_m_idx.unwrap_or(off + 1);
|
||||
let required_len = suc_m_idx.max(eco_m_idx) + 1;
|
||||
if state.len() < required_len {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: off + 2,
|
||||
expected: required_len,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
let m_suc = MassFlow::from_kg_per_s(state[off]);
|
||||
let m_eco = MassFlow::from_kg_per_s(state[off + 1]);
|
||||
let m_total = MassFlow::from_kg_per_s(state[off] + state[off + 1]);
|
||||
let m_suc = MassFlow::from_kg_per_s(state[suc_m_idx]);
|
||||
let m_eco = MassFlow::from_kg_per_s(state[eco_m_idx]);
|
||||
let m_total = MassFlow::from_kg_per_s(state[suc_m_idx] + state[eco_m_idx]);
|
||||
Ok(vec![
|
||||
m_suc, // suction in (+)
|
||||
MassFlow::from_kg_per_s(-m_total.to_kg_per_s()), // discharge out (-)
|
||||
@@ -631,6 +764,10 @@ impl Component for ScrewEconomizerCompressor {
|
||||
])
|
||||
}
|
||||
|
||||
fn port_enthalpies(&self, _state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
Ok(self.ports.iter().map(|port| port.enthalpy()).collect())
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
match self.operational_state {
|
||||
OperationalState::Off | OperationalState::Bypass => {
|
||||
@@ -638,11 +775,11 @@ impl Component for ScrewEconomizerCompressor {
|
||||
}
|
||||
OperationalState::On => {
|
||||
let off = self.global_state_offset;
|
||||
if state.len() < off + 3 {
|
||||
if state.len() <= off {
|
||||
return None;
|
||||
}
|
||||
let w = state[off + 2]; // shaft power W
|
||||
// Work done ON the compressor → negative sign convention
|
||||
let w = state[off]; // shaft power W (CM1.3: sole internal variable)
|
||||
// Work done ON the compressor → negative sign convention
|
||||
Some((Power::from_watts(0.0), Power::from_watts(-w)))
|
||||
}
|
||||
}
|
||||
@@ -662,11 +799,17 @@ impl Component for ScrewEconomizerCompressor {
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("ScrewEconomizerCompressor")
|
||||
.with_param("fluid", self.fluid_id.as_str())
|
||||
.with_param("nominalFrequencyHz", self.nominal_frequency_hz)
|
||||
.with_param("frequencyHz", self.frequency_hz)
|
||||
.with_param("mechanicalEfficiency", self.mechanical_efficiency)
|
||||
.with_param("curves", serde_json::to_value(&self.curves).unwrap_or(serde_json::Value::Null))
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||||
.with_param("nominal_frequency_hz", self.nominal_frequency_hz)
|
||||
.with_param("frequency_hz", self.frequency_hz)
|
||||
.with_param("mechanical_efficiency", self.mechanical_efficiency)
|
||||
.with_param(
|
||||
"curves",
|
||||
serde_json::to_value(&self.curves).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -772,7 +915,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
let comp = make_compressor();
|
||||
assert_eq!(comp.n_equations(), 5);
|
||||
// CM1.3: 5 thermo/performance + 1 mass-balance = 6
|
||||
assert_eq!(comp.n_equations(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -816,11 +960,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_ok_with_reasonable_state() {
|
||||
let comp = make_compressor();
|
||||
// Provide a reasonable state vector: [ṁ_suc, ṁ_eco, W]
|
||||
// Values are approximate — residuals won't be zero but must be finite
|
||||
let state = vec![1.0, 0.12, 50_000.0];
|
||||
let mut residuals = vec![0.0; 5];
|
||||
let mut comp = make_compressor();
|
||||
// CM1.3: stride-3 edge layout; suc=(0,1,2), eco=(3,4,5), dis=(6,7,8); W at 9
|
||||
comp.set_system_context(9, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
let mut state = vec![0.0; 10];
|
||||
state[0] = 1.0; // ṁ_suc
|
||||
state[3] = 0.12; // ṁ_eco
|
||||
state[9] = 50_000.0; // W_shaft
|
||||
let mut residuals = vec![0.0; 6];
|
||||
let result = comp.compute_residuals(&state, &mut residuals);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
@@ -836,8 +983,10 @@ mod tests {
|
||||
fn test_compute_residuals_off_state() {
|
||||
let mut comp = make_compressor();
|
||||
comp.set_state(OperationalState::Off).unwrap();
|
||||
let state = vec![0.0; 5];
|
||||
let mut residuals = vec![0.0; 5];
|
||||
// CM1.3: stride-3 edge layout; W at offset 9
|
||||
comp.set_system_context(9, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 6];
|
||||
comp.compute_residuals(&state, &mut residuals).unwrap();
|
||||
// In Off state, all residuals should be zero
|
||||
for r in &residuals {
|
||||
@@ -872,7 +1021,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_energy_transfers_on() {
|
||||
let comp = make_compressor();
|
||||
let state = vec![1.0, 0.12, 55_000.0];
|
||||
// CM1.3: W_shaft is the only internal var, at global_state_offset=0 by default
|
||||
let state = vec![55_000.0]; // state[0] = W_shaft
|
||||
let (q, w) = comp.energy_transfers(&state).unwrap();
|
||||
assert_eq!(q.to_watts(), 0.0);
|
||||
assert!((w.to_watts() + 55_000.0).abs() < 1e-6);
|
||||
@@ -898,6 +1048,84 @@ mod tests {
|
||||
assert!(!jac.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_jacobian_uses_mechanical_efficiency() {
|
||||
let mut comp = make_compressor();
|
||||
comp.set_system_context(3, &[(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
let state = vec![1.0, 0.12, 1.12, 55_000.0];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
comp.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
let dw_entry = jac
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|&&(row, col, _)| row == 2 && col == 3)
|
||||
.copied()
|
||||
.expect("energy row should include W derivative");
|
||||
assert!((dw_entry.2 - comp.mechanical_efficiency()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_names_and_to_params_match_cli_contract() {
|
||||
let comp = make_compressor();
|
||||
assert_eq!(
|
||||
comp.port_names(),
|
||||
vec![
|
||||
"suction".to_string(),
|
||||
"discharge".to_string(),
|
||||
"economizer".to_string()
|
||||
]
|
||||
);
|
||||
|
||||
let params = comp.to_params();
|
||||
assert_eq!(params.component_type, "ScrewEconomizerCompressor");
|
||||
assert!(params.get("nominal_frequency_hz").is_some());
|
||||
assert!(params.get("frequency_hz").is_some());
|
||||
assert!(params.get("mechanical_efficiency").is_some());
|
||||
assert!(params.get("nominalFrequencyHz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_z_flow_eco_scales_only_economizer_flow() {
|
||||
let mut base = make_compressor();
|
||||
base.set_system_context(3, &[(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
let state = vec![0.0, 0.0, 0.0, 55_000.0];
|
||||
|
||||
let mut base_residuals = vec![0.0; 6];
|
||||
base.compute_residuals(&state, &mut base_residuals).unwrap();
|
||||
|
||||
let mut suc_scaled = make_compressor();
|
||||
suc_scaled.set_system_context(3, &[(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
suc_scaled.set_calib(Calib {
|
||||
z_flow: 1.5,
|
||||
..Calib::default()
|
||||
});
|
||||
let mut suc_residuals = vec![0.0; 6];
|
||||
suc_scaled
|
||||
.compute_residuals(&state, &mut suc_residuals)
|
||||
.unwrap();
|
||||
|
||||
let mut eco_scaled = make_compressor();
|
||||
eco_scaled.set_system_context(3, &[(0, 0, 0), (1, 0, 0), (2, 0, 0)]);
|
||||
eco_scaled.set_calib(Calib {
|
||||
z_flow_eco: 1.5,
|
||||
..Calib::default()
|
||||
});
|
||||
let mut eco_residuals = vec![0.0; 6];
|
||||
eco_scaled
|
||||
.compute_residuals(&state, &mut eco_residuals)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
(suc_residuals[1] - base_residuals[1]).abs() < 1e-12,
|
||||
"z_flow must not scale economizer residual"
|
||||
);
|
||||
assert!(
|
||||
eco_residuals[1] > base_residuals[1],
|
||||
"z_flow_eco should scale economizer residual"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature() {
|
||||
let comp = make_compressor();
|
||||
@@ -943,7 +1171,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_internal_state_len() {
|
||||
let comp = make_compressor();
|
||||
assert_eq!(comp.internal_state_len(), 3);
|
||||
// CM1.3: ṁ_suc and ṁ_eco moved to edge state; only W_shaft remains internal
|
||||
assert_eq!(comp.internal_state_len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -957,16 +1186,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_compute_residuals_with_offset() {
|
||||
let mut comp = make_compressor();
|
||||
// Place internal state at offset 6 (simulating 3 edges × 2 vars = 6)
|
||||
comp.set_system_context(6, &[]);
|
||||
// CM1.3: 3 edges × 3 vars = 9 edge slots; W_shaft at offset 9
|
||||
comp.set_system_context(9, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
|
||||
|
||||
// Build state: 6 edge vars (zeros) + 3 internal vars
|
||||
let mut state = vec![0.0; 9];
|
||||
state[6] = 1.0; // ṁ_suc at offset+0
|
||||
state[7] = 0.12; // ṁ_eco at offset+1
|
||||
state[8] = 50_000.0; // W at offset+2
|
||||
// Build state: 9 edge vars + 1 internal (W_shaft)
|
||||
let mut state = vec![0.0; 10];
|
||||
state[0] = 1.0; // ṁ_suc at edge[0].m
|
||||
state[3] = 0.12; // ṁ_eco at edge[1].m
|
||||
state[9] = 50_000.0; // W_shaft at internal offset 9
|
||||
|
||||
let mut residuals = vec![0.0; 5];
|
||||
let mut residuals = vec![0.0; 6];
|
||||
let result = comp.compute_residuals(&state, &mut residuals);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
@@ -1018,14 +1247,23 @@ mod tests {
|
||||
#[test]
|
||||
fn test_energy_transfers_with_offset() {
|
||||
let mut comp = make_compressor();
|
||||
// CM1.3: W_shaft is at global_state_offset; edge mass flows are separate
|
||||
comp.set_system_context(4, &[]);
|
||||
let mut state = vec![0.0; 7];
|
||||
state[4] = 1.0;
|
||||
state[5] = 0.12;
|
||||
state[6] = 55_000.0;
|
||||
let mut state = vec![0.0; 5];
|
||||
state[4] = 55_000.0; // W_shaft at offset 4 (internal offset)
|
||||
comp.set_state(OperationalState::On).unwrap();
|
||||
let transfers = comp.energy_transfers(&state).unwrap();
|
||||
let w = transfers.1;
|
||||
assert!((w.to_watts() + 55_000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vi_penalty_and_slide_valve() {
|
||||
assert!((ScrewEconomizerCompressor::vi_power_penalty(2.8, 2.8) - 1.0).abs() < 1e-12);
|
||||
assert!(ScrewEconomizerCompressor::vi_power_penalty(5.0, 2.5) > 1.0);
|
||||
let mut comp = make_compressor();
|
||||
comp.set_volume_index_for_test(4.0);
|
||||
comp.set_slide_for_test(0.5);
|
||||
assert!((comp.effective_volume_index() - (1.0 + 3.0 * 0.5)).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
399
crates/components/src/thermal_load.rs
Normal file
399
crates/components/src/thermal_load.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! Thermal load component: the cold-side receiver of an inter-circuit
|
||||
//! thermal coupling.
|
||||
//!
|
||||
//! A `ThermalLoad` models a hydronic load segment (e.g. the cooling-water side
|
||||
//! of a shared heat exchanger) that receives an **externally-determined heat
|
||||
//! rate Q [W]** from the solver's thermal-coupling layer.
|
||||
//!
|
||||
//! Following the BOLT/Modelica boundary pattern, the loop's pressure and
|
||||
//! inlet temperature are fixed by **boundary components** (`BrineSource` for
|
||||
//! P_set/T_in, `BrineSink` for the back-pressure, temperature left free), NOT
|
||||
//! by this component. The load itself only contributes:
|
||||
//!
|
||||
//! ```text
|
||||
//! r0: ṁ − ṁ_design (imposed design flow)
|
||||
//! r1: ṁ_design·(h_out − h_in) − Q_ext (energy balance, Q_ext = state[q_idx])
|
||||
//! ```
|
||||
//!
|
||||
//! Topology (mirrors `BOLT.BoundaryNode.Coolant.Source → HX → Sink`):
|
||||
//!
|
||||
//! ```text
|
||||
//! BrineSource(P,T_in) ──edge──▶ ThermalLoad ──edge──▶ BrineSink(P_back, T free)
|
||||
//! ```
|
||||
//!
|
||||
//! The energy balance uses the **design** flow (a constant), not the ṁ unknown:
|
||||
//! r0 already pins ṁ = ṁ_design exactly, so the two forms coincide at the
|
||||
//! solution — but the constant form keeps the block linear and avoids a
|
||||
//! structurally singular Jacobian when the initializer starts at ṁ = 0
|
||||
//! (the bilinear form's ∂r1/∂h_out = ṁ would then vanish).
|
||||
//!
|
||||
//! `Q_ext` is read from the per-coupling state unknown wired by
|
||||
//! `System::finalize()` via [`Component::set_external_heat_index`]. When no
|
||||
//! coupling is wired, `Q_ext = 0` and the load is an adiabatic pass-through
|
||||
//! with imposed flow. The outlet temperature is **emergent**:
|
||||
//! `T_out = T_in + Q/(ṁ·cp)`.
|
||||
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput, ResidualVector,
|
||||
StateSlice,
|
||||
};
|
||||
|
||||
/// Cold-side thermal load with imposed design mass flow and externally-injected
|
||||
/// heat rate (see module docs). Pair with `BrineSource`/`BrineSink` boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThermalLoad {
|
||||
name: String,
|
||||
/// Imposed design mass flow [kg/s].
|
||||
mass_flow_kg_s: f64,
|
||||
/// State index of the external heat unknown Q [W] (wired by the solver).
|
||||
q_idx: Option<usize>,
|
||||
/// Inlet edge (m, h) state indices.
|
||||
inlet_m_idx: Option<usize>,
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Outlet edge h state index.
|
||||
outlet_h_idx: Option<usize>,
|
||||
circuit_id: CircuitId,
|
||||
operational_state: OperationalState,
|
||||
}
|
||||
|
||||
impl ThermalLoad {
|
||||
/// Creates a thermal load with the given imposed design mass flow [kg/s].
|
||||
pub fn new(name: impl Into<String>, mass_flow_kg_s: f64) -> Result<Self, ComponentError> {
|
||||
if !(mass_flow_kg_s.is_finite() && mass_flow_kg_s > 0.0) {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"ThermalLoad: mass_flow_kg_s must be positive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.into(),
|
||||
mass_flow_kg_s,
|
||||
q_idx: None,
|
||||
inlet_m_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_h_idx: None,
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// Returns the imposed design mass flow [kg/s].
|
||||
pub fn mass_flow_kg_s(&self) -> f64 {
|
||||
self.mass_flow_kg_s
|
||||
}
|
||||
|
||||
/// Returns the wired external-heat state index, if any.
|
||||
pub fn external_heat_index(&self) -> Option<usize> {
|
||||
self.q_idx
|
||||
}
|
||||
|
||||
/// External heat rate Q [W] read from the state (0 when not wired).
|
||||
fn q_ext(&self, state: &StateSlice) -> f64 {
|
||||
match self.q_idx {
|
||||
Some(i) if i < state.len() && state[i].is_finite() => state[i],
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn wired_indices(&self) -> Option<(usize, usize, usize)> {
|
||||
Some((self.inlet_m_idx?, self.inlet_h_idx?, self.outlet_h_idx?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ThermalLoad {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge (inlet), [1] = outgoing edge (outlet).
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
let (m, _p, h) = external_edge_state_indices[0];
|
||||
self.inlet_m_idx = Some(m);
|
||||
self.inlet_h_idx = Some(h);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
let (_, _p, h) = external_edge_state_indices[1];
|
||||
self.outlet_h_idx = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_external_heat_index(&mut self, idx: usize) {
|
||||
self.q_idx = Some(idx);
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let Some((m_idx, in_h, out_h)) = self.wired_indices() else {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"ThermalLoad requires live inlet/outlet edge state indices".to_string(),
|
||||
));
|
||||
};
|
||||
let m_dot = state[m_idx];
|
||||
let h_in = state[in_h];
|
||||
let h_out = state[out_h];
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
residuals[0] = m_dot; // ṁ = 0
|
||||
residuals[1] = h_out - h_in; // adiabatic
|
||||
}
|
||||
OperationalState::Bypass => {
|
||||
residuals[0] = m_dot - self.mass_flow_kg_s;
|
||||
residuals[1] = h_out - h_in; // adiabatic pass-through
|
||||
}
|
||||
OperationalState::On => {
|
||||
residuals[0] = m_dot - self.mass_flow_kg_s;
|
||||
// Linear form with the design flow (see module docs).
|
||||
residuals[1] = self.mass_flow_kg_s * (h_out - h_in) - self.q_ext(state);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let Some((m_idx, in_h, out_h)) = self.wired_indices() else {
|
||||
return Ok(());
|
||||
};
|
||||
jacobian.add_entry(0, m_idx, 1.0);
|
||||
match self.operational_state {
|
||||
OperationalState::Off | OperationalState::Bypass => {
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
}
|
||||
OperationalState::On => {
|
||||
jacobian.add_entry(1, out_h, self.mass_flow_kg_s);
|
||||
jacobian.add_entry(1, in_h, -self.mass_flow_kg_s);
|
||||
if let Some(q_idx) = self.q_idx {
|
||||
jacobian.add_entry(1, q_idx, -1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
let m = self
|
||||
.inlet_m_idx
|
||||
.filter(|&i| i < state.len())
|
||||
.map(|i| state[i])
|
||||
.unwrap_or(0.0);
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(m),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-m),
|
||||
])
|
||||
}
|
||||
|
||||
fn port_enthalpies(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||||
let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"ThermalLoad: not wired to system context".to_string(),
|
||||
));
|
||||
};
|
||||
if in_h >= state.len() || out_h >= state.len() {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"ThermalLoad: state indices out of bounds".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(vec![
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[in_h]),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(state[out_h]),
|
||||
])
|
||||
}
|
||||
|
||||
fn energy_transfers(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||||
// Heat added TO the component (from the coupled hot circuit) is positive.
|
||||
Some((
|
||||
entropyk_core::Power::from_watts(self.q_ext(state)),
|
||||
entropyk_core::Power::from_watts(0.0),
|
||||
))
|
||||
}
|
||||
|
||||
fn counts_in_cycle_performance(&self) -> bool {
|
||||
// The absorbed Q is the primary cycle's rejected duty — do not count
|
||||
// it as additional cooling capacity in COP aggregation.
|
||||
false
|
||||
}
|
||||
|
||||
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||||
match kind {
|
||||
// Received coupled heat [W].
|
||||
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
|
||||
Some(self.q_ext(state).abs())
|
||||
}
|
||||
MeasuredOutput::MassFlowRate => {
|
||||
let m_idx = self.inlet_m_idx?;
|
||||
(m_idx < state.len()).then(|| state[m_idx])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"ThermalLoad(name={}, m_design={:.4} kg/s, circuit={})",
|
||||
self.name, self.mass_flow_kg_s, self.circuit_id.0
|
||||
)
|
||||
}
|
||||
|
||||
fn to_params(&self) -> crate::ComponentParams {
|
||||
crate::ComponentParams::new("ThermalLoad")
|
||||
.with_param("name", self.name.as_str())
|
||||
.with_param("massFlowKgS", self.mass_flow_kg_s)
|
||||
.with_param("circuitId", self.circuit_id.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for ThermalLoad {
|
||||
fn state(&self) -> OperationalState {
|
||||
self.operational_state
|
||||
}
|
||||
|
||||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
if self.operational_state.can_transition_to(state) {
|
||||
self.operational_state = state;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ComponentError::InvalidStateTransition {
|
||||
from: self.operational_state,
|
||||
to: state,
|
||||
reason: "Transition not allowed".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||||
self.operational_state.can_transition_to(target)
|
||||
}
|
||||
|
||||
fn circuit_id(&self) -> &CircuitId {
|
||||
&self.circuit_id
|
||||
}
|
||||
|
||||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||||
self.circuit_id = circuit_id;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wired_load() -> ThermalLoad {
|
||||
let mut load = ThermalLoad::new("cw_load", 0.5).unwrap();
|
||||
// inlet edge: (m=0, p=1, h=2); outlet edge: (m=0, p=3, h=4); Q at 5.
|
||||
load.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
load.set_external_heat_index(5);
|
||||
load
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_nonpositive_flow() {
|
||||
assert!(ThermalLoad::new("x", 0.0).is_err());
|
||||
assert!(ThermalLoad::new("x", -1.0).is_err());
|
||||
assert!(ThermalLoad::new("x", f64::NAN).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_balance_residual_closed() {
|
||||
let load = wired_load();
|
||||
// ṁ = 0.5, h_in = 100 kJ/kg, h_out = 120 kJ/kg, Q = 10 kW
|
||||
let state = vec![0.5, 2.0e5, 100_000.0, 2.0e5, 120_000.0, 10_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
load.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[0].abs() < 1e-12, "flow residual: {}", r[0]);
|
||||
assert!(r[1].abs() < 1e-9, "energy residual: {}", r[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_residual_detects_imbalance() {
|
||||
let load = wired_load();
|
||||
// Q says 10 kW but the stream only picks up 5 kW.
|
||||
let state = vec![0.5, 2.0e5, 100_000.0, 2.0e5, 110_000.0, 10_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
load.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(
|
||||
(r[1] - (-5_000.0)).abs() < 1e-9,
|
||||
"energy residual: {}",
|
||||
r[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_matches_fd() {
|
||||
let load = wired_load();
|
||||
let state = vec![0.45, 2.0e5, 101_000.0, 1.9e5, 119_000.0, 9_500.0];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
load.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
let eps = 1e-3;
|
||||
for col in [0usize, 2, 4, 5] {
|
||||
let mut sp = state.clone();
|
||||
let mut sm = state.clone();
|
||||
sp[col] += eps;
|
||||
sm[col] -= eps;
|
||||
let (mut rp, mut rm) = (vec![0.0; 2], vec![0.0; 2]);
|
||||
load.compute_residuals(&sp, &mut rp).unwrap();
|
||||
load.compute_residuals(&sm, &mut rm).unwrap();
|
||||
for row in 0..2 {
|
||||
let fd = (rp[row] - rm[row]) / (2.0 * eps);
|
||||
let analytic: f64 = jac
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|(r, c, _)| *r == row && *c == col)
|
||||
.map(|(_, _, v)| *v)
|
||||
.sum();
|
||||
assert!(
|
||||
(fd - analytic).abs() < 1e-6 * (1.0 + fd.abs()),
|
||||
"row {row} col {col}: fd {fd} vs analytic {analytic}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unwired_q_is_zero() {
|
||||
let mut load = ThermalLoad::new("cw_load", 0.5).unwrap();
|
||||
load.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]);
|
||||
// No external heat wired: adiabatic (h_out must equal h_in).
|
||||
let state = vec![0.5, 2.0e5, 100_000.0, 2.0e5, 100_000.0];
|
||||
let mut r = vec![0.0; 2];
|
||||
load.compute_residuals(&state, &mut r).unwrap();
|
||||
assert!(r[1].abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
197
crates/components/src/valve_flow.rs
Normal file
197
crates/components/src/valve_flow.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Physical mass-flow models for expansion devices.
|
||||
//!
|
||||
//! Complements the isenthalpic `ExpansionValve` residual structure with
|
||||
//! catalogue / TXV flow equations used for sizing and EXV/TXV actuation.
|
||||
|
||||
/// Selectable orifice / TXV / EXV flow model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ValveFlowModel {
|
||||
/// Simple orifice: `ṁ = β · opening · √(2 ρ ΔP)`.
|
||||
IsenthalpicOrifice {
|
||||
/// Effective flow area constant β [m²].
|
||||
beta_m2: f64,
|
||||
},
|
||||
/// Electronic expansion valve: `ṁ = C_d · A_max · opening · √(2 ρ ΔP)`.
|
||||
ExvCdA {
|
||||
/// Discharge coefficient [-].
|
||||
cd: f64,
|
||||
/// Maximum orifice area [m²].
|
||||
area_max_m2: f64,
|
||||
},
|
||||
/// Eames–Milazzo–Maidment (2014) TXV model (liquid-charged bulb).
|
||||
TxvEames {
|
||||
/// Flow-area constant β [m²].
|
||||
beta_m2: f64,
|
||||
/// Static superheat setting pressure equivalent α [Pa].
|
||||
static_superheat_pa: f64,
|
||||
/// Full-open bulb ΔP = δ [Pa].
|
||||
full_open_delta_pa: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for ValveFlowModel {
|
||||
fn default() -> Self {
|
||||
Self::IsenthalpicOrifice { beta_m2: 1.0e-6 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs for valve mass-flow evaluation (SI).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ValveFlowInput {
|
||||
/// Upstream density [kg/m³] (usually saturated / subcooled liquid).
|
||||
pub density_kg_m3: f64,
|
||||
/// Condenser / upstream pressure [Pa].
|
||||
pub p_upstream_pa: f64,
|
||||
/// Evaporator / downstream pressure [Pa].
|
||||
pub p_downstream_pa: f64,
|
||||
/// Normalized opening in [0, 1] (EXV / orifice).
|
||||
pub opening: f64,
|
||||
/// Bulb pressure [Pa] (TXV); ignored for orifice/EXV.
|
||||
pub p_bulb_pa: f64,
|
||||
}
|
||||
|
||||
impl ValveFlowInput {
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if !self.density_kg_m3.is_finite() || self.density_kg_m3 <= 0.0 {
|
||||
return Err(format!("density must be positive, got {}", self.density_kg_m3));
|
||||
}
|
||||
if !self.p_upstream_pa.is_finite() || !self.p_downstream_pa.is_finite() {
|
||||
return Err("pressures must be finite".into());
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.opening) || !self.opening.is_finite() {
|
||||
return Err(format!("opening must be in [0,1], got {}", self.opening));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Mass flow [kg/s] for the selected model. Always ≥ 0.
|
||||
pub fn valve_mass_flow(model: &ValveFlowModel, input: &ValveFlowInput) -> Result<f64, String> {
|
||||
input.validate()?;
|
||||
let dp = (input.p_upstream_pa - input.p_downstream_pa).max(0.0);
|
||||
let sqrt_term = (2.0 * input.density_kg_m3 * dp).sqrt();
|
||||
let m = match model {
|
||||
ValveFlowModel::IsenthalpicOrifice { beta_m2 } => {
|
||||
beta_m2.max(0.0) * input.opening.clamp(0.0, 1.0) * sqrt_term
|
||||
}
|
||||
ValveFlowModel::ExvCdA { cd, area_max_m2 } => {
|
||||
cd.max(0.0) * area_max_m2.max(0.0) * input.opening.clamp(0.0, 1.0) * sqrt_term
|
||||
}
|
||||
ValveFlowModel::TxvEames {
|
||||
beta_m2,
|
||||
static_superheat_pa,
|
||||
full_open_delta_pa,
|
||||
} => {
|
||||
// Eames: ṁ = β √(2ρ(Pc−Pe)) · [(Pb−Pe) − α] / δ for α < (Pb−Pe) ≤ δ
|
||||
// fully open when (Pb−Pe) ≥ δ → factor 1.
|
||||
let drive = (input.p_bulb_pa - input.p_downstream_pa) - static_superheat_pa;
|
||||
let delta = full_open_delta_pa.max(1.0);
|
||||
let opening_eff = if drive <= 0.0 {
|
||||
0.0
|
||||
} else if drive >= delta {
|
||||
1.0
|
||||
} else {
|
||||
drive / delta
|
||||
};
|
||||
beta_m2.max(0.0) * opening_eff * sqrt_term
|
||||
}
|
||||
};
|
||||
Ok(m.max(0.0))
|
||||
}
|
||||
|
||||
/// Analytic ∂ṁ/∂P_upstream for orifice/EXV (TXV uses same √ΔP branch).
|
||||
pub fn valve_mass_flow_dp_up(model: &ValveFlowModel, input: &ValveFlowInput) -> Result<f64, String> {
|
||||
let m = valve_mass_flow(model, input)?;
|
||||
let dp = (input.p_upstream_pa - input.p_downstream_pa).max(0.0);
|
||||
if dp <= 0.0 || m <= 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
// ṁ ∝ √ΔP ⇒ ∂ṁ/∂P_up = ṁ / (2 ΔP)
|
||||
Ok(m / (2.0 * dp))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_input() -> ValveFlowInput {
|
||||
ValveFlowInput {
|
||||
density_kg_m3: 1200.0,
|
||||
p_upstream_pa: 1.5e6,
|
||||
p_downstream_pa: 0.4e6,
|
||||
opening: 0.5,
|
||||
p_bulb_pa: 0.5e6,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orifice_scales_with_opening() {
|
||||
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: 2e-6 };
|
||||
let mut half = base_input();
|
||||
half.opening = 0.5;
|
||||
let mut full = base_input();
|
||||
full.opening = 1.0;
|
||||
let m_half = valve_mass_flow(&model, &half).unwrap();
|
||||
let m_full = valve_mass_flow(&model, &full).unwrap();
|
||||
assert!((m_full - 2.0 * m_half).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exv_uses_cd_a() {
|
||||
let model = ValveFlowModel::ExvCdA {
|
||||
cd: 0.7,
|
||||
area_max_m2: 1e-5,
|
||||
};
|
||||
let m = valve_mass_flow(&model, &base_input()).unwrap();
|
||||
assert!(m.is_finite() && m > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txv_closed_below_sss() {
|
||||
let model = ValveFlowModel::TxvEames {
|
||||
beta_m2: 2e-6,
|
||||
static_superheat_pa: 50_000.0,
|
||||
full_open_delta_pa: 150_000.0,
|
||||
};
|
||||
let mut inp = base_input();
|
||||
// Pb - Pe = 0.5e6 - 0.4e6 = 0.1e6 < α=50k → wait, 100k > 50k so partially open
|
||||
inp.p_bulb_pa = inp.p_downstream_pa + 20_000.0; // drive = 20k - 50k < 0
|
||||
let m = valve_mass_flow(&model, &inp).unwrap();
|
||||
assert_eq!(m, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txv_fully_open_above_delta() {
|
||||
let model = ValveFlowModel::TxvEames {
|
||||
beta_m2: 2e-6,
|
||||
static_superheat_pa: 50_000.0,
|
||||
full_open_delta_pa: 100_000.0,
|
||||
};
|
||||
let mut inp = base_input();
|
||||
inp.p_bulb_pa = inp.p_downstream_pa + 200_000.0; // drive = 150k > δ
|
||||
let orifice = ValveFlowModel::IsenthalpicOrifice { beta_m2: 2e-6 };
|
||||
let mut full = inp;
|
||||
full.opening = 1.0;
|
||||
let m_txv = valve_mass_flow(&model, &inp).unwrap();
|
||||
let m_orf = valve_mass_flow(&orifice, &full).unwrap();
|
||||
assert!((m_txv - m_orf).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jacobian_dp_matches_fd() {
|
||||
let model = ValveFlowModel::ExvCdA {
|
||||
cd: 0.65,
|
||||
area_max_m2: 5e-6,
|
||||
};
|
||||
let inp = base_input();
|
||||
let d_analytic = valve_mass_flow_dp_up(&model, &inp).unwrap();
|
||||
let eps = 1.0;
|
||||
let mut up = inp;
|
||||
up.p_upstream_pa += eps;
|
||||
let mut dn = inp;
|
||||
dn.p_upstream_pa -= eps;
|
||||
let d_fd = (valve_mass_flow(&model, &up).unwrap() - valve_mass_flow(&model, &dn).unwrap())
|
||||
/ (2.0 * eps);
|
||||
assert!((d_analytic - d_fd).abs() / d_analytic.max(1e-12) < 1e-4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user