282 lines
8.2 KiB
Rust
282 lines
8.2 KiB
Rust
//! Condenser Coil Component
|
||
//!
|
||
//! An air-side (finned) heat exchanger for refrigerant condensation.
|
||
//! The refrigerant (hot side) condenses, releasing heat to air (cold side).
|
||
//! Used in split systems and air-source heat pumps.
|
||
//!
|
||
//! ## Port Convention
|
||
//!
|
||
//! - **Hot side (refrigerant)**: Condensing
|
||
//! - **Cold side (air)**: Heat sink — connect to Fan outlet/inlet
|
||
//!
|
||
//! ## Integration with Fan
|
||
//!
|
||
//! Connect Fan outlet → CondenserCoil air inlet, CondenserCoil air outlet → Fan inlet.
|
||
//! Use `FluidId::new("Air")` for air ports.
|
||
|
||
use super::condenser::Condenser;
|
||
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||
use crate::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||
};
|
||
|
||
/// Condenser coil (air-side finned heat exchanger).
|
||
///
|
||
/// Explicit component for air-source condensers. Uses LMTD method.
|
||
/// Refrigerant condenses on hot side, air on cold side.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::heat_exchanger::CondenserCoil;
|
||
/// use entropyk_components::Component;
|
||
///
|
||
/// let coil = CondenserCoil::new(10_000.0); // UA = 10 kW/K
|
||
/// assert_eq!(coil.ua(), 10_000.0);
|
||
/// assert_eq!(coil.n_equations(), 2);
|
||
/// ```
|
||
#[derive(Debug)]
|
||
pub struct CondenserCoil {
|
||
inner: Condenser,
|
||
air_validated: std::sync::atomic::AtomicBool,
|
||
}
|
||
|
||
impl CondenserCoil {
|
||
/// Creates a new condenser coil with the given UA value.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `ua` - Overall heat transfer coefficient × Area (W/K)
|
||
pub fn new(ua: f64) -> Self {
|
||
Self {
|
||
inner: Condenser::new(ua),
|
||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||
}
|
||
}
|
||
|
||
/// Creates a condenser coil with a specific saturation temperature.
|
||
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
|
||
Self {
|
||
inner: Condenser::with_saturation_temp(ua, saturation_temp),
|
||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||
}
|
||
}
|
||
|
||
/// Returns the name of this component.
|
||
pub fn name(&self) -> &str {
|
||
"CondenserCoil"
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// Sets the saturation temperature.
|
||
pub fn set_saturation_temp(&mut self, temp: f64) {
|
||
self.inner.set_saturation_temp(temp);
|
||
}
|
||
}
|
||
|
||
impl Component for CondenserCoil {
|
||
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.cold_fluid_id() {
|
||
if fluid_id.0.as_str() != "Air" {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"CondenserCoil requires Air on the cold 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)
|
||
}
|
||
|
||
fn signature(&self) -> String {
|
||
self.inner.signature()
|
||
}
|
||
|
||
fn to_params(&self) -> crate::ComponentParams {
|
||
self.inner.to_params()
|
||
}
|
||
|
||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||
self.inner.update_calib_factor(factor, value)
|
||
}
|
||
}
|
||
|
||
impl StateManageable for CondenserCoil {
|
||
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_condenser_coil_creation() {
|
||
let coil = CondenserCoil::new(10_000.0);
|
||
assert_eq!(coil.ua(), 10_000.0);
|
||
assert_eq!(coil.name(), "CondenserCoil");
|
||
}
|
||
|
||
#[test]
|
||
fn test_condenser_coil_n_equations() {
|
||
let coil = CondenserCoil::new(10_000.0);
|
||
assert_eq!(coil.n_equations(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_condenser_coil_with_saturation_temp() {
|
||
let coil = CondenserCoil::with_saturation_temp(10_000.0, 323.15);
|
||
assert_eq!(coil.saturation_temp(), 323.15);
|
||
}
|
||
|
||
#[test]
|
||
fn test_condenser_coil_compute_residuals() {
|
||
let coil = CondenserCoil::new(10_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_condenser_coil_rejects_non_air() {
|
||
use crate::heat_exchanger::HxSideConditions;
|
||
use entropyk_core::{MassFlow, Pressure, Temperature};
|
||
|
||
let mut coil = CondenserCoil::new(10_000.0);
|
||
coil.inner.set_cold_conditions(
|
||
HxSideConditions::new(
|
||
Temperature::from_celsius(20.0),
|
||
Pressure::from_bar(1.0),
|
||
MassFlow::from_kg_per_s(1.0),
|
||
"Water",
|
||
)
|
||
.expect("Valid cold 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_condenser_coil_jacobian_entries() {
|
||
let coil = CondenserCoil::new(10_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_condenser_coil_set_saturation_temp() {
|
||
let mut coil = CondenserCoil::new(10_000.0);
|
||
coil.set_saturation_temp(320.0);
|
||
assert!((coil.saturation_temp() - 320.0).abs() < 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_condenser_coil_state_manageable() {
|
||
use crate::state_machine::{OperationalState, StateManageable};
|
||
|
||
let mut coil = CondenserCoil::new(10_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);
|
||
}
|
||
}
|