Sepehr fdd124eefd 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.
2026-02-28 22:45:51 +01:00

284 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}