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.
284 lines
8.4 KiB
Rust
284 lines
8.4 KiB
Rust
//! Evaporator Coil Component
|
||
//!
|
||
//! An air-side (finned) heat exchanger for refrigerant evaporation.
|
||
//! The refrigerant (cold side) evaporates, absorbing heat from air (hot side).
|
||
//! Used in split systems and air-source heat pumps.
|
||
//!
|
||
//! ## Port Convention
|
||
//!
|
||
//! - **Hot side (air)**: Heat source — connect to Fan outlet/inlet
|
||
//! - **Cold side (refrigerant)**: Evaporating
|
||
//!
|
||
//! ## Integration with Fan
|
||
//!
|
||
//! Connect Fan outlet → EvaporatorCoil air inlet, EvaporatorCoil air outlet → Fan inlet.
|
||
//! Use `FluidId::new("Air")` for air ports.
|
||
|
||
use super::evaporator::Evaporator;
|
||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||
use crate::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||
};
|
||
|
||
/// Evaporator coil (air-side finned heat exchanger).
|
||
///
|
||
/// Explicit component for air-source evaporators. Uses ε-NTU method.
|
||
/// Refrigerant evaporates on cold side, air on hot side.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::heat_exchanger::EvaporatorCoil;
|
||
/// use entropyk_components::Component;
|
||
///
|
||
/// let coil = EvaporatorCoil::new(8_000.0); // UA = 8 kW/K
|
||
/// assert_eq!(coil.ua(), 8_000.0);
|
||
/// assert_eq!(coil.n_equations(), 3);
|
||
/// ```
|
||
#[derive(Debug)]
|
||
pub struct EvaporatorCoil {
|
||
inner: Evaporator,
|
||
air_validated: std::sync::atomic::AtomicBool,
|
||
}
|
||
|
||
impl EvaporatorCoil {
|
||
/// Creates a new evaporator coil with the given UA value.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
|
||
pub fn new(ua: f64) -> Self {
|
||
Self {
|
||
inner: Evaporator::new(ua),
|
||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||
}
|
||
}
|
||
|
||
/// Creates an evaporator coil with specific saturation and superheat.
|
||
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
|
||
Self {
|
||
inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target),
|
||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||
}
|
||
}
|
||
|
||
/// Returns the name of this component.
|
||
pub fn name(&self) -> &str {
|
||
"EvaporatorCoil"
|
||
}
|
||
|
||
/// Returns the UA value.
|
||
pub fn ua(&self) -> f64 {
|
||
self.inner.ua()
|
||
}
|
||
|
||
/// Returns the saturation temperature.
|
||
pub fn saturation_temp(&self) -> f64 {
|
||
self.inner.saturation_temp()
|
||
}
|
||
|
||
/// Returns the superheat target.
|
||
pub fn superheat_target(&self) -> f64 {
|
||
self.inner.superheat_target()
|
||
}
|
||
|
||
/// Sets the saturation temperature.
|
||
pub fn set_saturation_temp(&mut self, temp: f64) {
|
||
self.inner.set_saturation_temp(temp);
|
||
}
|
||
|
||
/// Sets the superheat target.
|
||
pub fn set_superheat_target(&mut self, superheat: f64) {
|
||
self.inner.set_superheat_target(superheat);
|
||
}
|
||
}
|
||
|
||
impl Component for EvaporatorCoil {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
if !self
|
||
.air_validated
|
||
.load(std::sync::atomic::Ordering::Relaxed)
|
||
{
|
||
if let Some(fluid_id) = self.inner.hot_fluid_id() {
|
||
if fluid_id.0.as_str() != "Air" {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"EvaporatorCoil requires Air on the hot side, found {}",
|
||
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)
|
||
}
|
||
}
|
||
|
||
impl StateManageable for EvaporatorCoil {
|
||
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_evaporator_coil_creation() {
|
||
let coil = EvaporatorCoil::new(8_000.0);
|
||
assert_eq!(coil.ua(), 8_000.0);
|
||
assert_eq!(coil.name(), "EvaporatorCoil");
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_n_equations() {
|
||
let coil = EvaporatorCoil::new(5_000.0);
|
||
assert_eq!(coil.n_equations(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_with_superheat() {
|
||
let coil = EvaporatorCoil::with_superheat(8_000.0, 278.15, 5.0);
|
||
assert_eq!(coil.saturation_temp(), 278.15);
|
||
assert_eq!(coil.superheat_target(), 5.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_compute_residuals() {
|
||
let coil = EvaporatorCoil::new(8_000.0);
|
||
let state = vec![0.0; 10];
|
||
let mut residuals = vec![0.0; 3];
|
||
let result = coil.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_ok());
|
||
assert!(
|
||
residuals.iter().all(|r| r.is_finite()),
|
||
"residuals must be finite"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_rejects_non_air() {
|
||
use crate::heat_exchanger::HxSideConditions;
|
||
use entropyk_core::{MassFlow, Pressure, Temperature};
|
||
|
||
let mut coil = EvaporatorCoil::new(8_000.0);
|
||
|
||
coil.inner.set_hot_conditions(
|
||
HxSideConditions::new(
|
||
Temperature::from_celsius(20.0),
|
||
Pressure::from_bar(1.0),
|
||
MassFlow::from_kg_per_s(1.0),
|
||
"Water",
|
||
)
|
||
.expect("Valid hot conditions"),
|
||
);
|
||
|
||
let state = vec![0.0; 10];
|
||
let mut residuals = vec![0.0; 3];
|
||
let result = coil.compute_residuals(&state, &mut residuals);
|
||
|
||
assert!(result.is_err());
|
||
if let Err(ComponentError::InvalidState(msg)) = result {
|
||
assert!(msg.contains("requires Air"));
|
||
} else {
|
||
panic!("Expected InvalidState error");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_jacobian_entries() {
|
||
let coil = EvaporatorCoil::new(8_000.0);
|
||
let state = vec![0.0; 10];
|
||
let mut jacobian = crate::JacobianBuilder::new();
|
||
let result = coil.jacobian_entries(&state, &mut jacobian);
|
||
assert!(result.is_ok());
|
||
// HeatExchanger base returns empty jacobian until framework implements it
|
||
assert!(
|
||
jacobian.is_empty(),
|
||
"delegation works; empty jacobian expected until HeatExchanger implements entries"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_setters() {
|
||
let mut coil = EvaporatorCoil::new(8_000.0);
|
||
coil.set_saturation_temp(275.0);
|
||
coil.set_superheat_target(7.0);
|
||
assert!((coil.saturation_temp() - 275.0).abs() < 1e-10);
|
||
assert!((coil.superheat_target() - 7.0).abs() < 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_evaporator_coil_state_manageable() {
|
||
use crate::state_machine::{OperationalState, StateManageable};
|
||
|
||
let mut coil = EvaporatorCoil::new(8_000.0);
|
||
assert_eq!(coil.state(), OperationalState::On);
|
||
assert!(coil.can_transition_to(OperationalState::Off));
|
||
assert!(coil.set_state(OperationalState::Off).is_ok());
|
||
assert_eq!(coil.state(), OperationalState::Off);
|
||
}
|
||
}
|