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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -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);
}

View 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());
}
}

View File

@@ -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() {

View File

@@ -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)
}

View 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);
}
}

View 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);
}
}

View File

@@ -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,
};

View File

@@ -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
}

View 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]");
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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 BernierBourret 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();

View File

@@ -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:
/// - `N1` pressure equalities
/// - `N1` 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:
/// - `N1` 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 ─────────────────────────────────────────────────

View File

@@ -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]

View 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 : 1015 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 : 1015 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(_))));
}
}

View File

@@ -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

View File

@@ -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"
);
}
}

View File

@@ -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(&params);
let evaluation = match self.correlation_selector.select_and_compute(
&params,
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

View File

@@ -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]

File diff suppressed because it is too large Load Diff

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -0,0 +1,165 @@
//! Fan-coil unit (FCU): waterair ε-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.050.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 waterair.
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);
}
}

View 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):533544
//! - 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 : 1018 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 WangLinLee (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.03.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 WangLinLee 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.851.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 1025 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
}
}

View File

@@ -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

View 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.011 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}");
}
}
}

View File

@@ -0,0 +1,142 @@
//! CO₂ / supercritical gas cooler HTC (Pettersen / ChengThome 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] (≈ 304320 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(((TT_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(&params).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);
}
}

View File

@@ -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;

View File

@@ -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]

View File

@@ -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,
};

View File

@@ -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.

View File

@@ -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,

View 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 ThomeRobinson 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.60.9).
#[inline]
pub fn palen_bundle_factor(f_b: f64) -> f64 {
f_b.clamp(0.5, 1.2)
}
/// ThomeRobinson (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());
}
}

View File

@@ -0,0 +1,190 @@
//! Shell-and-tube heat exchanger rating via BellDelaware 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 BellDelaware 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.70.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,
}
}
}
/// BellDelaware 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 BellDelaware 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 BellDelaware + 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);
}
}

View 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(BA)x` at low x, then smooth to vapor-only at x→1 via
/// `(dP/dz) = (A + 2(BA)x)·(1x)^(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 + (1x)/ρ_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);
}
}

View 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}"
);
}
}
}
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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 its 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();
}
}

View File

@@ -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 its always positive.
// Use clamped version so its 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();
}

View File

@@ -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();

View File

@@ -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(&params).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(&params).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(&params).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(&params).unwrap();
assert_eq!(comp.n_equations(), 6);
assert_eq!(
comp.port_names(),
vec![
"suction".to_string(),
"discharge".to_string(),
"economizer".to_string()
]
);
}
}

View 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.10.3 bar at rated flow, which alone reduces
//! capacity/COP by ~13 %). 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 ~13 % 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
);
}
}
}
}

View File

@@ -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.050.20)
//! ṁ_suction = z_flow × Σ a_ij × SST^i × SDT^j (polynomial, manufacturer data)
//! ṁ_eco = z_flow_eco × x_eco × ṁ_suction,nom (economizer fraction, 0.050.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.01.0)
mechanical_efficiency: f64,
/// Built-in volume index Vi = V_s / V_d (typically 2.25.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.25.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 040%
}
/// 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);
}
}

View 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);
}
}

View 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,
},
/// EamesMilazzoMaidment (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ρ(PcPe)) · [(PbPe) α] / δ for α < (PbPe) ≤ δ
// fully open when (PbPe) ≥ δ → 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);
}
}