fix: resolve CLI solver state dimension mismatch
Removed mathematical singularity in HeatExchanger models (q_hot - q_cold = 0 was redundant) causing them to incorrectly request 3 equations without internal variables. Fixed ScrewEconomizerCompressor internal_state_len to perfectly align with the solver dimensions.
This commit is contained in:
855
crates/components/src/heat_exchanger/bphx_condenser.rs
Normal file
855
crates/components/src/heat_exchanger/bphx_condenser.rs
Normal file
@@ -0,0 +1,855 @@
|
||||
//! BphxCondenser - Brazed Plate Heat Exchanger Condenser Component
|
||||
//!
|
||||
//! A plate condenser component for refrigerant condensation with
|
||||
//! geometry-based heat transfer correlations and subcooling calculation.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - Subcooled liquid outlet (quality <= 0)
|
||||
//! - Geometry-based heat transfer coefficient calculation
|
||||
//! - Longo (2004) condensation correlation as default
|
||||
//! - Calib factor support (f_ua, f_dp)
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use entropyk_components::heat_exchanger::{BphxCondenser, BphxGeometry};
|
||||
//!
|
||||
//! let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
//! let cond = BphxCondenser::new(geo)
|
||||
//! .with_refrigerant("R410A")
|
||||
//! .with_target_subcooling(3.0);
|
||||
//!
|
||||
//! assert_eq!(cond.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::exchanger::HxSideConditions;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// BphxCondenser - Brazed Plate Heat Exchanger configured as condenser
|
||||
///
|
||||
/// Supports condensation with subcooled liquid outlet.
|
||||
/// Wraps a `BphxExchanger` for base residual computation.
|
||||
pub struct BphxCondenser {
|
||||
inner: BphxExchanger,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
last_subcooling: Cell<Option<f64>>,
|
||||
last_outlet_quality: Cell<Option<f64>>,
|
||||
target_subcooling: f64,
|
||||
outlet_pressure_idx: Option<usize>,
|
||||
outlet_enthalpy_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BphxCondenser {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BphxCondenser")
|
||||
.field("ua", &self.inner.ua())
|
||||
.field("geometry", &self.inner.geometry())
|
||||
.field("target_subcooling", &self.target_subcooling)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BphxCondenser {
|
||||
/// Default target subcooling in Kelvin
|
||||
pub const DEFAULT_TARGET_SUBCOOLING: f64 = 3.0;
|
||||
|
||||
/// Creates a new BphxCondenser with the specified geometry.
|
||||
///
|
||||
/// The geometry's `exchanger_type` is automatically set to `BphxType::Condenser`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `geometry` - BPHX geometry specification
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::heat_exchanger::{BphxCondenser, BphxGeometry};
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let cond = BphxCondenser::new(geo);
|
||||
/// assert_eq!(cond.n_equations(), 3);
|
||||
/// ```
|
||||
pub fn new(geometry: BphxGeometry) -> Self {
|
||||
let geometry = geometry.with_exchanger_type(BphxType::Condenser);
|
||||
Self {
|
||||
inner: BphxExchanger::new(geometry),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
last_subcooling: Cell::new(None),
|
||||
last_outlet_quality: Cell::new(None),
|
||||
target_subcooling: Self::DEFAULT_TARGET_SUBCOOLING,
|
||||
outlet_pressure_idx: None,
|
||||
outlet_enthalpy_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
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
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for property queries.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the heat transfer correlation.
|
||||
pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self {
|
||||
self.inner = self.inner.with_correlation(correlation);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the target subcooling in Kelvin.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `sc` is negative (subcooling must be >= 0 K).
|
||||
pub fn with_target_subcooling(mut self, sc: f64) -> Self {
|
||||
assert!(sc >= 0.0, "target_subcooling must be >= 0 K, got {}", sc);
|
||||
self.target_subcooling = sc;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
/// Returns the geometry specification.
|
||||
pub fn geometry(&self) -> &BphxGeometry {
|
||||
self.inner.geometry()
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
/// Returns calibration factors.
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
/// Sets calibration factors.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.inner.set_calib(calib);
|
||||
}
|
||||
|
||||
/// Returns the target subcooling (K).
|
||||
pub fn target_subcooling(&self) -> f64 {
|
||||
self.target_subcooling
|
||||
}
|
||||
|
||||
/// Returns the last computed subcooling (K).
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - `compute_residuals` has not been called
|
||||
/// - No FluidBackend configured
|
||||
pub fn subcooling(&self) -> Option<f64> {
|
||||
self.last_subcooling.get()
|
||||
}
|
||||
|
||||
/// Returns the last computed outlet quality.
|
||||
///
|
||||
/// For a condenser, this should be <= 0 (subcooled liquid).
|
||||
pub fn outlet_quality(&self) -> Option<f64> {
|
||||
self.last_outlet_quality.get()
|
||||
}
|
||||
|
||||
/// Sets the outlet state indices for subcooling calculation.
|
||||
///
|
||||
/// These indices point to the pressure and enthalpy in the global state vector
|
||||
/// that represent the refrigerant outlet conditions.
|
||||
pub fn set_outlet_indices(&mut self, p_idx: usize, h_idx: usize) {
|
||||
self.outlet_pressure_idx = Some(p_idx);
|
||||
self.outlet_enthalpy_idx = Some(h_idx);
|
||||
}
|
||||
|
||||
/// Sets the hot side (refrigerant) boundary conditions.
|
||||
pub fn set_refrigerant_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_hot_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Sets the cold side (secondary fluid) boundary conditions.
|
||||
pub fn set_secondary_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_cold_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Computes outlet quality from enthalpy and saturation properties.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
let h_sat_v = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(1.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if h_sat_v > h_sat_l {
|
||||
let quality = (h_out - h_sat_l) / (h_sat_v - h_sat_l);
|
||||
Some(quality)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes subcooling from outlet enthalpy and saturation properties.
|
||||
///
|
||||
/// Subcooling = T_sat - T_outlet
|
||||
///
|
||||
/// - Positive value: outlet is subcooled liquid (T_outlet < T_sat)
|
||||
/// - Zero: outlet is saturated liquid
|
||||
/// - Negative value: outlet is two-phase or superheated (invalid for condenser)
|
||||
///
|
||||
/// Or equivalently: SC = (h_sat_l - h_outlet) / cp_l
|
||||
///
|
||||
/// Returns `None` if no FluidBackend is configured or saturation properties
|
||||
/// cannot be computed.
|
||||
fn compute_subcooling(&self, h_out: f64, p_pa: f64) -> Option<f64> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let t_sat = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let t_out = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Temperature,
|
||||
FluidState::from_ph(p, Enthalpy::from_joules_per_kg(h_out)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(t_sat - t_out)
|
||||
}
|
||||
|
||||
/// Computes the heat transfer coefficient using the configured correlation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_htc(
|
||||
&self,
|
||||
mass_flux: f64,
|
||||
quality: f64,
|
||||
rho_l: f64,
|
||||
rho_v: f64,
|
||||
mu_l: f64,
|
||||
mu_v: f64,
|
||||
k_l: f64,
|
||||
pr_l: f64,
|
||||
t_sat: f64,
|
||||
t_wall: f64,
|
||||
) -> CorrelationResult {
|
||||
self.inner.compute_htc(
|
||||
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
|
||||
)
|
||||
}
|
||||
|
||||
/// Computes the pressure drop using the correlation.
|
||||
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
|
||||
self.inner.compute_pressure_drop(mass_flux, rho)
|
||||
}
|
||||
|
||||
/// Updates UA based on computed HTC.
|
||||
pub fn update_ua_from_htc(&mut self, h: f64) {
|
||||
self.inner.update_ua_from_htc(h);
|
||||
}
|
||||
|
||||
/// Validates that outlet is subcooled (quality <= 0).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - Returns error if outlet quality > 0 (not subcooled)
|
||||
/// - Returns error if refrigerant_id not set
|
||||
/// - Returns error if FluidBackend not configured
|
||||
pub fn validate_outlet(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"BphxCondenser: refrigerant_id not set".to_string(),
|
||||
));
|
||||
}
|
||||
if self.fluid_backend.is_none() {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"BphxCondenser: FluidBackend not configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let quality = self.compute_quality(h_out, p_pa).ok_or_else(|| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"BphxCondenser: Cannot compute quality for {} at P={:.0} Pa",
|
||||
self.refrigerant_id, p_pa
|
||||
))
|
||||
})?;
|
||||
|
||||
if quality > 0.0 {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"BphxCondenser: outlet quality {:.2} > 0 (not subcooled). Outlet must be subcooled liquid.",
|
||||
quality
|
||||
)))
|
||||
} else {
|
||||
Ok(quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BphxCondenser {
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inner.n_equations()
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.compute_residuals(state, residuals)?;
|
||||
|
||||
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx) {
|
||||
if p_idx < state.len() && h_idx < state.len() {
|
||||
let p_pa = state[p_idx];
|
||||
let h_out = state[h_idx];
|
||||
|
||||
if let Some(sc) = self.compute_subcooling(h_out, p_pa) {
|
||||
self.last_subcooling.set(Some(sc));
|
||||
}
|
||||
|
||||
if let Some(q) = self.compute_quality(h_out, p_pa) {
|
||||
self.last_outlet_quality.set(Some(q));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
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<MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn port_enthalpies(&self, state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
self.inner.port_enthalpies(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"BphxCondenser({} plates, dh={:.2}mm, A={:.3}m², {}, SC={:.1}K, {})",
|
||||
self.inner.geometry().n_plates,
|
||||
self.inner.geometry().dh * 1000.0,
|
||||
self.inner.geometry().area,
|
||||
self.inner.correlation_name(),
|
||||
self.target_subcooling,
|
||||
self.refrigerant_id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for BphxCondenser {
|
||||
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::*;
|
||||
|
||||
fn test_geometry() -> BphxGeometry {
|
||||
BphxGeometry::from_dh_area(0.003, 0.5, 20)
|
||||
}
|
||||
|
||||
use entropyk_core::Enthalpy;
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockCondenserBackend {
|
||||
h_sat_l: f64,
|
||||
h_sat_v: f64,
|
||||
t_sat: f64,
|
||||
}
|
||||
|
||||
impl Default for MockCondenserBackend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
h_sat_l: 250_000.0,
|
||||
h_sat_v: 450_000.0,
|
||||
t_sat: 320.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl entropyk_fluids::FluidBackend for MockCondenserBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: Property,
|
||||
state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Temperature => {
|
||||
let h = match state {
|
||||
FluidState::PressureEnthalpy(_, h) => Some(h),
|
||||
FluidState::PressureQuality(_, _) => None,
|
||||
_ => None,
|
||||
};
|
||||
match h {
|
||||
Some(h_val) => {
|
||||
let cp_l = 4180.0;
|
||||
Ok(self.t_sat - (self.h_sat_l - h_val.to_joules_per_kg()) / cp_l)
|
||||
}
|
||||
None => Ok(self.t_sat),
|
||||
}
|
||||
}
|
||||
Property::Enthalpy => {
|
||||
let q = match state {
|
||||
FluidState::PressureQuality(_, q) => Some(q.value()),
|
||||
_ => None,
|
||||
};
|
||||
match q {
|
||||
Some(q_val) => Ok(self.h_sat_l + q_val * (self.h_sat_v - self.h_sat_l)),
|
||||
None => Ok(self.h_sat_v),
|
||||
}
|
||||
}
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: _fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_creation() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
assert_eq!(cond.n_equations(), 2);
|
||||
assert!(cond.ua() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_default_target_subcooling() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
assert_eq!(
|
||||
cond.target_subcooling(),
|
||||
BphxCondenser::DEFAULT_TARGET_SUBCOOLING
|
||||
);
|
||||
assert_eq!(cond.target_subcooling(), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_with_target_subcooling() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_target_subcooling(5.0);
|
||||
|
||||
assert_eq!(cond.target_subcooling(), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_with_refrigerant() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_refrigerant("R410A");
|
||||
assert_eq!(cond.refrigerant_id, "R410A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_with_secondary_fluid() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_secondary_fluid("Water");
|
||||
assert_eq!(cond.secondary_fluid_id, "Water");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_with_correlation() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_correlation(BphxCorrelation::Shah1979);
|
||||
|
||||
let sig = cond.signature();
|
||||
assert!(sig.contains("Shah (1979)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_compute_residuals() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_state_manageable() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
assert_eq!(cond.state(), OperationalState::On);
|
||||
assert!(cond.can_transition_to(OperationalState::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_set_state() {
|
||||
let geo = test_geometry();
|
||||
let mut cond = BphxCondenser::new(geo);
|
||||
|
||||
let result = cond.set_state(OperationalState::Off);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(cond.state(), OperationalState::Off);
|
||||
|
||||
let result = cond.set_state(OperationalState::Bypass);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(cond.state(), OperationalState::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_calib_default() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let calib = cond.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_set_calib() {
|
||||
let geo = test_geometry();
|
||||
let mut cond = BphxCondenser::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
cond.set_calib(calib);
|
||||
assert_eq!(cond.calib().f_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_geometry() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo.clone());
|
||||
assert_eq!(cond.geometry().n_plates, geo.n_plates);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_signature() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo)
|
||||
.with_target_subcooling(5.0)
|
||||
.with_refrigerant("R410A");
|
||||
|
||||
let sig = cond.signature();
|
||||
assert!(sig.contains("BphxCondenser"));
|
||||
assert!(sig.contains("R410A"));
|
||||
assert!(sig.contains("SC=5.0K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_energy_transfers() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let state = vec![0.0; 10];
|
||||
let (heat, work) = cond.energy_transfers(&state).unwrap();
|
||||
assert_eq!(heat.to_watts(), 0.0);
|
||||
assert_eq!(work.to_watts(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_subcooling_initial() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
assert_eq!(cond.subcooling(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_outlet_quality_initial() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
assert_eq!(cond.outlet_quality(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_compute_htc() {
|
||||
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,
|
||||
);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.re > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_compute_pressure_drop() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo.clone());
|
||||
|
||||
let dp = cond.compute_pressure_drop(30.0, 1100.0);
|
||||
assert!(dp >= 0.0);
|
||||
|
||||
let mut cond_with_calib = BphxCondenser::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_dp = 0.5;
|
||||
cond_with_calib.set_calib(calib);
|
||||
|
||||
let dp_calib = cond_with_calib.compute_pressure_drop(30.0, 1100.0);
|
||||
assert!((dp_calib - dp * 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_validate_outlet_no_refrigerant() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo);
|
||||
let result = cond.validate_outlet(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("refrigerant_id not set"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_validate_outlet_no_backend() {
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo).with_refrigerant("R134a");
|
||||
let result = cond.validate_outlet(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::CalculationFailed(msg)) => {
|
||||
assert!(msg.contains("FluidBackend not configured"));
|
||||
}
|
||||
_ => panic!("Expected CalculationFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_update_ua_from_htc() {
|
||||
let geo = test_geometry();
|
||||
let area = geo.area;
|
||||
let mut cond = BphxCondenser::new(geo);
|
||||
|
||||
let h = 8000.0;
|
||||
let ua_before = cond.ua();
|
||||
cond.update_ua_from_htc(h);
|
||||
let ua_after = cond.ua();
|
||||
|
||||
assert!(ua_after > ua_before);
|
||||
let expected_ua = h * area;
|
||||
assert!((ua_after - expected_ua).abs() / expected_ua < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_set_outlet_indices() {
|
||||
let geo = test_geometry();
|
||||
let mut cond = BphxCondenser::new(geo);
|
||||
cond.set_outlet_indices(2, 3);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_subcooling_with_mock_backend() {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> =
|
||||
Arc::new(MockCondenserBackend::default());
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let sc = cond.compute_subcooling(240_000.0, 1_000_000.0);
|
||||
assert!(sc.is_some());
|
||||
let sc_val = sc.unwrap();
|
||||
assert!(sc_val > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_quality_with_mock_backend() {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> =
|
||||
Arc::new(MockCondenserBackend::default());
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let q = cond.compute_quality(200_000.0, 1_000_000.0);
|
||||
assert!(q.is_some());
|
||||
let q_val = q.unwrap();
|
||||
assert!(q_val <= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_validate_outlet_subcooled() {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> =
|
||||
Arc::new(MockCondenserBackend::default());
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let result = cond.validate_outlet(200_000.0, 1_000_000.0);
|
||||
assert!(result.is_ok());
|
||||
let quality = result.unwrap();
|
||||
assert!(quality <= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_validate_outlet_two_phase() {
|
||||
let backend: Arc<dyn entropyk_fluids::FluidBackend> =
|
||||
Arc::new(MockCondenserBackend::default());
|
||||
let geo = test_geometry();
|
||||
let cond = BphxCondenser::new(geo)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let result = cond.validate_outlet(350_000.0, 1_000_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("not subcooled"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_default_correlation_is_longo() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_condenser_negative_subcooling_panics() {
|
||||
let geo = test_geometry();
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
BphxCondenser::new(geo).with_target_subcooling(-5.0);
|
||||
});
|
||||
assert!(result.is_err(), "with_target_subcooling(-5.0) should panic");
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@
|
||||
//!
|
||||
//! ## Supported Correlations
|
||||
//!
|
||||
//! - **Longo (2004)**: Default for BPHX evaporation/condensation
|
||||
//! - **Shah (1979)**: Tubes condensation
|
||||
//! - **Shah (2021)**: Plates condensation (recent)
|
||||
//! - **Kandlikar (1990)**: Tubes evaporation
|
||||
//! - **Gungor-Winterton (1986)**: Tubes evaporation
|
||||
//! - **Gnielinski (1976)**: Single-phase turbulent (accurate)
|
||||
//! | Correlation | Year | Application | Geometry |
|
||||
//! |-------------|------|-------------|----------|
|
||||
//! | Longo | 2004 | BPHX evaporation/condensation | Plates |
|
||||
//! | Shah | 1979 | Tubes condensation | Tubes |
|
||||
//! | Shah | 2021 | Plates condensation (recent) | Plates |
|
||||
//! | Kandlikar | 1990 | Tubes evaporation | Tubes |
|
||||
//! | Gungor-Winterton | 1986 | Tubes evaporation | Tubes |
|
||||
//! | Gnielinski | 1976 | Single-phase turbulent (accurate) | Tubes |
|
||||
//! | Dittus-Boelter | 1930 | Single-phase turbulent (simple) | Tubes |
|
||||
//! | Ko | 2021 | Low-GWP refrigerants | Plates |
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -27,6 +31,22 @@ pub enum FlowRegime {
|
||||
Condensation,
|
||||
}
|
||||
|
||||
/// Heat exchanger geometry types for correlation applicability
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ExchangerGeometryType {
|
||||
/// Smooth tubes
|
||||
#[default]
|
||||
SmoothTube,
|
||||
/// Finned tubes
|
||||
FinnedTube,
|
||||
/// Brazed plate heat exchanger
|
||||
BrazedPlate,
|
||||
/// Gasketed plate heat exchanger
|
||||
GasketedPlate,
|
||||
/// Shell-and-tube heat exchanger
|
||||
ShellAndTube,
|
||||
}
|
||||
|
||||
/// Validity status for correlation results
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ValidityStatus {
|
||||
@@ -68,6 +88,22 @@ impl Default for CorrelationResult {
|
||||
}
|
||||
}
|
||||
|
||||
impl CorrelationResult {
|
||||
/// Returns true if the result is within valid range
|
||||
pub fn is_valid(&self) -> bool {
|
||||
matches!(
|
||||
self.validity,
|
||||
ValidityStatus::Valid | ValidityStatus::NearBoundary
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a new result with a warning message
|
||||
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
|
||||
self.warning = Some(warning.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for heat transfer correlation calculations
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationParams {
|
||||
@@ -215,6 +251,10 @@ pub enum BphxCorrelation {
|
||||
GungorWinterton1986,
|
||||
/// Gnielinski (1976) - Single-phase turbulent (accurate)
|
||||
Gnielinski1976,
|
||||
/// Dittus-Boelter (1930) - Single-phase turbulent (simple)
|
||||
DittusBoelter1930,
|
||||
/// Ko (2021) - Low-GWP refrigerants in plates
|
||||
Ko2021,
|
||||
}
|
||||
|
||||
impl BphxCorrelation {
|
||||
@@ -227,6 +267,8 @@ impl BphxCorrelation {
|
||||
Self::Kandlikar1990 => kandlikar_1990(params),
|
||||
Self::GungorWinterton1986 => gungor_winterton_1986(params),
|
||||
Self::Gnielinski1976 => gnielinski_1976(params),
|
||||
Self::DittusBoelter1930 => dittus_boelter_1930(params),
|
||||
Self::Ko2021 => ko_2021(params),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +281,82 @@ impl BphxCorrelation {
|
||||
Self::Kandlikar1990 => "Kandlikar (1990)",
|
||||
Self::GungorWinterton1986 => "Gungor-Winterton (1986)",
|
||||
Self::Gnielinski1976 => "Gnielinski (1976)",
|
||||
Self::DittusBoelter1930 => "Dittus-Boelter (1930)",
|
||||
Self::Ko2021 => "Ko (2021)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the publication year
|
||||
pub const fn year(&self) -> u16 {
|
||||
match self {
|
||||
Self::Longo2004 => 2004,
|
||||
Self::Shah1979 => 1979,
|
||||
Self::Shah2021 => 2021,
|
||||
Self::Kandlikar1990 => 1990,
|
||||
Self::GungorWinterton1986 => 1986,
|
||||
Self::Gnielinski1976 => 1976,
|
||||
Self::DittusBoelter1930 => 1930,
|
||||
Self::Ko2021 => 2021,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bibliographic reference
|
||||
pub fn reference(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Longo2004 => {
|
||||
"Longo, G.A. et al. (2004). Int. J. Heat Mass Transfer 47, 1039-1047"
|
||||
}
|
||||
Self::Shah1979 => "Shah, M.M. (1979). Int. J. Heat Mass Transfer 22, 547-556",
|
||||
Self::Shah2021 => "Shah, M.M. (2021). Int. J. Heat Mass Transfer 176, 1-16",
|
||||
Self::Kandlikar1990 => "Kandlikar, S.G. (1990). J. Heat Transfer 112, 219-227",
|
||||
Self::GungorWinterton1986 => {
|
||||
"Gungor, K.E., Winterton, H.S. (1986). Int. J. Heat Mass Transfer 29, 2715-2722"
|
||||
}
|
||||
Self::Gnielinski1976 => {
|
||||
"Gnielinski, V. (1976). Int. Chemical Engineering 16(2), 359-368"
|
||||
}
|
||||
Self::DittusBoelter1930 => {
|
||||
"Dittus, F.W., Boelter, L.M.K. (1930). Univ. California Publ. Eng. 2, 443-461"
|
||||
}
|
||||
Self::Ko2021 => "Ko, J. et al. (2021). Int. J. Heat Mass Transfer 181, 1-12",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns supported geometry types
|
||||
pub fn supported_geometries(&self) -> Vec<ExchangerGeometryType> {
|
||||
match self {
|
||||
Self::Longo2004 => vec![
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
ExchangerGeometryType::GasketedPlate,
|
||||
],
|
||||
Self::Shah1979 => vec![
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
ExchangerGeometryType::ShellAndTube,
|
||||
],
|
||||
Self::Shah2021 => vec![
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
ExchangerGeometryType::GasketedPlate,
|
||||
],
|
||||
Self::Kandlikar1990 => vec![
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
ExchangerGeometryType::FinnedTube,
|
||||
],
|
||||
Self::GungorWinterton1986 => vec![
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
ExchangerGeometryType::FinnedTube,
|
||||
],
|
||||
Self::Gnielinski1976 => vec![
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
ExchangerGeometryType::ShellAndTube,
|
||||
],
|
||||
Self::DittusBoelter1930 => vec![
|
||||
ExchangerGeometryType::SmoothTube,
|
||||
ExchangerGeometryType::ShellAndTube,
|
||||
],
|
||||
Self::Ko2021 => vec![
|
||||
ExchangerGeometryType::BrazedPlate,
|
||||
ExchangerGeometryType::GasketedPlate,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +368,9 @@ impl BphxCorrelation {
|
||||
Self::Shah2021 => (200.0, 10000.0),
|
||||
Self::Kandlikar1990 => (300.0, 10000.0),
|
||||
Self::GungorWinterton1986 => (300.0, 50000.0),
|
||||
Self::Gnielinski1976 => (2300.0, 5000000.0),
|
||||
Self::Gnielinski1976 => (2300.0, 5_000_000.0),
|
||||
Self::DittusBoelter1930 => (10000.0, 120000.0),
|
||||
Self::Ko2021 => (200.0, 10000.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +383,8 @@ impl BphxCorrelation {
|
||||
Self::Kandlikar1990 => (50.0, 500.0),
|
||||
Self::GungorWinterton1986 => (50.0, 500.0),
|
||||
Self::Gnielinski1976 => (1.0, 1000.0),
|
||||
Self::DittusBoelter1930 => (100.0, 1000.0),
|
||||
Self::Ko2021 => (20.0, 300.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,9 +397,36 @@ impl BphxCorrelation {
|
||||
Self::Kandlikar1990 => (0.0, 1.0),
|
||||
Self::GungorWinterton1986 => (0.0, 1.0),
|
||||
Self::Gnielinski1976 => (0.0, 0.0),
|
||||
Self::DittusBoelter1930 => (0.0, 0.0),
|
||||
Self::Ko2021 => (0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns description with validity range
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Longo2004 => {
|
||||
"BPHX evaporation/condensation. Re: 100-6000, G: 15-50 kg/(m²·s), x: 0.05-0.95"
|
||||
}
|
||||
Self::Shah1979 => "Tube condensation. Re: 350-63000, G: 10-500 kg/(m²·s)",
|
||||
Self::Shah2021 => "Plate condensation (recent). Re: 200-10000, G: 20-300 kg/(m²·s)",
|
||||
Self::Kandlikar1990 => "Tube evaporation. Re: 300-10000, G: 50-500 kg/(m²·s)",
|
||||
Self::GungorWinterton1986 => "Tube evaporation. Re: 300-50000, G: 50-500 kg/(m²·s)",
|
||||
Self::Gnielinski1976 => "Single-phase turbulent (accurate). Re: 2300-5000000",
|
||||
Self::DittusBoelter1930 => {
|
||||
"Single-phase turbulent (simple). Re: 10000-120000, Pr: 0.7-160, L/D>10"
|
||||
}
|
||||
Self::Ko2021 => {
|
||||
"Low-GWP refrigerants in plates. Re: 200-10000, G: 20-300 kg/(m²·s), x: 0-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if this correlation supports the given geometry type
|
||||
pub fn supports_geometry(&self, geometry: ExchangerGeometryType) -> bool {
|
||||
self.supported_geometries().contains(&geometry)
|
||||
}
|
||||
|
||||
/// Custom validity check for correlation
|
||||
fn check_validity_custom(&self, params: &CorrelationParams) -> ValidityStatus {
|
||||
let (re_min, re_max) = self.re_range();
|
||||
@@ -405,12 +554,27 @@ fn shah_1979(params: &CorrelationParams) -> CorrelationResult {
|
||||
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
let (re_min, re_max) = (350.0, 63000.0);
|
||||
let (g_min, g_max) = (10.0, 500.0);
|
||||
let re_ok = re_l >= re_min && re_l <= re_max;
|
||||
let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max;
|
||||
let validity = if re_ok && g_ok {
|
||||
let re_near = (re_l - re_min).abs() < re_min * 0.1 || (re_max - re_l).abs() < re_max * 0.1;
|
||||
if re_near {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Valid
|
||||
}
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity: ValidityStatus::Valid,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
@@ -434,12 +598,27 @@ fn shah_2021(params: &CorrelationParams) -> CorrelationResult {
|
||||
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
let (re_min, re_max) = (200.0, 10000.0);
|
||||
let (g_min, g_max) = (20.0, 300.0);
|
||||
let re_ok = re_l >= re_min && re_l <= re_max;
|
||||
let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max;
|
||||
let validity = if re_ok && g_ok {
|
||||
let re_near = (re_l - re_min).abs() < re_min * 0.1 || (re_max - re_l).abs() < re_max * 0.1;
|
||||
if re_near {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Valid
|
||||
}
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity: ValidityStatus::Valid,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
@@ -466,12 +645,27 @@ fn kandlikar_1990(params: &CorrelationParams) -> CorrelationResult {
|
||||
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
let (re_min, re_max) = (300.0, 10000.0);
|
||||
let (g_min, g_max) = (50.0, 500.0);
|
||||
let re_ok = re_l >= re_min && re_l <= re_max;
|
||||
let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max;
|
||||
let validity = if re_ok && g_ok {
|
||||
let re_near = (re_l - re_min).abs() < re_min * 0.1 || (re_max - re_l).abs() < re_max * 0.1;
|
||||
if re_near {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Valid
|
||||
}
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity: ValidityStatus::Valid,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
@@ -492,19 +686,35 @@ fn gungor_winterton_1986(params: &CorrelationParams) -> CorrelationResult {
|
||||
let bo = params.rho_v / params.rho_l;
|
||||
let e = 1.0 + 1.8 / bo;
|
||||
let s = 1.0 + 0.1 / bo.powf(0.7);
|
||||
let h_ratio = e * (1.0 / params.quality).powf(0.3) * s;
|
||||
let quality_safe = params.quality.max(0.01);
|
||||
let h_ratio = e * (1.0 / quality_safe).powf(0.3) * s;
|
||||
nu_sp * h_ratio
|
||||
}
|
||||
};
|
||||
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
let (re_min, re_max) = (300.0, 50000.0);
|
||||
let (g_min, g_max) = (50.0, 500.0);
|
||||
let re_ok = re_l >= re_min && re_l <= re_max;
|
||||
let g_ok = params.mass_flux >= g_min && params.mass_flux <= g_max;
|
||||
let validity = if re_ok && g_ok {
|
||||
let re_near = (re_l - re_min).abs() < re_min * 0.1 || (re_max - re_l).abs() < re_max * 0.1;
|
||||
if re_near {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Valid
|
||||
}
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity: ValidityStatus::Valid,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
@@ -532,12 +742,125 @@ fn gnielinski_1976(params: &CorrelationParams) -> CorrelationResult {
|
||||
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
let (re_min, re_max) = (2300.0, 5_000_000.0);
|
||||
let validity = if re_l >= re_min && re_l <= re_max {
|
||||
let re_near = (re_l - re_min).abs() < re_min * 0.1 || (re_max - re_l).abs() < re_max * 0.1;
|
||||
if re_near {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Valid
|
||||
}
|
||||
} else if re_l >= re_min * 0.9 {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity: ValidityStatus::Valid,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dittus-Boelter (1930) correlation for single-phase turbulent flow
|
||||
///
|
||||
/// $$Nu = 0.023 \cdot Re^{0.8} \cdot Pr^n$$
|
||||
///
|
||||
/// Where:
|
||||
/// - n = 0.4 for heating (fluid being heated)
|
||||
/// - n = 0.3 for cooling (fluid being cooled)
|
||||
///
|
||||
/// ## Validity Ranges
|
||||
///
|
||||
/// - Re: 10,000 - 120,000
|
||||
/// - Pr: 0.7 - 160
|
||||
/// - L/D > 10
|
||||
fn dittus_boelter_1930(params: &CorrelationParams) -> CorrelationResult {
|
||||
let re_l = if params.mu_l < 1e-15 {
|
||||
0.0
|
||||
} else {
|
||||
params.mass_flux * params.dh / params.mu_l
|
||||
};
|
||||
|
||||
// Determine exponent based on regime (heating vs cooling)
|
||||
let n = match params.regime {
|
||||
FlowRegime::SinglePhaseVapor => 0.4, // Heating (vapor being heated)
|
||||
FlowRegime::SinglePhaseLiquid => 0.3, // Cooling (liquid being cooled)
|
||||
_ => 0.4,
|
||||
};
|
||||
|
||||
let nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(n);
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
// Check validity
|
||||
let validity = if re_l >= 10_000.0 && re_l <= 120_000.0 {
|
||||
ValidityStatus::Valid
|
||||
} else if re_l >= 5_000.0 {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ko (2021) correlation for low-GWP refrigerants in plate heat exchangers
|
||||
///
|
||||
/// $$Nu = 0.205 \cdot Re^{0.7} \cdot Pr^{0.33} \cdot F_{corr}$$
|
||||
///
|
||||
/// Where:
|
||||
/// - F_corr = 1.15 for evaporation/condensation
|
||||
/// - F_corr = 1.0 for single-phase
|
||||
///
|
||||
/// ## Validity Ranges
|
||||
///
|
||||
/// - Re: 200 - 10,000
|
||||
/// - Mass flux: 20 - 300 kg/(m²·s)
|
||||
/// - Quality: 0 - 1
|
||||
fn ko_2021(params: &CorrelationParams) -> CorrelationResult {
|
||||
let re_l = if params.mu_l < 1e-15 {
|
||||
0.0
|
||||
} else {
|
||||
params.mass_flux * params.dh / params.mu_l
|
||||
};
|
||||
|
||||
// Correlation factor based on regime
|
||||
let f_corr = match params.regime {
|
||||
FlowRegime::Evaporation | FlowRegime::Condensation => 1.15,
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
let nu = 0.205 * re_l.powf(0.7) * params.pr_l.powf(0.33) * f_corr;
|
||||
let h = nu * params.k_l / params.dh;
|
||||
|
||||
// Check validity
|
||||
let g_ok = params.mass_flux >= 20.0 && params.mass_flux <= 300.0;
|
||||
let re_ok = re_l >= 200.0 && re_l <= 10000.0;
|
||||
let validity = if g_ok && re_ok {
|
||||
ValidityStatus::Valid
|
||||
} else if re_l >= 180.0 && re_l <= 11000.0 {
|
||||
ValidityStatus::NearBoundary
|
||||
} else {
|
||||
ValidityStatus::Extrapolation
|
||||
};
|
||||
|
||||
CorrelationResult {
|
||||
h,
|
||||
re: re_l,
|
||||
pr: params.pr_l,
|
||||
nu,
|
||||
validity,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
@@ -569,6 +892,50 @@ impl CorrelationSelector {
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a list of all available correlations
|
||||
pub fn available_correlations() -> Vec<BphxCorrelation> {
|
||||
vec![
|
||||
BphxCorrelation::Longo2004,
|
||||
BphxCorrelation::Shah1979,
|
||||
BphxCorrelation::Shah2021,
|
||||
BphxCorrelation::Kandlikar1990,
|
||||
BphxCorrelation::GungorWinterton1986,
|
||||
BphxCorrelation::Gnielinski1976,
|
||||
BphxCorrelation::DittusBoelter1930,
|
||||
BphxCorrelation::Ko2021,
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns metadata for a specific correlation
|
||||
pub fn get_correlation_info(&self, correlation: BphxCorrelation) -> CorrelationInfo {
|
||||
let (re_min, re_max) = correlation.re_range();
|
||||
let (g_min, g_max) = correlation.mass_flux_range();
|
||||
let (x_min, x_max) = correlation.quality_range();
|
||||
|
||||
CorrelationInfo {
|
||||
name: correlation.name().to_string(),
|
||||
year: correlation.year(),
|
||||
reference: correlation.reference().to_string(),
|
||||
validity: format!(
|
||||
"Re: {:.0} - {:.0}, Mass flux: {:.1} - {:.1} kg/(m²·s), Quality: {:.2} - {:.2}",
|
||||
re_min, re_max, g_min, g_max, x_min, x_max
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recommends the best correlation for a given geometry type
|
||||
pub fn recommend_for_geometry(&self, geometry: ExchangerGeometryType) -> BphxCorrelation {
|
||||
match geometry {
|
||||
ExchangerGeometryType::BrazedPlate | ExchangerGeometryType::GasketedPlate => {
|
||||
BphxCorrelation::Ko2021
|
||||
}
|
||||
ExchangerGeometryType::SmoothTube | ExchangerGeometryType::ShellAndTube => {
|
||||
BphxCorrelation::Gnielinski1976
|
||||
}
|
||||
ExchangerGeometryType::FinnedTube => BphxCorrelation::Kandlikar1990,
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the heat transfer coefficient
|
||||
pub fn compute_htc(&self, params: &CorrelationParams) -> CorrelationResult {
|
||||
let mut result = self.correlation.compute_htc(params);
|
||||
@@ -590,26 +957,25 @@ impl CorrelationSelector {
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for a correlation
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationInfo {
|
||||
/// Correlation name
|
||||
pub name: String,
|
||||
/// Publication year
|
||||
pub year: u16,
|
||||
/// Bibliographic reference
|
||||
pub reference: String,
|
||||
/// Validity range description
|
||||
pub validity: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_params() -> CorrelationParams {
|
||||
CorrelationParams {
|
||||
mass_flux: 30.0,
|
||||
quality: 0.5,
|
||||
dh: 0.003,
|
||||
rho_l: 1100.0,
|
||||
rho_v: 30.0,
|
||||
mu_l: 0.0002,
|
||||
mu_v: 0.000012,
|
||||
k_l: 0.1,
|
||||
pr_l: 3.5,
|
||||
t_sat: 280.0,
|
||||
t_wall: 285.0,
|
||||
regime: FlowRegime::Evaporation,
|
||||
chevron_angle: 60.0,
|
||||
}
|
||||
CorrelationParams::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -642,6 +1008,142 @@ mod tests {
|
||||
assert!(result.nu > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dittus_boelter_1930_heating() {
|
||||
let mut params = test_params();
|
||||
params.regime = FlowRegime::SinglePhaseVapor;
|
||||
params.t_wall = 310.0;
|
||||
let result = BphxCorrelation::DittusBoelter1930.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
let re_l = params.mass_flux * params.dh / params.mu_l;
|
||||
let expected_nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.4);
|
||||
assert!((result.nu - expected_nu).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dittus_boelter_1930_cooling() {
|
||||
let mut params = test_params();
|
||||
params.regime = FlowRegime::SinglePhaseLiquid;
|
||||
params.t_wall = 300.0;
|
||||
let result = BphxCorrelation::DittusBoelter1930.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
let re_l = params.mass_flux * params.dh / params.mu_l;
|
||||
let expected_nu = 0.023 * re_l.powf(0.8) * params.pr_l.powf(0.3);
|
||||
assert!((result.nu - expected_nu).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dittus_boelter_1930_extrapolation() {
|
||||
let mut params = test_params();
|
||||
params.mass_flux = 500.0;
|
||||
params.regime = FlowRegime::SinglePhaseLiquid;
|
||||
let result = BphxCorrelation::DittusBoelter1930.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
assert_eq!(result.validity, ValidityStatus::NearBoundary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ko_2021_evaporation() {
|
||||
let params = test_params();
|
||||
let result = BphxCorrelation::Ko2021.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
assert_eq!(result.validity, ValidityStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ko_2021_condensation() {
|
||||
let mut params = test_params();
|
||||
params.regime = FlowRegime::Condensation;
|
||||
let result = BphxCorrelation::Ko2021.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
assert_eq!(result.validity, ValidityStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ko_2021_single_phase() {
|
||||
let mut params = test_params();
|
||||
params.regime = FlowRegime::SinglePhaseLiquid;
|
||||
let result = BphxCorrelation::Ko2021.compute_htc(¶ms);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
assert_eq!(result.validity, ValidityStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_selector_available_correlations() {
|
||||
let correlations = CorrelationSelector::available_correlations();
|
||||
assert_eq!(correlations.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_selector_get_info() {
|
||||
let info = CorrelationSelector::new().get_correlation_info(BphxCorrelation::Longo2004);
|
||||
|
||||
assert_eq!(info.name, "Longo (2004)");
|
||||
assert_eq!(info.year, 2004);
|
||||
assert!(info.reference.contains("2004"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_selector_recommend_for_geometry() {
|
||||
let selector = CorrelationSelector::new();
|
||||
|
||||
let rec = selector.recommend_for_geometry(ExchangerGeometryType::BrazedPlate);
|
||||
assert_eq!(rec, BphxCorrelation::Ko2021);
|
||||
|
||||
let rec = selector.recommend_for_geometry(ExchangerGeometryType::SmoothTube);
|
||||
assert_eq!(rec, BphxCorrelation::Gnielinski1976);
|
||||
|
||||
let rec = selector.recommend_for_geometry(ExchangerGeometryType::FinnedTube);
|
||||
assert_eq!(rec, BphxCorrelation::Kandlikar1990);
|
||||
|
||||
let rec = selector.recommend_for_geometry(ExchangerGeometryType::ShellAndTube);
|
||||
assert_eq!(rec, BphxCorrelation::Gnielinski1976);
|
||||
|
||||
let rec = selector.recommend_for_geometry(ExchangerGeometryType::GasketedPlate);
|
||||
assert_eq!(rec, BphxCorrelation::Ko2021);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_result_is_valid() {
|
||||
let result = CorrelationResult {
|
||||
h: 5000.0,
|
||||
re: 30000.0,
|
||||
pr: 3.5,
|
||||
nu: 100.0,
|
||||
validity: ValidityStatus::Valid,
|
||||
warning: None,
|
||||
};
|
||||
|
||||
assert!(result.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_result_with_warning() {
|
||||
let result = CorrelationResult {
|
||||
h: 5000.0,
|
||||
re: 30000.0,
|
||||
pr: 3.5,
|
||||
nu: 100.0,
|
||||
validity: ValidityStatus::Extrapolation,
|
||||
warning: None,
|
||||
}
|
||||
.with_warning("Test warning");
|
||||
|
||||
assert_eq!(result.warning, Some("Test warning".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_result_default() {
|
||||
let result = CorrelationResult::default();
|
||||
@@ -660,6 +1162,10 @@ mod tests {
|
||||
let (re_min, re_max) = BphxCorrelation::Longo2004.re_range();
|
||||
assert_eq!(re_min, 100.0);
|
||||
assert_eq!(re_max, 6000.0);
|
||||
|
||||
let (re_min, re_max) = BphxCorrelation::Gnielinski1976.re_range();
|
||||
assert_eq!(re_min, 2300.0);
|
||||
assert_eq!(re_max, 5_000_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -724,6 +1230,16 @@ mod tests {
|
||||
assert!(result.h > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gungor_winterton_1986_zero_quality() {
|
||||
let mut params = test_params();
|
||||
params.quality = 0.0;
|
||||
params.regime = FlowRegime::Evaporation;
|
||||
let result = BphxCorrelation::GungorWinterton1986.compute_htc(¶ms);
|
||||
assert!(result.h.is_finite());
|
||||
assert!(result.h > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gnielinski_1976() {
|
||||
let params = test_params();
|
||||
@@ -744,4 +1260,40 @@ mod tests {
|
||||
|
||||
assert!((result.nu - expected_nu).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_selector_warn_on_extrapolation() {
|
||||
let selector = CorrelationSelector::new()
|
||||
.with_correlation(BphxCorrelation::Longo2004)
|
||||
.with_warn_on_extrapolation(true);
|
||||
|
||||
let mut params = test_params();
|
||||
params.mass_flux = 100.0;
|
||||
|
||||
let result = selector.compute_htc(¶ms);
|
||||
assert_eq!(result.validity, ValidityStatus::Extrapolation);
|
||||
assert!(result.warning.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_near_boundary_validity() {
|
||||
let mut params = test_params();
|
||||
params.mass_flux = 16.0;
|
||||
|
||||
let validity = BphxCorrelation::Longo2004.check_validity_custom(¶ms);
|
||||
assert_eq!(validity, ValidityStatus::NearBoundary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_geometry() {
|
||||
assert!(BphxCorrelation::Longo2004.supports_geometry(ExchangerGeometryType::BrazedPlate));
|
||||
assert!(!BphxCorrelation::Longo2004.supports_geometry(ExchangerGeometryType::SmoothTube));
|
||||
|
||||
assert!(
|
||||
BphxCorrelation::Gnielinski1976.supports_geometry(ExchangerGeometryType::SmoothTube)
|
||||
);
|
||||
assert!(
|
||||
!BphxCorrelation::Gnielinski1976.supports_geometry(ExchangerGeometryType::BrazedPlate)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
974
crates/components/src/heat_exchanger/bphx_evaporator.rs
Normal file
974
crates/components/src/heat_exchanger/bphx_evaporator.rs
Normal file
@@ -0,0 +1,974 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ## 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
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry, BphxEvaporatorMode};
|
||||
//!
|
||||
//! // 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_refrigerant("R410A");
|
||||
//!
|
||||
//! assert_eq!(evap.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::bphx_correlation::{BphxCorrelation, CorrelationResult};
|
||||
use super::bphx_exchanger::BphxExchanger;
|
||||
use super::bphx_geometry::{BphxGeometry, BphxType};
|
||||
use super::exchanger::HxSideConditions;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
|
||||
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.
|
||||
pub struct BphxEvaporator {
|
||||
inner: BphxExchanger,
|
||||
mode: BphxEvaporatorMode,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BphxEvaporator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BphxEvaporator")
|
||||
.field("ua", &self.inner.ua())
|
||||
.field("geometry", &self.inner.geometry())
|
||||
.field("mode", &self.mode)
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BphxEvaporator {
|
||||
/// Creates a new BphxEvaporator with the specified geometry.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `geometry` - BPHX geometry specification
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::heat_exchanger::{BphxEvaporator, BphxGeometry};
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let evap = BphxEvaporator::new(geo);
|
||||
/// assert_eq!(evap.n_equations(), 3);
|
||||
/// ```
|
||||
pub fn new(geometry: BphxGeometry) -> Self {
|
||||
let geometry = geometry.with_exchanger_type(BphxType::Evaporator);
|
||||
Self {
|
||||
inner: BphxExchanger::new(geometry),
|
||||
mode: BphxEvaporatorMode::default(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a BphxEvaporator with a specified geometry and correlation.
|
||||
pub fn with_geometry(geometry: BphxGeometry) -> Self {
|
||||
Self::new(geometry)
|
||||
}
|
||||
|
||||
/// Sets the operation mode.
|
||||
pub fn with_mode(mut self, mode: BphxEvaporatorMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
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
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for property queries.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the heat transfer correlation.
|
||||
pub fn with_correlation(mut self, correlation: BphxCorrelation) -> Self {
|
||||
self.inner = self.inner.with_correlation(correlation);
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
/// Returns the geometry specification.
|
||||
pub fn geometry(&self) -> &BphxGeometry {
|
||||
self.inner.geometry()
|
||||
}
|
||||
|
||||
/// Returns the operation mode.
|
||||
pub fn mode(&self) -> BphxEvaporatorMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
/// Returns calibration factors.
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
/// Sets calibration factors.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.inner.set_calib(calib);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// that represent the refrigerant outlet conditions.
|
||||
pub fn set_outlet_indices(&mut self, p_idx: usize, h_idx: usize) {
|
||||
self.outlet_pressure_idx = Some(p_idx);
|
||||
self.outlet_enthalpy_idx = Some(h_idx);
|
||||
}
|
||||
|
||||
/// Sets the hot side (secondary fluid) boundary conditions.
|
||||
pub fn set_secondary_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_hot_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Sets the cold side (refrigerant) boundary conditions.
|
||||
pub fn set_refrigerant_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_cold_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Computes outlet quality from enthalpy and saturation properties.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
let h_sat_v = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(1.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if h_sat_v > h_sat_l {
|
||||
let quality = (h_out - h_sat_l) / (h_sat_v - h_sat_l);
|
||||
Some(quality)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes superheat from outlet temperature and saturation temperature.
|
||||
///
|
||||
/// Returns `None` if no FluidBackend is configured or saturation properties
|
||||
/// cannot be computed.
|
||||
fn compute_superheat(&self, h_out: f64, p_pa: f64) -> Option<f64> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let t_sat = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Temperature,
|
||||
FluidState::from_px(p, Quality::new(1.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
let t_out = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Temperature,
|
||||
FluidState::from_ph(p, Enthalpy::from_joules_per_kg(h_out)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(t_out - t_sat)
|
||||
}
|
||||
|
||||
/// Computes the heat transfer coefficient using the configured correlation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_htc(
|
||||
&self,
|
||||
mass_flux: f64,
|
||||
quality: f64,
|
||||
rho_l: f64,
|
||||
rho_v: f64,
|
||||
mu_l: f64,
|
||||
mu_v: f64,
|
||||
k_l: f64,
|
||||
pr_l: f64,
|
||||
t_sat: f64,
|
||||
t_wall: f64,
|
||||
) -> CorrelationResult {
|
||||
self.inner.compute_htc(
|
||||
mass_flux, quality, rho_l, rho_v, mu_l, mu_v, k_l, pr_l, t_sat, t_wall,
|
||||
)
|
||||
}
|
||||
|
||||
/// Computes the pressure drop using the correlation.
|
||||
pub fn compute_pressure_drop(&self, mass_flux: f64, rho: f64) -> f64 {
|
||||
self.inner.compute_pressure_drop(mass_flux, rho)
|
||||
}
|
||||
|
||||
/// Updates UA based on computed HTC.
|
||||
pub fn update_ua_from_htc(&mut self, h: f64) {
|
||||
self.inner.update_ua_from_htc(h);
|
||||
}
|
||||
|
||||
/// Validates that outlet is in the correct region for the mode.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - DX mode: Returns error if outlet quality < 1.0 (not superheated)
|
||||
/// - Flooded mode: Returns error if outlet quality >= 1.0 (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(
|
||||
"BphxEvaporator: refrigerant_id not set".to_string(),
|
||||
));
|
||||
}
|
||||
if self.fluid_backend.is_none() {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"BphxEvaporator: FluidBackend not configured".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let quality = self.compute_quality(h_out, p_pa).ok_or_else(|| {
|
||||
ComponentError::CalculationFailed(format!(
|
||||
"BphxEvaporator: Cannot compute quality for {} at P={:.0} Pa",
|
||||
self.refrigerant_id, p_pa
|
||||
))
|
||||
})?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BphxEvaporator {
|
||||
fn n_equations(&self) -> usize {
|
||||
self.inner.n_equations()
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.compute_residuals(state, residuals)?;
|
||||
|
||||
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx) {
|
||||
if p_idx < state.len() && h_idx < state.len() {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
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<MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn port_enthalpies(&self, state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
self.inner.port_enthalpies(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"BphxEvaporator({} plates, dh={:.2}mm, A={:.3}m², {}, {}, {})",
|
||||
self.inner.geometry().n_plates,
|
||||
self.inner.geometry().dh * 1000.0,
|
||||
self.inner.geometry().area,
|
||||
mode_str,
|
||||
self.inner.correlation_name(),
|
||||
self.refrigerant_id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for BphxEvaporator {
|
||||
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::*;
|
||||
|
||||
fn test_geometry() -> BphxGeometry {
|
||||
BphxGeometry::from_dh_area(0.003, 0.5, 20)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_creation() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
assert_eq!(evap.n_equations(), 2);
|
||||
assert!(evap.ua() > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_dx_mode() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_refrigerant() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_refrigerant("R410A");
|
||||
assert_eq!(evap.refrigerant_id, "R410A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_secondary_fluid() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_secondary_fluid("Water");
|
||||
assert_eq!(evap.secondary_fluid_id, "Water");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_with_correlation() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_correlation(BphxCorrelation::Shah1979);
|
||||
|
||||
let sig = evap.signature();
|
||||
assert!(sig.contains("Shah (1979)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_compute_residuals() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = evap.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_state_manageable() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
assert_eq!(evap.state(), OperationalState::On);
|
||||
assert!(evap.can_transition_to(OperationalState::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_set_state() {
|
||||
let geo = test_geometry();
|
||||
let mut evap = BphxEvaporator::new(geo);
|
||||
|
||||
let result = evap.set_state(OperationalState::Off);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(evap.state(), OperationalState::Off);
|
||||
|
||||
let result = evap.set_state(OperationalState::Bypass);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(evap.state(), OperationalState::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_calib_default() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let calib = evap.calib();
|
||||
assert_eq!(calib.f_ua, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_set_calib() {
|
||||
let geo = test_geometry();
|
||||
let mut evap = BphxEvaporator::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_ua = 0.9;
|
||||
evap.set_calib(calib);
|
||||
assert_eq!(evap.calib().f_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_geometry() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo.clone());
|
||||
assert_eq!(evap.geometry().n_plates, geo.n_plates);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_signature_dx() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo)
|
||||
.with_mode(BphxEvaporatorMode::Dx {
|
||||
target_superheat: 5.0,
|
||||
})
|
||||
.with_refrigerant("R410A");
|
||||
|
||||
let sig = evap.signature();
|
||||
assert!(sig.contains("BphxEvaporator"));
|
||||
assert!(sig.contains("DX"));
|
||||
assert!(sig.contains("R410A"));
|
||||
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();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let state = vec![0.0; 10];
|
||||
let (heat, work) = evap.energy_transfers(&state).unwrap();
|
||||
assert_eq!(heat.to_watts(), 0.0);
|
||||
assert_eq!(work.to_watts(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_superheat_initial() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_mode(BphxEvaporatorMode::Dx {
|
||||
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,
|
||||
);
|
||||
|
||||
assert!(result.h > 0.0);
|
||||
assert!(result.re > 0.0);
|
||||
assert!(result.nu > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_compute_pressure_drop() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo.clone());
|
||||
|
||||
let dp = evap.compute_pressure_drop(30.0, 1100.0);
|
||||
assert!(dp >= 0.0);
|
||||
|
||||
let mut evap_with_calib = BphxEvaporator::new(geo);
|
||||
let mut calib = Calib::default();
|
||||
calib.f_dp = 0.5;
|
||||
evap_with_calib.set_calib(calib);
|
||||
|
||||
let dp_calib = evap_with_calib.compute_pressure_drop(30.0, 1100.0);
|
||||
assert!((dp_calib - dp * 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_validate_outlet_no_refrigerant() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo);
|
||||
let result = evap.validate_outlet(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("refrigerant_id not set"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_validate_outlet_no_backend() {
|
||||
let geo = test_geometry();
|
||||
let evap = BphxEvaporator::new(geo).with_refrigerant("R134a");
|
||||
let result = evap.validate_outlet(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::CalculationFailed(msg)) => {
|
||||
assert!(msg.contains("FluidBackend not configured"));
|
||||
}
|
||||
_ => panic!("Expected CalculationFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_update_ua_from_htc() {
|
||||
let geo = test_geometry();
|
||||
let area = geo.area;
|
||||
let mut evap = BphxEvaporator::new(geo);
|
||||
|
||||
let h = 8000.0;
|
||||
let ua_before = evap.ua();
|
||||
evap.update_ua_from_htc(h);
|
||||
let ua_after = evap.ua();
|
||||
|
||||
assert!(ua_after > ua_before);
|
||||
let expected_ua = h * area;
|
||||
assert!((ua_after - expected_ua).abs() / expected_ua < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_set_outlet_indices() {
|
||||
let geo = test_geometry();
|
||||
let mut evap = BphxEvaporator::new(geo);
|
||||
evap.set_outlet_indices(2, 3);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_superheat_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
|
||||
impl entropyk_fluids::FluidBackend for MockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: Property,
|
||||
state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Temperature => {
|
||||
let h = match state {
|
||||
FluidState::PressureEnthalpy(_, h) => Some(h),
|
||||
FluidState::PressureQuality(_, _) => None,
|
||||
_ => None,
|
||||
};
|
||||
let t_sat = 280.0;
|
||||
let cp = 4180.0;
|
||||
match h {
|
||||
Some(h_val) => Ok(t_sat + (h_val.to_joules_per_kg() - 400_000.0) / cp),
|
||||
None => Ok(t_sat),
|
||||
}
|
||||
}
|
||||
Property::Enthalpy => Ok(400_000.0),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: _fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
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_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let sh = evap.compute_superheat(420_000.0, 300_000.0);
|
||||
assert!(sh.is_some());
|
||||
let sh_val = sh.unwrap();
|
||||
assert!(sh_val > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bphx_evaporator_quality_with_mock_backend() {
|
||||
use entropyk_core::Enthalpy;
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
|
||||
impl entropyk_fluids::FluidBackend for MockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: Property,
|
||||
state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Enthalpy => {
|
||||
let q = match state {
|
||||
FluidState::PressureQuality(_, q) => Some(q.value()),
|
||||
_ => None,
|
||||
};
|
||||
match q {
|
||||
Some(q_val) => Ok(200_000.0 + q_val * 200_000.0),
|
||||
None => Ok(300_000.0),
|
||||
}
|
||||
}
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: _fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
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_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let q = evap.compute_quality(340_000.0, 300_000.0);
|
||||
assert!(q.is_some());
|
||||
let q_val = q.unwrap();
|
||||
assert!(q_val >= 0.0 && q_val <= 1.0);
|
||||
}
|
||||
|
||||
#[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() {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ impl BphxExchanger {
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::heat_exchanger::{BphxExchanger, BphxGeometry};
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// let geo = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
/// let hx = BphxExchanger::new(geo);
|
||||
@@ -171,6 +172,11 @@ impl BphxExchanger {
|
||||
&self.geometry
|
||||
}
|
||||
|
||||
/// Returns the name of the configured heat transfer correlation.
|
||||
pub fn correlation_name(&self) -> &'static str {
|
||||
self.correlation_selector.correlation.name()
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
@@ -401,7 +407,7 @@ mod tests {
|
||||
fn test_bphx_exchanger_creation() {
|
||||
let geo = test_geometry();
|
||||
let hx = BphxExchanger::new(geo);
|
||||
assert_eq!(hx.n_equations(), 3);
|
||||
assert_eq!(hx.n_equations(), 2);
|
||||
assert!(hx.ua() > 0.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,16 @@ impl BphxGeometry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the exchanger type.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `exchanger_type` - The type of heat exchanger (Evaporator, Condenser, Generic)
|
||||
pub fn with_exchanger_type(mut self, exchanger_type: BphxType) -> Self {
|
||||
self.exchanger_type = exchanger_type;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the effective flow cross-sectional area per channel (m²).
|
||||
///
|
||||
/// A_channel = channel_spacing × plate_width
|
||||
|
||||
@@ -101,6 +101,20 @@ impl Condenser {
|
||||
self.saturation_temp = temp;
|
||||
}
|
||||
|
||||
/// Overrides the effective UA value [W/K] at runtime.
|
||||
///
|
||||
/// Sets the UA scale factor so that `UA_nominal × scale = ua_value`.
|
||||
/// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections.
|
||||
pub fn set_ua(&mut self, ua_value: f64) {
|
||||
let ua_nominal = self.inner.ua_nominal();
|
||||
let scale = if ua_nominal > 0.0 {
|
||||
ua_value / ua_nominal
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
self.inner.set_ua_scale(scale.max(0.0));
|
||||
}
|
||||
|
||||
/// Validates that the outlet quality is <= 1 (fully condensed or subcooled).
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -243,7 +257,7 @@ mod tests {
|
||||
fn test_condenser_creation() {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
assert_eq!(condenser.ua(), 10_000.0);
|
||||
assert_eq!(condenser.n_equations(), 3);
|
||||
assert_eq!(condenser.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,7 +319,7 @@ mod tests {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
|
||||
let result = condenser.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
|
||||
@@ -185,7 +185,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_condenser_coil_n_equations() {
|
||||
let coil = CondenserCoil::new(10_000.0);
|
||||
assert_eq!(coil.n_equations(), 3);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -267,6 +267,6 @@ mod tests {
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
let economizer = Economizer::new(2_000.0);
|
||||
assert_eq!(economizer.n_equations(), 3);
|
||||
assert_eq!(economizer.n_equations(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,11 +242,10 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
|
||||
residuals[0] = q_hot - q;
|
||||
residuals[1] = q_cold - q;
|
||||
residuals[2] = q_hot - q_cold;
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
3
|
||||
2
|
||||
}
|
||||
|
||||
fn ua(&self) -> f64 {
|
||||
@@ -321,7 +320,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
let model = EpsNtuModel::counter_flow(1000.0);
|
||||
assert_eq!(model.n_equations(), 3);
|
||||
assert_eq!(model.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -269,7 +269,7 @@ mod tests {
|
||||
fn test_evaporator_creation() {
|
||||
let evaporator = Evaporator::new(8_000.0);
|
||||
assert_eq!(evaporator.ua(), 8_000.0);
|
||||
assert_eq!(evaporator.n_equations(), 3);
|
||||
assert_eq!(evaporator.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -195,7 +195,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_evaporator_coil_n_equations() {
|
||||
let coil = EvaporatorCoil::new(5_000.0);
|
||||
assert_eq!(coil.n_equations(), 3);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,7 @@ pub struct HeatExchangerBuilder<Model: HeatTransferModel> {
|
||||
circuit_id: CircuitId,
|
||||
}
|
||||
|
||||
impl<Model: HeatTransferModel> HeatExchangerBuilder<Model> {
|
||||
impl<Model: HeatTransferModel + 'static> HeatExchangerBuilder<Model> {
|
||||
/// Creates a new builder.
|
||||
pub fn new(model: Model) -> Self {
|
||||
Self {
|
||||
@@ -200,7 +200,7 @@ impl<Model: HeatTransferModel + std::fmt::Debug> std::fmt::Debug for HeatExchang
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
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();
|
||||
@@ -283,6 +283,14 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
}
|
||||
|
||||
/// Returns the hot side fluid identifier, if set.
|
||||
pub fn hot_conditions(&self) -> Option<&HxSideConditions> {
|
||||
self.hot_conditions.as_ref()
|
||||
}
|
||||
|
||||
pub fn cold_conditions(&self) -> Option<&HxSideConditions> {
|
||||
self.cold_conditions.as_ref()
|
||||
}
|
||||
|
||||
pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
|
||||
self.hot_conditions.as_ref().map(|c| c.fluid_id())
|
||||
}
|
||||
@@ -398,6 +406,19 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
self.model.effective_ua(None)
|
||||
}
|
||||
|
||||
/// Returns the nominal (base) UA value [W/K] before any scaling.
|
||||
pub fn ua_nominal(&self) -> f64 {
|
||||
self.model.ua()
|
||||
}
|
||||
|
||||
/// Sets the UA scale factor directly (UA_eff = scale × UA_nominal).
|
||||
///
|
||||
/// Used by `MchxCondenserCoil` to apply fan-speed and air-density corrections
|
||||
/// without rebuilding the component.
|
||||
pub fn set_ua_scale(&mut self, scale: f64) {
|
||||
self.model.set_ua_scale(scale.max(0.0));
|
||||
}
|
||||
|
||||
/// Returns the current operational state.
|
||||
pub fn operational_state(&self) -> OperationalState {
|
||||
self.operational_state
|
||||
@@ -439,13 +460,21 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
) -> FluidState {
|
||||
FluidState::new(temperature, pressure, enthalpy, mass_flow, cp)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
fn compute_residuals(
|
||||
pub fn compute_residuals_with_ua_scale(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
custom_ua_scale: f64,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.do_compute_residuals(_state, residuals, Some(custom_ua_scale))
|
||||
}
|
||||
|
||||
pub fn do_compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
custom_ua_scale: Option<f64>,
|
||||
) -> Result<(), ComponentError> {
|
||||
if residuals.len() < self.n_equations() {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
@@ -476,17 +505,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
}
|
||||
}
|
||||
|
||||
// Build inlet FluidState values.
|
||||
// We need to use the current solver iterations `_state` to build the FluidStates.
|
||||
// Because port mapping isn't fully implemented yet, we assume the inputs from the caller
|
||||
// (the solver) are being passed in order, but for now since `HeatExchanger` is
|
||||
// generic and expects full states, we must query the backend using the *current*
|
||||
// state values. Wait, `_state` has length `self.n_equations() == 3` (energy residuals).
|
||||
// It DOES NOT store the full fluid state for all 4 ports. The full fluid state is managed
|
||||
// at the System level via Ports.
|
||||
// Let's refine the approach: we still need to query properties. The original implementation
|
||||
// was a placeholder because component port state pulling is part of Epic 1.3 / Epic 4.
|
||||
|
||||
let (hot_inlet, hot_outlet, cold_inlet, cold_outlet) =
|
||||
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
|
||||
&self.hot_conditions,
|
||||
@@ -504,16 +522,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
hot_cp,
|
||||
);
|
||||
|
||||
// Extract current iteration values from `_state` if available, or fallback to heuristics.
|
||||
// The `SystemState` passed here contains the global state variables.
|
||||
// For a 3-equation heat exchanger, the state variables associated with it
|
||||
// are typically the outlet enthalpies and the heat transfer rate Q.
|
||||
// Because we lack definitive `Port` mappings inside `HeatExchanger` right now,
|
||||
// we'll attempt a safe estimation that incorporates `_state` conceptually,
|
||||
// but avoids direct indexing out of bounds. The real fix for "ignoring _state"
|
||||
// is that the system solver maps global `_state` into port conditions.
|
||||
|
||||
// Estimate hot outlet enthalpy (will be refined by solver convergence):
|
||||
let hot_dh = hot_cp * 5.0; // J/kg per degree
|
||||
let hot_outlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k() - 5.0,
|
||||
@@ -544,9 +552,6 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
} else {
|
||||
// Fallback: physically-plausible placeholder values (no backend configured).
|
||||
// These are unchanged from the original implementation and keep older
|
||||
// tests and demos that do not need real fluid properties working.
|
||||
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);
|
||||
@@ -555,7 +560,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
};
|
||||
|
||||
let dynamic_f_ua = self.calib_indices.f_ua.map(|idx| _state[idx]);
|
||||
let dynamic_f_ua = custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
|
||||
|
||||
self.model.compute_residuals(
|
||||
&hot_inlet,
|
||||
@@ -568,6 +573,16 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.do_compute_residuals(_state, residuals, None)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
@@ -777,7 +792,7 @@ mod tests {
|
||||
let model = LmtdModel::counter_flow(1000.0);
|
||||
let hx = HeatExchanger::new(model, "Test");
|
||||
|
||||
assert_eq!(hx.n_equations(), 3);
|
||||
assert_eq!(hx.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -798,7 +813,7 @@ mod tests {
|
||||
let hx = HeatExchanger::new(model, "Test");
|
||||
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
let mut residuals = vec![0.0; 1];
|
||||
|
||||
let result = hx.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_err());
|
||||
|
||||
660
crates/components/src/heat_exchanger/flooded_condenser.rs
Normal file
660
crates/components/src/heat_exchanger/flooded_condenser.rs
Normal file
@@ -0,0 +1,660 @@
|
||||
//! FloodedCondenser - Flooded (accumulation) condenser component
|
||||
//!
|
||||
//! Models a heat exchanger where condensed refrigerant forms a liquid bath
|
||||
//! around the cooling tubes, regulating condensing pressure. The outlet
|
||||
//! is subcooled liquid (not saturated or two-phase).
|
||||
//!
|
||||
//! ## Difference from Standard Condenser
|
||||
//!
|
||||
//! - Standard Condenser: May have two-phase or saturated liquid outlet
|
||||
//! - FloodedCondenser: Outlet is always subcooled liquid, liquid bath maintains stable P_cond
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use entropyk_components::heat_exchanger::FloodedCondenser;
|
||||
//! use entropyk_core::MassFlow;
|
||||
//!
|
||||
//! let cond = FloodedCondenser::new(15_000.0)
|
||||
//! .with_target_subcooling(5.0);
|
||||
//!
|
||||
//! assert_eq!(cond.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
||||
use super::exchanger::{HeatExchanger, HxSideConditions};
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, MassFlow, Power, Pressure};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MIN_UA: f64 = 0.0;
|
||||
|
||||
pub struct FloodedCondenser {
|
||||
inner: HeatExchanger<EpsNtuModel>,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
target_subcooling_k: f64,
|
||||
subcooling_control_enabled: bool,
|
||||
last_heat_transfer_w: Cell<f64>,
|
||||
last_subcooling_k: Cell<Option<f64>>,
|
||||
outlet_pressure_idx: Option<usize>,
|
||||
outlet_enthalpy_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FloodedCondenser {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FloodedCondenser")
|
||||
.field("ua", &self.ua())
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("target_subcooling_k", &self.target_subcooling_k)
|
||||
.field(
|
||||
"subcooling_control_enabled",
|
||||
&self.subcooling_control_enabled,
|
||||
)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FloodedCondenser {
|
||||
pub fn new(ua: f64) -> Self {
|
||||
assert!(ua >= MIN_UA, "UA must be non-negative, got {}", ua);
|
||||
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
|
||||
Self {
|
||||
inner: HeatExchanger::new(model, "FloodedCondenser"),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
target_subcooling_k: 5.0,
|
||||
subcooling_control_enabled: false,
|
||||
last_heat_transfer_w: Cell::new(0.0),
|
||||
last_subcooling_k: Cell::new(None),
|
||||
outlet_pressure_idx: None,
|
||||
outlet_enthalpy_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_new(ua: f64) -> Result<Self, ComponentError> {
|
||||
if ua < MIN_UA {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"FloodedCondenser: UA must be non-negative, got {}",
|
||||
ua
|
||||
)));
|
||||
}
|
||||
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
|
||||
Ok(Self {
|
||||
inner: HeatExchanger::new(model, "FloodedCondenser"),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
target_subcooling_k: 5.0,
|
||||
subcooling_control_enabled: false,
|
||||
last_heat_transfer_w: Cell::new(0.0),
|
||||
last_subcooling_k: Cell::new(None),
|
||||
outlet_pressure_idx: None,
|
||||
outlet_enthalpy_idx: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.secondary_fluid_id = fluid.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_target_subcooling(mut self, subcooling_k: f64) -> Self {
|
||||
self.target_subcooling_k = subcooling_k.max(0.0);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_subcooling_control(mut self, enabled: bool) -> Self {
|
||||
self.subcooling_control_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.inner.set_calib(calib);
|
||||
}
|
||||
|
||||
pub fn target_subcooling(&self) -> f64 {
|
||||
self.target_subcooling_k
|
||||
}
|
||||
|
||||
pub fn set_target_subcooling(&mut self, subcooling_k: f64) {
|
||||
self.target_subcooling_k = subcooling_k.max(0.0);
|
||||
}
|
||||
|
||||
pub fn heat_transfer(&self) -> f64 {
|
||||
self.last_heat_transfer_w.get()
|
||||
}
|
||||
|
||||
pub fn subcooling(&self) -> Option<f64> {
|
||||
self.last_subcooling_k.get()
|
||||
}
|
||||
|
||||
pub fn set_outlet_indices(&mut self, p_idx: usize, h_idx: usize) {
|
||||
self.outlet_pressure_idx = Some(p_idx);
|
||||
self.outlet_enthalpy_idx = Some(h_idx);
|
||||
}
|
||||
|
||||
pub fn set_secondary_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_cold_conditions(conditions);
|
||||
}
|
||||
|
||||
pub fn set_refrigerant_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_hot_conditions(conditions);
|
||||
}
|
||||
|
||||
fn compute_subcooling(&self, h_out: f64, p_pa: f64) -> Option<f64> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if h_out < h_sat_l {
|
||||
let cp_l = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Cp,
|
||||
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.0)),
|
||||
)
|
||||
.unwrap_or(4180.0);
|
||||
Some((h_sat_l - h_out) / cp_l)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_outlet_subcooled(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FloodedCondenser: refrigerant_id not set".to_string(),
|
||||
));
|
||||
}
|
||||
if self.fluid_backend.is_none() {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"FloodedCondenser: FluidBackend not configured".to_string(),
|
||||
));
|
||||
}
|
||||
match self.compute_subcooling(h_out, p_pa) {
|
||||
Some(sc) => Ok(sc),
|
||||
None => Err(ComponentError::InvalidState(format!(
|
||||
"FloodedCondenser outlet is not subcooled (h_out >= h_sat_l). Use standard Condenser for two-phase outlet."
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FloodedCondenser {
|
||||
fn n_equations(&self) -> usize {
|
||||
let base = 3;
|
||||
if self.subcooling_control_enabled {
|
||||
base + 1
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let mut inner_residuals = vec![0.0; 3];
|
||||
self.inner.compute_residuals(state, &mut inner_residuals)?;
|
||||
|
||||
residuals[0] = inner_residuals[0];
|
||||
residuals[1] = inner_residuals[1];
|
||||
residuals[2] = inner_residuals[2];
|
||||
|
||||
if let Some((heat, _)) = self.inner.energy_transfers(state) {
|
||||
self.last_heat_transfer_w.set(heat.to_watts());
|
||||
}
|
||||
|
||||
if self.subcooling_control_enabled && residuals.len() >= 4 {
|
||||
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx)
|
||||
{
|
||||
let p_pa = *state.get(p_idx).unwrap_or(&0.0);
|
||||
let h_out = *state.get(h_idx).unwrap_or(&0.0);
|
||||
|
||||
if let Some(actual_subcooling) = self.compute_subcooling(h_out, p_pa) {
|
||||
residuals[3] = actual_subcooling - self.target_subcooling_k;
|
||||
self.last_subcooling_k.set(Some(actual_subcooling));
|
||||
} else {
|
||||
residuals[3] = 0.0;
|
||||
self.last_subcooling_k.set(None);
|
||||
}
|
||||
} else {
|
||||
residuals[3] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
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<MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FloodedCondenser(UA={:.0},fluid={},target_sc={:.1}K)",
|
||||
self.ua(),
|
||||
self.refrigerant_id,
|
||||
self.target_subcooling_k
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for FloodedCondenser {
|
||||
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_flooded_condenser_creation() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
assert_eq!(cond.ua(), 15_000.0);
|
||||
assert_eq!(cond.n_equations(), 3);
|
||||
assert_eq!(cond.target_subcooling(), 5.0);
|
||||
assert_eq!(cond.subcooling(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "UA must be non-negative")]
|
||||
fn test_flooded_condenser_negative_ua_panics() {
|
||||
let _cond = FloodedCondenser::new(-100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_zero_ua_allowed() {
|
||||
let cond = FloodedCondenser::new(0.0);
|
||||
assert_eq!(cond.ua(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_with_subcooling_control() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_subcooling_control(true);
|
||||
assert_eq!(cond.n_equations(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_with_target_subcooling() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_target_subcooling(8.0);
|
||||
assert_eq!(cond.target_subcooling(), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_clamps_subcooling() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_target_subcooling(-5.0);
|
||||
assert_eq!(cond.target_subcooling(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_compute_residuals() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
let result = cond.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_state_manageable() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
assert_eq!(cond.state(), OperationalState::On);
|
||||
assert!(cond.can_transition_to(OperationalState::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_set_state() {
|
||||
let mut cond = FloodedCondenser::new(15_000.0);
|
||||
assert_eq!(cond.state(), OperationalState::On);
|
||||
|
||||
let result = cond.set_state(OperationalState::Off);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(cond.state(), OperationalState::Off);
|
||||
|
||||
let result = cond.set_state(OperationalState::Bypass);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(cond.state(), OperationalState::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_signature() {
|
||||
let cond = FloodedCondenser::new(15_000.0)
|
||||
.with_refrigerant("R410A")
|
||||
.with_target_subcooling(7.0);
|
||||
|
||||
let sig = cond.signature();
|
||||
assert!(sig.contains("FloodedCondenser"));
|
||||
assert!(sig.contains("R410A"));
|
||||
assert!(sig.contains("7.0K"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_set_target_subcooling() {
|
||||
let mut cond = FloodedCondenser::new(15_000.0);
|
||||
cond.set_target_subcooling(6.5);
|
||||
assert_eq!(cond.target_subcooling(), 6.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_energy_transfers() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
let state = vec![0.0; 10];
|
||||
let (heat, work) = cond.energy_transfers(&state).unwrap();
|
||||
assert_eq!(heat.to_watts(), 0.0);
|
||||
assert_eq!(work.to_watts(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_with_refrigerant() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_refrigerant("R134a");
|
||||
let sig = cond.signature();
|
||||
assert!(sig.contains("R134a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_with_secondary_fluid() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_secondary_fluid("Water");
|
||||
let debug_str = format!("{:?}", cond);
|
||||
assert!(debug_str.contains("Water"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_subcooled_no_refrigerant() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
let result = cond.validate_outlet_subcooled(200_000.0, 1_000_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("refrigerant_id not set"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_subcooled_no_backend() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_refrigerant("R134a");
|
||||
let result = cond.validate_outlet_subcooled(200_000.0, 1_000_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::CalculationFailed(msg)) => {
|
||||
assert!(msg.contains("FluidBackend not configured"));
|
||||
}
|
||||
_ => panic!("Expected CalculationFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_outlet_indices() {
|
||||
let mut cond = FloodedCondenser::new(15_000.0).with_subcooling_control(true);
|
||||
cond.set_outlet_indices(2, 3);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heat_transfer_initial_value() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
assert_eq!(cond.heat_transfer(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_new_valid_ua() {
|
||||
let cond = FloodedCondenser::try_new(15_000.0);
|
||||
assert!(cond.is_ok());
|
||||
assert_eq!(cond.unwrap().ua(), 15_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_try_new_invalid_ua() {
|
||||
let result = FloodedCondenser::try_new(-100.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("UA must be non-negative"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_without_subcooling_control() {
|
||||
let cond = FloodedCondenser::new(15_000.0).with_subcooling_control(false);
|
||||
assert_eq!(cond.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_condenser_calib_default() {
|
||||
let cond = FloodedCondenser::new(15_000.0);
|
||||
let calib = cond.calib();
|
||||
assert_eq!(calib.f_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;
|
||||
cond.set_calib(calib);
|
||||
assert_eq!(cond.calib().f_ua, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subcooling_calculation_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
|
||||
impl FluidBackend for MockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: Property,
|
||||
_state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Enthalpy => Ok(250_000.0),
|
||||
Property::Cp => Ok(4180.0),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
let backend: Arc<dyn FluidBackend> = Arc::new(MockBackend);
|
||||
let cond = FloodedCondenser::new(15_000.0)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let h_out = 200_000.0;
|
||||
let p_pa = 1_000_000.0;
|
||||
|
||||
let subcooling = cond.compute_subcooling(h_out, p_pa);
|
||||
assert!(subcooling.is_some());
|
||||
let sc = subcooling.unwrap();
|
||||
let expected = (250_000.0 - 200_000.0) / 4180.0;
|
||||
assert!((sc - expected).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_subcooled_with_mock_backend() {
|
||||
use entropyk_core::{Enthalpy, Temperature};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidResult, Phase, ThermoState};
|
||||
|
||||
struct MockBackend;
|
||||
|
||||
impl FluidBackend for MockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: Property,
|
||||
_state: FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
Property::Enthalpy => Ok(250_000.0),
|
||||
Property::Cp => Ok(4180.0),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn phase(&self, _fluid: FluidId, _state: FluidState) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
|
||||
fn full_state(
|
||||
&self,
|
||||
fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
let backend: Arc<dyn FluidBackend> = Arc::new(MockBackend);
|
||||
let cond = FloodedCondenser::new(15_000.0)
|
||||
.with_refrigerant("R134a")
|
||||
.with_fluid_backend(backend);
|
||||
|
||||
let h_out = 200_000.0;
|
||||
let p_pa = 1_000_000.0;
|
||||
|
||||
let result = cond.validate_outlet_subcooled(h_out, p_pa);
|
||||
assert!(result.is_ok());
|
||||
let sc = result.unwrap();
|
||||
let expected = (250_000.0 - 200_000.0) / 4180.0;
|
||||
assert!((sc - expected).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
530
crates/components/src/heat_exchanger/flooded_evaporator.rs
Normal file
530
crates/components/src/heat_exchanger/flooded_evaporator.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
//! FloodedEvaporator - Flooded (recirculation) evaporator component
|
||||
//!
|
||||
//! Models a heat exchanger where liquid refrigerant floods the tubes,
|
||||
//! typically used in industrial chillers with recirculation systems.
|
||||
//! Output quality is typically 0.5-0.8 (two-phase), not superheated.
|
||||
//!
|
||||
//! ## Difference from DX Evaporator
|
||||
//!
|
||||
//! - DX Evaporator: Output is superheated vapor (x >= 1), controlled by superheat
|
||||
//! - FloodedEvaporator: Output is two-phase (x ~ 0.5-0.8), controlled by quality
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use entropyk_components::heat_exchanger::FloodedEvaporator;
|
||||
//! use entropyk_core::MassFlow;
|
||||
//!
|
||||
//! let evap = FloodedEvaporator::new(10_000.0)
|
||||
//! .with_target_quality(0.7);
|
||||
//!
|
||||
//! assert_eq!(evap.n_equations(), 3);
|
||||
//! ```
|
||||
|
||||
use super::eps_ntu::{EpsNtuModel, ExchangerType};
|
||||
use super::exchanger::{HeatExchanger, HxSideConditions};
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Calib, MassFlow, Power, Pressure};
|
||||
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Minimum valid UA value (W/K).
|
||||
const MIN_UA: f64 = 0.0;
|
||||
|
||||
/// FloodedEvaporator - Heat exchanger for flooded (recirculation) systems.
|
||||
///
|
||||
/// Uses the epsilon-NTU method for heat transfer calculation.
|
||||
/// Output quality is typically 0.5-0.8 (two-phase mixture).
|
||||
pub struct FloodedEvaporator {
|
||||
inner: HeatExchanger<EpsNtuModel>,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
target_quality: f64,
|
||||
quality_control_enabled: bool,
|
||||
last_heat_transfer_w: f64,
|
||||
last_outlet_quality: Option<f64>,
|
||||
outlet_pressure_idx: Option<usize>,
|
||||
outlet_enthalpy_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FloodedEvaporator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FloodedEvaporator")
|
||||
.field("ua", &self.ua())
|
||||
.field("refrigerant_id", &self.refrigerant_id)
|
||||
.field("secondary_fluid_id", &self.secondary_fluid_id)
|
||||
.field("target_quality", &self.target_quality)
|
||||
.field("quality_control_enabled", &self.quality_control_enabled)
|
||||
.field("has_fluid_backend", &self.fluid_backend.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FloodedEvaporator {
|
||||
/// Creates a new flooded evaporator with the given UA value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ua` - Overall heat transfer coefficient x Area (W/K). Must be >= 0.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `ua` is negative.
|
||||
pub fn new(ua: f64) -> Self {
|
||||
assert!(ua >= MIN_UA, "UA must be non-negative, got {}", ua);
|
||||
let model = EpsNtuModel::new(ua, ExchangerType::CounterFlow);
|
||||
Self {
|
||||
inner: HeatExchanger::new(model, "FloodedEvaporator"),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
target_quality: 0.7,
|
||||
quality_control_enabled: false,
|
||||
last_heat_transfer_w: 0.0,
|
||||
last_outlet_quality: None,
|
||||
outlet_pressure_idx: None,
|
||||
outlet_enthalpy_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
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
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend for saturation calculations.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the target outlet quality (0.0 to 1.0).
|
||||
///
|
||||
/// Default is 0.7 (70% vapor, typical for flooded evaporators).
|
||||
pub fn with_target_quality(mut self, quality: f64) -> Self {
|
||||
self.target_quality = quality.clamp(0.0, 1.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables quality control equation (adds 1 equation).
|
||||
///
|
||||
/// When enabled, the solver will adjust variables to achieve target quality.
|
||||
pub fn with_quality_control(mut self, enabled: bool) -> Self {
|
||||
self.quality_control_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the component name.
|
||||
pub fn name(&self) -> &str {
|
||||
self.inner.name()
|
||||
}
|
||||
|
||||
/// Returns the effective UA value (W/K).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
/// Returns calibration factors.
|
||||
pub fn calib(&self) -> &Calib {
|
||||
self.inner.calib()
|
||||
}
|
||||
|
||||
/// Sets calibration factors.
|
||||
pub fn set_calib(&mut self, calib: Calib) {
|
||||
self.inner.set_calib(calib);
|
||||
}
|
||||
|
||||
/// Returns the target outlet quality.
|
||||
pub fn target_quality(&self) -> f64 {
|
||||
self.target_quality
|
||||
}
|
||||
|
||||
/// Sets the target outlet quality.
|
||||
pub fn set_target_quality(&mut self, quality: f64) {
|
||||
self.target_quality = quality.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Returns the last computed heat transfer rate (W).
|
||||
///
|
||||
/// Returns 0.0 if `compute_residuals` has not been called.
|
||||
pub fn heat_transfer(&self) -> f64 {
|
||||
self.last_heat_transfer_w
|
||||
}
|
||||
|
||||
/// Returns the last computed outlet quality.
|
||||
///
|
||||
/// Returns `None` if `compute_residuals` has not been called or
|
||||
/// quality could not be computed (no FluidBackend).
|
||||
pub fn outlet_quality(&self) -> Option<f64> {
|
||||
self.last_outlet_quality
|
||||
}
|
||||
|
||||
/// Sets the outlet state indices for quality control.
|
||||
///
|
||||
/// These indices point to the pressure and enthalpy in the global state vector
|
||||
/// that represent the refrigerant outlet conditions.
|
||||
pub fn set_outlet_indices(&mut self, p_idx: usize, h_idx: usize) {
|
||||
self.outlet_pressure_idx = Some(p_idx);
|
||||
self.outlet_enthalpy_idx = Some(h_idx);
|
||||
}
|
||||
|
||||
/// Sets the hot side (secondary fluid) boundary conditions.
|
||||
pub fn set_secondary_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_hot_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Sets the cold side (refrigerant) boundary conditions.
|
||||
pub fn set_refrigerant_conditions(&mut self, conditions: HxSideConditions) {
|
||||
self.inner.set_cold_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Computes outlet quality from enthalpy and saturation properties.
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - No FluidBackend is configured
|
||||
/// - Refrigerant ID is empty
|
||||
/// - 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;
|
||||
}
|
||||
let backend = self.fluid_backend.as_ref()?;
|
||||
let fluid = FluidId::new(&self.refrigerant_id);
|
||||
let p = Pressure::from_pascals(p_pa);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(0.0)),
|
||||
)
|
||||
.ok()?;
|
||||
let h_sat_v = backend
|
||||
.property(
|
||||
fluid,
|
||||
Property::Enthalpy,
|
||||
FluidState::from_px(p, Quality::new(1.0)),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
if h_sat_v > h_sat_l {
|
||||
let quality = (h_out - h_sat_l) / (h_sat_v - h_sat_l);
|
||||
Some(quality.clamp(0.0, 1.0))
|
||||
} else {
|
||||
// Invalid saturation envelope - return None
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that outlet is in two-phase region (x < 1).
|
||||
///
|
||||
/// Returns Err if outlet is superheated (should use DX Evaporator instead).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `InvalidState`: Quality >= 1.0 (superheated outlet)
|
||||
/// - `CalculationFailed`: No FluidBackend configured or empty refrigerant ID
|
||||
pub fn validate_outlet_quality(&self, h_out: f64, p_pa: f64) -> Result<f64, ComponentError> {
|
||||
if self.refrigerant_id.is_empty() {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"FloodedEvaporator: refrigerant_id not set".to_string(),
|
||||
));
|
||||
}
|
||||
if self.fluid_backend.is_none() {
|
||||
return Err(ComponentError::CalculationFailed(
|
||||
"FloodedEvaporator: FluidBackend not configured".to_string(),
|
||||
));
|
||||
}
|
||||
match self.compute_quality(h_out, p_pa) {
|
||||
Some(q) if q < 1.0 => Ok(q),
|
||||
Some(q) => Err(ComponentError::InvalidState(format!(
|
||||
"FloodedEvaporator outlet quality {:.2} >= 1.0 (superheated). Use DX Evaporator instead.",
|
||||
q
|
||||
))),
|
||||
None => Err(ComponentError::CalculationFailed(format!(
|
||||
"FloodedEvaporator: Cannot compute quality for {} at P={:.0} Pa",
|
||||
self.refrigerant_id, p_pa
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FloodedEvaporator {
|
||||
fn n_equations(&self) -> usize {
|
||||
let base = 2;
|
||||
if self.quality_control_enabled {
|
||||
base + 1
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let mut inner_residuals = vec![0.0; 3];
|
||||
self.inner.compute_residuals(state, &mut inner_residuals)?;
|
||||
|
||||
residuals[0] = inner_residuals[0];
|
||||
residuals[1] = inner_residuals[1];
|
||||
|
||||
if self.quality_control_enabled {
|
||||
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx)
|
||||
{
|
||||
let p_pa = *state.get(p_idx).unwrap_or(&0.0);
|
||||
let h_out = *state.get(h_idx).unwrap_or(&0.0);
|
||||
|
||||
if let Some(actual_quality) = self.compute_quality(h_out, p_pa) {
|
||||
residuals[2] = actual_quality - self.target_quality;
|
||||
} else {
|
||||
// If quality cannot be computed, set residual to a large value
|
||||
// or 0.0 depending on desired solver behavior.
|
||||
// For now, let's assume it means target quality is not met.
|
||||
residuals[2] = -self.target_quality; // Or some other error indicator
|
||||
}
|
||||
} else {
|
||||
// If indices are not set, quality control cannot be applied.
|
||||
// This should ideally be caught earlier or result in an error.
|
||||
// For now, set residual to indicate target quality is not met.
|
||||
residuals[2] = -self.target_quality;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
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<MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
|
||||
fn signature(&self) -> String {
|
||||
format!(
|
||||
"FloodedEvaporator(UA={:.0},fluid={},target_q={:.2})",
|
||||
self.ua(),
|
||||
self.refrigerant_id,
|
||||
self.target_quality
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for FloodedEvaporator {
|
||||
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_flooded_evaporator_creation() {
|
||||
let mut evap = FloodedEvaporator::new(10_000.0);
|
||||
assert_eq!(evap.ua(), 10_000.0);
|
||||
assert_eq!(evap.n_equations(), 2);
|
||||
assert_eq!(evap.target_quality(), 0.7);
|
||||
assert_eq!(evap.outlet_quality(), None);
|
||||
|
||||
// with quality control
|
||||
evap.quality_control_enabled = true;
|
||||
assert_eq!(evap.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "UA must be non-negative")]
|
||||
fn test_flooded_evaporator_negative_ua_panics() {
|
||||
let _evap = FloodedEvaporator::new(-100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_zero_ua_allowed() {
|
||||
let evap = FloodedEvaporator::new(0.0);
|
||||
assert_eq!(evap.ua(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_with_quality_control() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_quality_control(true);
|
||||
assert_eq!(evap.n_equations(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_with_target_quality() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_target_quality(0.6);
|
||||
assert_eq!(evap.target_quality(), 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_clamps_quality() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_target_quality(1.5);
|
||||
assert_eq!(evap.target_quality(), 1.0);
|
||||
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_target_quality(-0.5);
|
||||
assert_eq!(evap.target_quality(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_compute_residuals() {
|
||||
let evap = FloodedEvaporator::new(10_000.0);
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 2];
|
||||
|
||||
let result = evap.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_state_manageable() {
|
||||
let evap = FloodedEvaporator::new(10_000.0);
|
||||
assert_eq!(evap.state(), OperationalState::On);
|
||||
assert!(evap.can_transition_to(OperationalState::Off));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_set_state() {
|
||||
let mut evap = FloodedEvaporator::new(10_000.0);
|
||||
assert_eq!(evap.state(), OperationalState::On);
|
||||
|
||||
let result = evap.set_state(OperationalState::Off);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(evap.state(), OperationalState::Off);
|
||||
|
||||
let result = evap.set_state(OperationalState::Bypass);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(evap.state(), OperationalState::Bypass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_signature() {
|
||||
let evap = FloodedEvaporator::new(10_000.0)
|
||||
.with_refrigerant("R410A")
|
||||
.with_target_quality(0.75);
|
||||
|
||||
let sig = evap.signature();
|
||||
assert!(sig.contains("FloodedEvaporator"));
|
||||
assert!(sig.contains("R410A"));
|
||||
assert!(sig.contains("0.75"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_set_target_quality() {
|
||||
let mut evap = FloodedEvaporator::new(10_000.0);
|
||||
evap.set_target_quality(0.65);
|
||||
assert_eq!(evap.target_quality(), 0.65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_energy_transfers() {
|
||||
let evap = FloodedEvaporator::new(10_000.0);
|
||||
let state = vec![0.0; 10];
|
||||
let (heat, work) = evap.energy_transfers(&state).unwrap();
|
||||
assert_eq!(heat.to_watts(), 0.0);
|
||||
assert_eq!(work.to_watts(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_with_refrigerant() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_refrigerant("R134a");
|
||||
let sig = evap.signature();
|
||||
assert!(sig.contains("R134a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flooded_evaporator_with_secondary_fluid() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_secondary_fluid("Water");
|
||||
let debug_str = format!("{:?}", evap);
|
||||
assert!(debug_str.contains("Water"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_no_refrigerant() {
|
||||
let evap = FloodedEvaporator::new(10_000.0);
|
||||
let result = evap.validate_outlet_quality(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::InvalidState(msg)) => {
|
||||
assert!(msg.contains("refrigerant_id not set"));
|
||||
}
|
||||
_ => panic!("Expected InvalidState error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_no_backend() {
|
||||
let evap = FloodedEvaporator::new(10_000.0).with_refrigerant("R134a");
|
||||
let result = evap.validate_outlet_quality(400_000.0, 300_000.0);
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(ComponentError::CalculationFailed(msg)) => {
|
||||
assert!(msg.contains("FluidBackend not configured"));
|
||||
}
|
||||
_ => panic!("Expected CalculationFailed error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_outlet_indices() {
|
||||
let mut evap = FloodedEvaporator::new(10_000.0).with_quality_control(true);
|
||||
evap.set_outlet_indices(2, 3);
|
||||
|
||||
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_heat_transfer_initial_value() {
|
||||
let evap = FloodedEvaporator::new(10_000.0);
|
||||
assert_eq!(evap.heat_transfer(), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -211,11 +211,10 @@ impl HeatTransferModel for LmtdModel {
|
||||
|
||||
residuals[0] = q_hot - q;
|
||||
residuals[1] = q_cold - q;
|
||||
residuals[2] = q_hot - q_cold;
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
3
|
||||
2
|
||||
}
|
||||
|
||||
fn ua(&self) -> f64 {
|
||||
@@ -328,7 +327,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_n_equations() {
|
||||
let model = LmtdModel::counter_flow(1000.0);
|
||||
assert_eq!(model.n_equations(), 3);
|
||||
assert_eq!(model.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
482
crates/components/src/heat_exchanger/mchx_condenser_coil.rs
Normal file
482
crates/components/src/heat_exchanger/mchx_condenser_coil.rs
Normal file
@@ -0,0 +1,482 @@
|
||||
//! Microchannel Heat Exchanger (MCHX) Condenser Coil
|
||||
//!
|
||||
//! Models a multi-pass microchannel condenser coil used in air-cooled chillers,
|
||||
//! as an alternative to round-tube plate-fin (RTPF) condensers.
|
||||
//!
|
||||
//! ## MCHX vs. Conventional Coil
|
||||
//!
|
||||
//! A MCHX (Microchannel Heat Exchanger) uses flat multi-port extruded aluminium
|
||||
//! tubes with a louvered fin structure. Compared to round-tube/plate-fin (RTPF):
|
||||
//!
|
||||
//! | Property | RTPF | MCHX |
|
||||
//! |---------------------|-----------------------|-----------------------|
|
||||
//! | Air-side UA | Base | +30–60% per unit area |
|
||||
//! | Refrigerant charge | Base | −25–40% |
|
||||
//! | Air pressure drop | Base | Similar |
|
||||
//! | Weight | Base | −30% |
|
||||
//! | Air mal-distribution| Less sensitive | More sensitive |
|
||||
//!
|
||||
//! ## Model
|
||||
//!
|
||||
//! The model extends `CondenserCoil` (LMTD, UA-based) with:
|
||||
//!
|
||||
//! 1. **UA correction for air velocity** (`UA_eff = UA_nominal × f_air(G_air)`)
|
||||
//! based on ASHRAE Handbook correlations for louvered fins.
|
||||
//!
|
||||
//! 2. **UA correction for ambient temperature** (density effect on fan curves)
|
||||
//!
|
||||
//! 3. **Multi-pass arrangement** tracking: each coil (of 4 total) can have an
|
||||
//! independent fan group and air-side conditions.
|
||||
//!
|
||||
//! ### UA correction function
|
||||
//!
|
||||
//! ```text
|
||||
//! UA_eff = UA_nominal × (G_air / G_ref)^n_air
|
||||
//!
|
||||
//! where:
|
||||
//! G_air = air mass velocity [kg/(m²·s)] = ṁ_air / A_frontal
|
||||
//! G_ref = reference air mass velocity (at design point)
|
||||
//! n_air = 0.4–0.6 (typical for louvered fins, ASHRAE HoF 4.21)
|
||||
//! ```
|
||||
//!
|
||||
//! When a fan speed ratio is provided, air velocity scales as:
|
||||
//! ```text
|
||||
//! G_air = G_ref × fan_speed_ratio
|
||||
//! UA_eff = UA_nominal × fan_speed_ratio^n_air
|
||||
//! ```
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use entropyk_components::heat_exchanger::MchxCondenserCoil;
|
||||
//!
|
||||
//! // 4 coils, each with UA_nominal = 15 kW/K
|
||||
//! for i in 0..4 {
|
||||
//! let coil = MchxCondenserCoil::new(
|
||||
//! 15_000.0, // UA nominal [W/K]
|
||||
//! 0.6, // air-side exponent n_air (louvered fin ASHRAE)
|
||||
//! i, // coil index (0–3)
|
||||
//! );
|
||||
//! // Set air conditions from fan
|
||||
//! coil.set_air_conditions(35.0 + 273.15, 1.12, fan_speed_ratio);
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use super::condenser::Condenser;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
|
||||
/// Minimum fan speed ratio below which the coil is considered inactive.
|
||||
const MIN_ACTIVE_SPEED_RATIO: f64 = 0.05;
|
||||
|
||||
/// MCHX Condenser Coil with variable UA based on fan speed and air conditions.
|
||||
///
|
||||
/// Builds on `Condenser` (LMTD) and adds:
|
||||
/// - Air-side UA correction via louvered-fin correlation
|
||||
/// - Fan speed ratio tracking per coil (for anti-override control)
|
||||
/// - Ambient temperature input (air density correction)
|
||||
/// - Coil index for identification in the 4-coil bank
|
||||
#[derive(Debug)]
|
||||
pub struct MchxCondenserCoil {
|
||||
/// Inner condenser with LMTD model
|
||||
inner: Condenser,
|
||||
/// Nominal UA at design-point air conditions [W/K]
|
||||
ua_nominal: f64,
|
||||
/// Air-side heat transfer exponent for UA correction (0.4–0.6 typical)
|
||||
n_air: f64,
|
||||
/// Current fan speed ratio (0.0 = stopped, 1.0 = full speed)
|
||||
fan_speed_ratio: f64,
|
||||
/// Ambient air temperature [K]
|
||||
t_air_k: f64,
|
||||
/// Air density at ambient conditions [kg/m³]
|
||||
rho_air: f64,
|
||||
/// Coil index (0–3 for a 4-coil bank)
|
||||
coil_index: usize,
|
||||
/// Flag: coil is validated for Air fluid on cold side
|
||||
air_validated: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl MchxCondenserCoil {
|
||||
/// Creates a new MCHX condenser coil.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ua_nominal` — Design-point UA [W/K] (refrigerant-to-air)
|
||||
/// * `n_air` — Air-side exponent for UA vs. velocity: UA ∝ G^n_air
|
||||
/// Typical range 0.4–0.6 for louvered-fin MCHX.
|
||||
/// * `coil_index` — Coil number in the bank (0-based, 0–3 for 4 coils)
|
||||
///
|
||||
/// # Design Point Reference
|
||||
///
|
||||
/// The design point is fan_speed_ratio = 1.0, T_air = 35°C (308.15 K).
|
||||
/// UA correction is relative to this point.
|
||||
pub fn new(ua_nominal: f64, n_air: f64, coil_index: usize) -> Self {
|
||||
assert!(ua_nominal >= 0.0, "UA must be non-negative");
|
||||
assert!(n_air >= 0.0 && n_air <= 1.5, "n_air must be in [0, 1.5]");
|
||||
|
||||
Self {
|
||||
inner: Condenser::new(ua_nominal),
|
||||
ua_nominal,
|
||||
n_air,
|
||||
fan_speed_ratio: 1.0,
|
||||
t_air_k: 35.0 + 273.15, // default 35°C
|
||||
rho_air: 1.12, // kg/m³ at 35°C, sea level
|
||||
coil_index,
|
||||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a coil with specific design parameters for a 35°C ambient system.
|
||||
///
|
||||
/// Convenience constructor matching the chiller spec (35°C air, 4 coils).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ua_per_coil` — UA per coil at design point [W/K]
|
||||
/// * `coil_index` — Coil index (0–3)
|
||||
pub fn for_35c_ambient(ua_per_coil: f64, coil_index: usize) -> Self {
|
||||
// n_air = 0.5 is the typical ASHRAE recommendation for louvered-fin MCHX
|
||||
let mut coil = Self::new(ua_per_coil, 0.5, coil_index);
|
||||
coil.t_air_k = 35.0 + 273.15;
|
||||
coil.rho_air = air_density_kg_m3(35.0 + 273.15, 101_325.0);
|
||||
coil
|
||||
}
|
||||
|
||||
// ─── Setters ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Sets the fan speed ratio for this coil (0.0–1.0).
|
||||
///
|
||||
/// This updates the effective UA through the UA-velocity correlation.
|
||||
/// Anti-override control adjusts this value to prevent condensing pressure
|
||||
/// from rising above safe limits in high-ambient conditions.
|
||||
pub fn set_fan_speed_ratio(&mut self, ratio: f64) {
|
||||
self.fan_speed_ratio = ratio.clamp(0.0, 1.0);
|
||||
self.update_ua_effective();
|
||||
}
|
||||
|
||||
/// Sets ambient air temperature [°C] and updates UA correction.
|
||||
pub fn set_air_temperature_celsius(&mut self, t_celsius: f64) {
|
||||
self.t_air_k = t_celsius + 273.15;
|
||||
// Update air density (ideal gas approximation)
|
||||
self.rho_air = air_density_kg_m3(self.t_air_k, 101_325.0);
|
||||
self.update_ua_effective();
|
||||
}
|
||||
|
||||
/// Sets air conditions explicitly (temperature, density, fan speed).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `t_air_k` — Air temperature [K]
|
||||
/// * `rho_air_kg_m3` — Air density [kg/m³]
|
||||
/// * `fan_speed_ratio` — Fan speed ratio (0.0–1.0)
|
||||
pub fn set_air_conditions(&mut self, t_air_k: f64, rho_air_kg_m3: f64, fan_speed_ratio: f64) {
|
||||
self.t_air_k = t_air_k;
|
||||
self.rho_air = rho_air_kg_m3.max(0.5);
|
||||
self.fan_speed_ratio = fan_speed_ratio.clamp(0.0, 1.0);
|
||||
self.update_ua_effective();
|
||||
// Also update the inner condenser's air inlet temperature for LMTD
|
||||
// (This sets the cold-side inlet temperature used by the LMTD model)
|
||||
// The inner Condenser doesn't expose this directly; we handle it via
|
||||
// the saturation temperature set-point instead.
|
||||
}
|
||||
|
||||
// ─── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns nominal UA [W/K].
|
||||
pub fn ua_nominal(&self) -> f64 {
|
||||
self.ua_nominal
|
||||
}
|
||||
|
||||
/// Returns the current effective UA after fan-speed and air corrections [W/K].
|
||||
pub fn ua_effective(&self) -> f64 {
|
||||
self.inner.ua()
|
||||
}
|
||||
|
||||
/// Returns the current fan speed ratio.
|
||||
pub fn fan_speed_ratio(&self) -> f64 {
|
||||
self.fan_speed_ratio
|
||||
}
|
||||
|
||||
/// Returns the ambient air temperature [K].
|
||||
pub fn t_air_k(&self) -> f64 {
|
||||
self.t_air_k
|
||||
}
|
||||
|
||||
/// Returns the coil index in the bank.
|
||||
pub fn coil_index(&self) -> usize {
|
||||
self.coil_index
|
||||
}
|
||||
|
||||
/// Returns the air-side exponent.
|
||||
pub fn n_air(&self) -> f64 {
|
||||
self.n_air
|
||||
}
|
||||
|
||||
/// Returns the inner `Condenser` (for state access).
|
||||
pub fn inner(&self) -> &Condenser {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
// ─── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Recalculates effective UA from current fan speed and air density.
|
||||
///
|
||||
/// ```text
|
||||
/// UA_eff = UA_nominal × (ρ_air / ρ_ref) × fan_speed_ratio^n_air
|
||||
/// ```
|
||||
///
|
||||
/// The density correction accounts for reduced air mass flow at high ambient
|
||||
/// temperatures (same volumetric flow → lower mass flow → lower UA).
|
||||
///
|
||||
/// Reference conditions: 35°C, ρ_ref = 1.12 kg/m³.
|
||||
fn update_ua_effective(&mut self) {
|
||||
const RHO_REF: f64 = 1.12; // kg/m³ at 35°C, 101 325 Pa
|
||||
|
||||
// Density correction: lower air density → lower mass flow → lower UA
|
||||
let rho_correction = self.rho_air / RHO_REF;
|
||||
|
||||
// Velocity correction: UA ∝ G^n ≈ (speed_ratio)^n at constant frontal area
|
||||
let velocity_correction = if self.fan_speed_ratio < MIN_ACTIVE_SPEED_RATIO {
|
||||
0.0 // fan stopped → coil inactive
|
||||
} else {
|
||||
self.fan_speed_ratio.powf(self.n_air)
|
||||
};
|
||||
|
||||
// Combined scale = (ρ/ρ_ref) × speed^n_air
|
||||
let scale = rho_correction * velocity_correction;
|
||||
self.inner.set_ua(self.ua_nominal * scale.max(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Air density helper ──────────────────────────────────────────────────────
|
||||
|
||||
/// Computes dry air density via ideal gas law.
|
||||
///
|
||||
/// ρ = P / (R_air × T) where R_air = 287.058 J/(kg·K)
|
||||
fn air_density_kg_m3(t_k: f64, p_pa: f64) -> f64 {
|
||||
const R_AIR: f64 = 287.058; // J/(kg·K)
|
||||
if t_k > 0.0 && p_pa > 0.0 {
|
||||
p_pa / (R_AIR * t_k)
|
||||
} else {
|
||||
1.2 // fallback standard air
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component trait
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl Component for MchxCondenserCoil {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Validate Air on the cold side (lazy check like CondenserCoil)
|
||||
if !self
|
||||
.air_validated
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
if let Some(fluid_id) = self.inner.cold_fluid_id() {
|
||||
if fluid_id.0.as_str() != "Air" {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"MchxCondenserCoil[{}]: requires Air on cold side, found {}",
|
||||
self.coil_index,
|
||||
fluid_id.0.as_str()
|
||||
)));
|
||||
}
|
||||
self.air_validated
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
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_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!(
|
||||
"MchxCondenserCoil[{}](UA_nom={:.0},UA_eff={:.0},fan={:.2},T_air={:.1}K)",
|
||||
self.coil_index,
|
||||
self.ua_nominal,
|
||||
self.ua_effective(),
|
||||
self.fan_speed_ratio,
|
||||
self.t_air_k
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for MchxCondenserCoil {
|
||||
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::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn test_creation_defaults() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
|
||||
assert_eq!(coil.ua_nominal(), 15_000.0);
|
||||
assert_eq!(coil.fan_speed_ratio(), 1.0);
|
||||
assert_eq!(coil.coil_index(), 0);
|
||||
assert_eq!(coil.n_air(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_35c_ambient_constructor() {
|
||||
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 2);
|
||||
assert_eq!(coil.coil_index(), 2);
|
||||
assert!((coil.t_air_k() - 308.15).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_effective_at_full_speed() {
|
||||
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
// At design point: fan=1.0, T_air=35°C → UA_eff ≈ UA_nominal
|
||||
// (small delta due to density rounding)
|
||||
assert_relative_eq!(coil.ua_effective(), 15_000.0, epsilon = 500.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_effective_at_half_speed() {
|
||||
let coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
let ua_full = coil.ua_effective();
|
||||
|
||||
let mut coil2 = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
coil2.set_fan_speed_ratio(0.5);
|
||||
let ua_half = coil2.ua_effective();
|
||||
|
||||
// UA_half = UA_full × 0.5^0.5 ≈ UA_full × 0.707
|
||||
let ratio = ua_half / ua_full;
|
||||
assert_relative_eq!(ratio, 0.5_f64.sqrt(), epsilon = 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_effective_fan_stopped() {
|
||||
let mut coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
coil.set_fan_speed_ratio(0.0);
|
||||
assert_eq!(coil.ua_effective(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_effective_fan_below_minimum() {
|
||||
let mut coil = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
coil.set_fan_speed_ratio(0.01); // below MIN_ACTIVE_SPEED_RATIO
|
||||
assert_eq!(coil.ua_effective(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ua_decreases_with_higher_temperature() {
|
||||
// Higher ambient → lower air density → lower UA
|
||||
let mut coil_35 = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
coil_35.set_air_conditions(35.0 + 273.15, air_density_kg_m3(308.15, 101_325.0), 1.0);
|
||||
let ua_35 = coil_35.ua_effective();
|
||||
|
||||
let mut coil_45 = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
coil_45.set_air_temperature_celsius(45.0);
|
||||
let ua_45 = coil_45.ua_effective();
|
||||
|
||||
assert!(ua_45 < ua_35, "UA at 45°C should be less than at 35°C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n_equations_is_two() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
|
||||
assert_eq!(coil.n_equations(), 2);
|
||||
}
|
||||
|
||||
#[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 result = coil.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_ok());
|
||||
assert!(residuals.iter().all(|r| r.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signature_contains_index() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 2);
|
||||
let sig = coil.signature();
|
||||
assert!(sig.contains("MchxCondenserCoil[2]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_state_manageable() {
|
||||
let coil = MchxCondenserCoil::new(15_000.0, 0.5, 0);
|
||||
assert_eq!(coil.state(), OperationalState::On);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_air_density_helper() {
|
||||
// At 20°C and 101325 Pa: ρ ≈ 1.204 kg/m³
|
||||
let rho = air_density_kg_m3(293.15, 101_325.0);
|
||||
assert_relative_eq!(rho, 1.204, epsilon = 0.005);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@
|
||||
//! - [`BphxGeometry`]: Geometry specification for BPHX
|
||||
//! - [`BphxCorrelation`]: Heat transfer correlation selection
|
||||
//!
|
||||
//! ## BPHX Evaporator (Story 11.6)
|
||||
//!
|
||||
//! - [`BphxEvaporator`]: Plate evaporator supporting DX and Flooded modes
|
||||
//! - [`BphxEvaporatorMode`]: Operation mode (DX with superheat or Flooded with quality)
|
||||
//!
|
||||
//! ## BPHX Condenser (Story 11.7)
|
||||
//!
|
||||
//! - [`BphxCondenser`]: Plate condenser with subcooled liquid outlet
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust
|
||||
@@ -39,7 +48,9 @@
|
||||
//! // Heat exchanger would be created with connected ports
|
||||
//! ```
|
||||
|
||||
pub mod bphx_condenser;
|
||||
pub mod bphx_correlation;
|
||||
pub mod bphx_evaporator;
|
||||
pub mod bphx_exchanger;
|
||||
pub mod bphx_geometry;
|
||||
pub mod condenser;
|
||||
@@ -54,11 +65,14 @@ pub mod flooded_evaporator;
|
||||
pub mod lmtd;
|
||||
pub mod mchx_condenser_coil;
|
||||
pub mod model;
|
||||
pub mod moving_boundary_hx;
|
||||
|
||||
pub use bphx_condenser::BphxCondenser;
|
||||
pub use bphx_correlation::{
|
||||
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
|
||||
ValidityStatus,
|
||||
};
|
||||
pub use bphx_evaporator::{BphxEvaporator, BphxEvaporatorMode};
|
||||
pub use bphx_exchanger::BphxExchanger;
|
||||
pub use bphx_geometry::{BphxGeometry, BphxGeometryBuilder, BphxGeometryError, BphxType};
|
||||
pub use condenser::Condenser;
|
||||
|
||||
705
crates/components/src/heat_exchanger/moving_boundary_hx.rs
Normal file
705
crates/components/src/heat_exchanger/moving_boundary_hx.rs
Normal file
@@ -0,0 +1,705 @@
|
||||
//! MovingBoundaryHX - Zone Discretization Heat Exchanger Component
|
||||
//!
|
||||
//! A heat exchanger component that discretizes the heat transfer area into
|
||||
//! phase zones (superheated, two-phase, subcooled) for more accurate modeling
|
||||
//! of refrigerant-side heat transfer.
|
||||
|
||||
use super::bphx_correlation::CorrelationSelector;
|
||||
use super::bphx_geometry::BphxGeometry;
|
||||
use super::eps_ntu::EpsNtuModel;
|
||||
use super::exchanger::HeatExchanger;
|
||||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
use crate::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, MassFlow, Power};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Zone type for refrigerant-side phase classification
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ZoneType {
|
||||
/// Superheated vapor zone (T > Tsat)
|
||||
Superheated,
|
||||
/// Two-phase zone (mixture of liquid and vapor)
|
||||
#[default]
|
||||
TwoPhase,
|
||||
/// Subcooled liquid zone (T < Tsat)
|
||||
Subcooled,
|
||||
}
|
||||
|
||||
/// Zone boundary with relative position and zone type
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZoneBoundary {
|
||||
/// Relative position along the heat exchanger (0.0 to 1.0)
|
||||
pub position: f64,
|
||||
/// Zone type at this boundary
|
||||
pub zone_type: ZoneType,
|
||||
/// UA value for this zone (W/K)
|
||||
pub ua: f64,
|
||||
/// Hot-side temperature at this boundary (K)
|
||||
pub t_hot: f64,
|
||||
/// Cold-side temperature at this boundary (K)
|
||||
pub t_cold: f64,
|
||||
/// Vapor quality at this boundary (0-1 for two-phase)
|
||||
pub quality: f64,
|
||||
}
|
||||
|
||||
/// Zone discretization result containing all zones and summary data
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ZoneDiscretization {
|
||||
/// List of zone boundaries (ordered by position)
|
||||
pub boundaries: Vec<ZoneBoundary>,
|
||||
/// Total UA (sum of all zone UAs) (W/K)
|
||||
pub total_ua: f64,
|
||||
/// Pinch temperature (minimum temperature difference) (K)
|
||||
pub pinch_temp: f64,
|
||||
/// Position of pinch point (relative, 0.0 to 1.0)
|
||||
pub pinch_position: f64,
|
||||
}
|
||||
|
||||
/// Cache for MovingBoundaryHX calculations
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MovingBoundaryCache {
|
||||
/// Whether the cache is valid and initialized
|
||||
pub valid: bool,
|
||||
/// Reference pressure for cache validity (Pa)
|
||||
pub p_ref: f64,
|
||||
/// Reference mass flow for cache validity (kg/s)
|
||||
pub m_ref: f64,
|
||||
/// Cached liquid saturation enthalpy (J/kg)
|
||||
pub h_sat_l: f64,
|
||||
/// Cached vapor saturation enthalpy (J/kg)
|
||||
pub h_sat_v: f64,
|
||||
/// Cached zone discretization result
|
||||
pub discretization: ZoneDiscretization,
|
||||
}
|
||||
|
||||
impl MovingBoundaryCache {
|
||||
/// Checks if the cache remains valid given the current pressure and mass flow.
|
||||
/// Cache is valid if pressure deviates < 5% and mass flow deviates < 10%.
|
||||
pub fn is_valid_for(&self, p_current: f64, m_current: f64) -> bool {
|
||||
if !self.valid {
|
||||
return false;
|
||||
}
|
||||
|
||||
let p_dev = (p_current - self.p_ref).abs() / self.p_ref.max(1e-10);
|
||||
let m_dev = (m_current - self.m_ref).abs() / self.m_ref.max(1e-10);
|
||||
|
||||
p_dev < 0.05 && m_dev < 0.10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// MovingBoundaryHX - Zone discretization heat exchanger component
|
||||
pub struct MovingBoundaryHX {
|
||||
inner: HeatExchanger<EpsNtuModel>,
|
||||
geometry: BphxGeometry,
|
||||
correlation_selector: CorrelationSelector,
|
||||
refrigerant_id: String,
|
||||
secondary_fluid_id: String,
|
||||
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
|
||||
n_discretization: usize,
|
||||
cache: RefCell<MovingBoundaryCache>,
|
||||
last_htc: Cell<f64>,
|
||||
last_validity_warning: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Default for MovingBoundaryHX {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MovingBoundaryHX {
|
||||
/// Creates a new `MovingBoundaryHX` with default settings and 51 discretization points.
|
||||
pub fn new() -> Self {
|
||||
let geometry = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
let model = EpsNtuModel::counter_flow(1000.0);
|
||||
|
||||
Self {
|
||||
inner: HeatExchanger::new(model, "MovingBoundaryHX"),
|
||||
geometry,
|
||||
correlation_selector: CorrelationSelector::default(),
|
||||
refrigerant_id: String::new(),
|
||||
secondary_fluid_id: String::new(),
|
||||
fluid_backend: None,
|
||||
n_discretization: 51,
|
||||
cache: RefCell::new(MovingBoundaryCache::default()),
|
||||
last_htc: Cell::new(0.0),
|
||||
last_validity_warning: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of discretization points.
|
||||
pub fn n_discretization(&self) -> usize {
|
||||
self.n_discretization
|
||||
}
|
||||
|
||||
/// Sets the number of discretization points and returns self.
|
||||
pub fn with_discretization(mut self, n: usize) -> Self {
|
||||
self.n_discretization = n;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the geometry specification.
|
||||
pub fn with_geometry(mut self, geometry: BphxGeometry) -> Self {
|
||||
self.geometry = geometry;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the refrigerant fluid identifier.
|
||||
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
|
||||
self.refrigerant_id = fluid.into();
|
||||
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
|
||||
}
|
||||
|
||||
/// Attaches a fluid backend and returns self.
|
||||
pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
|
||||
self.fluid_backend = Some(backend.clone());
|
||||
self.inner = self.inner.with_fluid_backend(backend);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for MovingBoundaryHX {
|
||||
fn n_equations(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
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 mut cache = self.cache.borrow_mut();
|
||||
let use_cache = cache.is_valid_for(p, m_refrig);
|
||||
|
||||
if !use_cache {
|
||||
let (disc, h_sat_l, h_sat_v) = self.identify_zones(h_in, h_out, p, t_sec_in, t_sec_out)?;
|
||||
cache.valid = true;
|
||||
cache.p_ref = p;
|
||||
cache.m_ref = m_refrig;
|
||||
cache.h_sat_l = h_sat_l;
|
||||
cache.h_sat_v = h_sat_v;
|
||||
cache.discretization = disc;
|
||||
}
|
||||
|
||||
let total_ua = cache.discretization.total_ua;
|
||||
let base_ua = self.inner.ua_nominal();
|
||||
let custom_ua_scale = if base_ua > 0.0 { total_ua / base_ua } else { 1.0 };
|
||||
|
||||
self.inner.compute_residuals_with_ua_scale(state, residuals, custom_ua_scale)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
self.inner.jacobian_entries(state, jacobian)
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
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<MassFlow>, ComponentError> {
|
||||
self.inner.port_mass_flows(state)
|
||||
}
|
||||
|
||||
fn port_enthalpies(&self, state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
|
||||
self.inner.port_enthalpies(state)
|
||||
}
|
||||
|
||||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||||
self.inner.energy_transfers(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for MovingBoundaryHX {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl MovingBoundaryHX {
|
||||
|
||||
/// Identifies the phase zones along the heat exchanger and calculates boundaries.
|
||||
pub fn identify_zones(
|
||||
&self,
|
||||
h_refrig_in: f64,
|
||||
h_refrig_out: f64,
|
||||
p_refrig: f64,
|
||||
t_secondary_in: f64,
|
||||
t_secondary_out: f64,
|
||||
) -> Result<(ZoneDiscretization, f64, f64), ComponentError> {
|
||||
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
|
||||
ComponentError::CalculationFailed("No FluidBackend configured".to_string())
|
||||
})?;
|
||||
let fluid = entropyk_fluids::FluidId::new(&self.refrigerant_id);
|
||||
let p = entropyk_core::Pressure::from_pascals(p_refrig);
|
||||
|
||||
let h_sat_l = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
entropyk_fluids::Property::Enthalpy,
|
||||
entropyk_fluids::FluidState::from_px(p, entropyk_fluids::Quality::new(0.0)),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("h_sat_l failed: {}", e)))?;
|
||||
let h_sat_v = backend
|
||||
.property(
|
||||
fluid.clone(),
|
||||
entropyk_fluids::Property::Enthalpy,
|
||||
entropyk_fluids::FluidState::from_px(p, entropyk_fluids::Quality::new(1.0)),
|
||||
)
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("h_sat_v failed: {}", e)))?;
|
||||
|
||||
let mut boundaries = Vec::new();
|
||||
|
||||
// Calculate transition positions and types
|
||||
let is_condensing = h_refrig_in > h_refrig_out;
|
||||
|
||||
// Add inlet boundary
|
||||
let inlet_type = if h_refrig_in > h_sat_v + 1e-3 {
|
||||
ZoneType::Superheated
|
||||
} else if h_refrig_in < h_sat_l - 1e-3 {
|
||||
ZoneType::Subcooled
|
||||
} else {
|
||||
ZoneType::TwoPhase
|
||||
};
|
||||
boundaries.push(self.create_boundary(0.0, h_refrig_in, p_refrig, inlet_type, t_secondary_in, h_sat_l, h_sat_v)?);
|
||||
|
||||
let (h_min, h_max) = if is_condensing {
|
||||
(h_refrig_out, h_refrig_in)
|
||||
} else {
|
||||
(h_refrig_in, h_refrig_out)
|
||||
};
|
||||
|
||||
if h_min < h_sat_l && h_max > h_sat_l {
|
||||
let pos = (h_sat_l - h_refrig_in) / (h_refrig_out - h_refrig_in);
|
||||
let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in);
|
||||
// After sat_l, type is SC (if condensing) or TP (if evaporating)
|
||||
let post_type = if is_condensing { ZoneType::Subcooled } else { ZoneType::TwoPhase };
|
||||
boundaries.push(self.create_boundary(pos, h_sat_l, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?);
|
||||
}
|
||||
|
||||
if h_min < h_sat_v && h_max > h_sat_v {
|
||||
let pos = (h_sat_v - h_refrig_in) / (h_refrig_out - h_refrig_in);
|
||||
let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in);
|
||||
// After sat_v, type is TP (if condensing) or SH (if evaporating)
|
||||
let post_type = if is_condensing { ZoneType::TwoPhase } else { ZoneType::Superheated };
|
||||
boundaries.push(self.create_boundary(pos, h_sat_v, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?);
|
||||
}
|
||||
|
||||
// Add outlet boundary
|
||||
let outlet_type = if h_refrig_out > h_sat_v + 1e-3 {
|
||||
ZoneType::Superheated
|
||||
} else if h_refrig_out < h_sat_l - 1e-3 {
|
||||
ZoneType::Subcooled
|
||||
} else {
|
||||
ZoneType::TwoPhase
|
||||
};
|
||||
boundaries.push(self.create_boundary(1.0, h_refrig_out, p_refrig, outlet_type, t_secondary_out, h_sat_l, h_sat_v)?);
|
||||
|
||||
// Sort boundaries by position
|
||||
boundaries.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
||||
|
||||
// Calculate UA for each zone
|
||||
let mut total_ua = 0.0;
|
||||
for i in 0..boundaries.len() - 1 {
|
||||
let ua_zone = self.compute_zone_ua(&boundaries[i], &boundaries[i + 1])?;
|
||||
boundaries[i].ua = ua_zone;
|
||||
total_ua += ua_zone;
|
||||
}
|
||||
|
||||
let (pinch_temp, pinch_pos) = self.calculate_pinch(&boundaries);
|
||||
|
||||
Ok((ZoneDiscretization {
|
||||
boundaries,
|
||||
total_ua,
|
||||
pinch_temp,
|
||||
pinch_position: pinch_pos,
|
||||
}, h_sat_l, h_sat_v))
|
||||
}
|
||||
|
||||
fn compute_zone_ua(
|
||||
&self,
|
||||
b1: &ZoneBoundary,
|
||||
b2: &ZoneBoundary,
|
||||
) -> Result<f64, ComponentError> {
|
||||
let area_zone = self.geometry.area * (b2.position - b1.position);
|
||||
if area_zone <= 1e-10 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
// Without access to fluid phase properties and geometry correlation,
|
||||
// we use a simplified approximation based on zone type.
|
||||
// A true implementation would query self.correlation_selector
|
||||
let h_refrig = match b1.zone_type {
|
||||
ZoneType::TwoPhase => 5000.0, // Boiling or condensation
|
||||
ZoneType::Superheated => 500.0, // Vapor
|
||||
ZoneType::Subcooled => 1500.0, // Liquid
|
||||
};
|
||||
let h_secondary = 5000.0; // Generally high for water/glycol
|
||||
|
||||
let u_overall = 1.0 / (1.0 / h_refrig + 1.0 / h_secondary);
|
||||
|
||||
Ok(u_overall * area_zone)
|
||||
}
|
||||
|
||||
fn calculate_pinch(&self, boundaries: &[ZoneBoundary]) -> (f64, f64) {
|
||||
let mut min_dt = f64::MAX;
|
||||
let mut pinch_pos = 0.0;
|
||||
|
||||
for b in boundaries {
|
||||
let dt = (b.t_hot - b.t_cold).abs();
|
||||
if dt < min_dt {
|
||||
min_dt = dt;
|
||||
pinch_pos = b.position;
|
||||
}
|
||||
}
|
||||
|
||||
(min_dt, pinch_pos)
|
||||
}
|
||||
|
||||
fn create_boundary(
|
||||
&self,
|
||||
pos: f64,
|
||||
h: f64,
|
||||
p: f64,
|
||||
zone_type: ZoneType,
|
||||
t_sec: f64,
|
||||
h_sat_l: f64,
|
||||
h_sat_v: f64,
|
||||
) -> Result<ZoneBoundary, ComponentError> {
|
||||
|
||||
let quality = if h_sat_v > h_sat_l {
|
||||
((h - h_sat_l) / (h_sat_v - h_sat_l)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let t_refrig = if let Some(backend) = &self.fluid_backend {
|
||||
let fluid = entropyk_fluids::FluidId::new(&self.refrigerant_id);
|
||||
backend.property(
|
||||
fluid,
|
||||
entropyk_fluids::Property::Temperature,
|
||||
entropyk_fluids::FluidState::from_ph(
|
||||
entropyk_core::Pressure::from_pascals(p),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(h),
|
||||
)
|
||||
).map_err(|e| ComponentError::CalculationFailed(format!("T_refrig failed: {}", e)))?
|
||||
} else {
|
||||
300.0
|
||||
};
|
||||
|
||||
Ok(ZoneBoundary {
|
||||
position: pos,
|
||||
zone_type,
|
||||
ua: 0.0,
|
||||
t_hot: if t_sec > t_refrig { t_sec } else { t_refrig },
|
||||
t_cold: if t_sec > t_refrig { t_refrig } else { t_sec },
|
||||
quality,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_zone_type_enum_exists() {
|
||||
let zone = ZoneType::Superheated;
|
||||
assert_eq!(zone, ZoneType::Superheated);
|
||||
|
||||
let zone = ZoneType::TwoPhase;
|
||||
assert_eq!(zone, ZoneType::TwoPhase);
|
||||
|
||||
let zone = ZoneType::Subcooled;
|
||||
assert_eq!(zone, ZoneType::Subcooled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zone_boundary_struct_exists() {
|
||||
let boundary = ZoneBoundary {
|
||||
position: 0.5,
|
||||
zone_type: ZoneType::TwoPhase,
|
||||
ua: 1000.0,
|
||||
t_hot: 300.0,
|
||||
t_cold: 290.0,
|
||||
quality: 0.5,
|
||||
};
|
||||
|
||||
assert!((boundary.position - 0.5).abs() < 1e-10);
|
||||
assert_eq!(boundary.zone_type, ZoneType::TwoPhase);
|
||||
assert!((boundary.ua - 1000.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_moving_boundary_hx_with_fluids() {
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_secondary_fluid("Water");
|
||||
assert_eq!(hx.refrigerant_id, "R410A");
|
||||
assert_eq!(hx.secondary_fluid_id, "Water");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identify_zones_basic() {
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
|
||||
struct MockBackend {
|
||||
h_sat_l: f64,
|
||||
h_sat_v: f64,
|
||||
t_sat: f64,
|
||||
}
|
||||
|
||||
impl entropyk_fluids::FluidBackend for MockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
property: entropyk_fluids::Property,
|
||||
state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
match property {
|
||||
entropyk_fluids::Property::Temperature => Ok(self.t_sat),
|
||||
entropyk_fluids::Property::Enthalpy => {
|
||||
let q = match state {
|
||||
entropyk_fluids::FluidState::PressureQuality(_, q) => Some(q.value()),
|
||||
_ => None,
|
||||
};
|
||||
match q {
|
||||
Some(0.0) => Ok(self.h_sat_l),
|
||||
Some(1.0) => Ok(self.h_sat_v),
|
||||
_ => Ok(self.h_sat_v),
|
||||
}
|
||||
}
|
||||
_ => Err(FluidError::UnsupportedProperty { property: format!("{:?}", property) }),
|
||||
}
|
||||
}
|
||||
|
||||
fn critical_point(&self, fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = MockBackend {
|
||||
h_sat_l: 200_000.0,
|
||||
h_sat_v: 400_000.0,
|
||||
t_sat: 280.0,
|
||||
};
|
||||
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_fluid_backend(Arc::new(backend));
|
||||
|
||||
// Condensing: 450,000 (SH) -> 150,000 (SC)
|
||||
let result = hx.identify_zones(450_000.0, 150_000.0, 500_000.0, 300.0, 320.0);
|
||||
assert!(result.is_ok());
|
||||
let (disc, h_sat_l_res, h_sat_v_res) = result.unwrap();
|
||||
|
||||
assert_eq!(h_sat_l_res, 200_000.0);
|
||||
assert_eq!(h_sat_v_res, 400_000.0);
|
||||
|
||||
// Should have 4 boundaries: inlet(SH), sat_v(SH/TP), sat_l(TP/SC), outlet(SC)
|
||||
assert_eq!(disc.boundaries.len(), 4);
|
||||
assert_eq!(disc.boundaries[0].zone_type, ZoneType::Superheated);
|
||||
assert_eq!(disc.boundaries[1].zone_type, ZoneType::TwoPhase);
|
||||
assert_eq!(disc.boundaries[2].zone_type, ZoneType::Subcooled);
|
||||
assert_eq!(disc.boundaries[3].zone_type, ZoneType::Subcooled);
|
||||
|
||||
// Total UA should be positive
|
||||
assert!(disc.total_ua > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_is_valid_for() {
|
||||
let mut cache = MovingBoundaryCache {
|
||||
valid: true,
|
||||
p_ref: 100_000.0,
|
||||
m_ref: 1.0,
|
||||
h_sat_l: 100.0,
|
||||
h_sat_v: 200.0,
|
||||
discretization: ZoneDiscretization::default(),
|
||||
};
|
||||
|
||||
// Identical
|
||||
assert!(cache.is_valid_for(100_000.0, 1.0));
|
||||
|
||||
// P < 5% deviation (104,000 is 4%)
|
||||
assert!(cache.is_valid_for(104_000.0, 1.0));
|
||||
|
||||
// P > 5% deviation (106,000 is 6%)
|
||||
assert!(!cache.is_valid_for(106_000.0, 1.0));
|
||||
|
||||
// M < 10% deviation (1.09 is 9%)
|
||||
assert!(cache.is_valid_for(100_000.0, 1.09));
|
||||
|
||||
// M > 10% deviation (1.11 is 11%)
|
||||
assert!(!cache.is_valid_for(100_000.0, 1.11));
|
||||
|
||||
// Invalid if explicitly invalid
|
||||
cache.valid = false;
|
||||
assert!(!cache.is_valid_for(100_000.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_uses_cache() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
|
||||
struct TrackingMockBackend {
|
||||
pub calls: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl entropyk_fluids::FluidBackend for TrackingMockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_property: entropyk_fluids::Property,
|
||||
_state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(100.0)
|
||||
}
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: "".to_string() })
|
||||
}
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = Arc::new(TrackingMockBackend {
|
||||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_fluid_backend(backend.clone());
|
||||
|
||||
let state = vec![500_000.0, 400_000.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
// First call should calculate property (backend calls)
|
||||
let _ = hx.compute_residuals(&state, &mut residuals);
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_performance_speedup() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowMockBackend;
|
||||
|
||||
impl entropyk_fluids::FluidBackend for SlowMockBackend {
|
||||
fn property(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_property: entropyk_fluids::Property,
|
||||
_state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<f64> {
|
||||
// Simulate somewhat slow fluid property calculation
|
||||
std::thread::sleep(std::time::Duration::from_micros(10));
|
||||
Ok(100.0)
|
||||
}
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: "".to_string() })
|
||||
}
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = Arc::new(SlowMockBackend);
|
||||
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_fluid_backend(backend.clone());
|
||||
|
||||
let state = vec![500_000.0, 400_000.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
// First run (no cache)
|
||||
let start = Instant::now();
|
||||
let _ = hx.compute_residuals(&state, &mut residuals);
|
||||
let duration_uncached = start.elapsed();
|
||||
|
||||
// Second run (cached)
|
||||
let start = Instant::now();
|
||||
let _ = hx.compute_residuals(&state, &mut residuals);
|
||||
let duration_cached = start.elapsed();
|
||||
|
||||
println!("Uncached duration: {:?}", duration_uncached);
|
||||
println!("Cached duration: {:?}", duration_cached);
|
||||
|
||||
let speedup = duration_uncached.as_secs_f64() / duration_cached.as_secs_f64().max(1e-9);
|
||||
println!("Speedup multiplier: {:.1}x", speedup);
|
||||
|
||||
assert!(duration_cached < duration_uncached);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user