Some checks failed
CI / check (push) Has been cancelled
Fixed/Free Probe calibration now emits embeddings[] (unknown + equation) instead of controls[], keeping SaturatedController for physical regulation only. Co-authored-by: Cursor <cursoragent@cursor.com>
2518 lines
88 KiB
Rust
2518 lines
88 KiB
Rust
//! Compressor Component Implementation (AHRI 540 Standard + SST/SDT Curves)
|
||
//!
|
||
//! This module provides a compressor component implementation based on the
|
||
//! AHRI 540 standard for performance rating of positive displacement
|
||
//! refrigerant compressors and compressor units.
|
||
//!
|
||
//! ## AHRI 540 Standard Equations
|
||
//!
|
||
//! The compressor is modeled using 10 coefficients (M1-M10):
|
||
//!
|
||
//! **Mass Flow Rate:**
|
||
//! ```text
|
||
//! ṁ = M1 × (1 - (P_suction/P_discharge)^(1/M2)) × ρ_suction × V_disp × N/60
|
||
//! ```
|
||
//!
|
||
//! **Power Consumption (Cooling):**
|
||
//! ```text
|
||
//! Ẇ = M3 + M4 × (P_discharge/P_suction) + M5 × T_suction + M6 × T_discharge
|
||
//! ```
|
||
//!
|
||
//! **Power Consumption (Heating):**
|
||
//! ```text
|
||
//! Ẇ = M7 + M8 × (P_discharge/P_suction) + M9 × T_suction + M10 × T_discharge
|
||
//! ```
|
||
//!
|
||
//! ## SST/SDT Polynomial Model
|
||
//!
|
||
//! Alternative model based on saturated temperatures:
|
||
//!
|
||
//! **Mass Flow Rate:**
|
||
//! ```text
|
||
//! ṁ = Σ a_ij × SST^i × SDT^j
|
||
//! ```
|
||
//!
|
||
//! **Power Consumption:**
|
||
//! ```text
|
||
//! Ẇ = Σ b_ij × SST^i × SDT^j
|
||
//! ```
|
||
//!
|
||
//! Where:
|
||
//! - SST = Saturated Suction Temperature (K)
|
||
//! - SDT = Saturated Discharge Temperature (K)
|
||
|
||
use crate::polynomials::Polynomial2D;
|
||
use crate::port::{Connected, Disconnected, FluidId, Port};
|
||
use crate::{
|
||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||
ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::{Calib, Enthalpy, MassFlow, Temperature};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::marker::PhantomData;
|
||
|
||
/// Coefficients for AHRI 540 compressor performance model.
|
||
///
|
||
/// The AHRI 540 standard defines 10 coefficients (M1-M10) that characterize
|
||
/// compressor performance across operating conditions.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
pub struct Ahri540Coefficients {
|
||
/// Flow coefficient (M1)
|
||
pub m1: f64,
|
||
/// Pressure ratio exponent (M2)
|
||
pub m2: f64,
|
||
/// Power coefficient - constant term for cooling (M3)
|
||
pub m3: f64,
|
||
/// Power coefficient - pressure ratio term for cooling (M4)
|
||
pub m4: f64,
|
||
/// Power coefficient - suction temperature term for cooling (M5)
|
||
pub m5: f64,
|
||
/// Power coefficient - discharge temperature term for cooling (M6)
|
||
pub m6: f64,
|
||
/// Power coefficient - constant term for heating (M7)
|
||
pub m7: f64,
|
||
/// Power coefficient - pressure ratio term for heating (M8)
|
||
pub m8: f64,
|
||
/// Power coefficient - suction temperature term for heating (M9)
|
||
pub m9: f64,
|
||
/// Power coefficient - discharge temperature term for heating (M10)
|
||
pub m10: f64,
|
||
}
|
||
|
||
impl Ahri540Coefficients {
|
||
/// Creates a new set of AHRI 540 coefficients.
|
||
#[allow(clippy::too_many_arguments)]
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `m1` - Flow coefficient
|
||
/// * `m2` - Pressure ratio exponent
|
||
/// * `m3` - Power constant (cooling)
|
||
/// * `m4` - Power pressure ratio coefficient (cooling)
|
||
/// * `m5` - Power suction temperature coefficient (cooling)
|
||
/// * `m6` - Power discharge temperature coefficient (cooling)
|
||
/// * `m7` - Power constant (heating)
|
||
/// * `m8` - Power pressure ratio coefficient (heating)
|
||
/// * `m9` - Power suction temperature coefficient (heating)
|
||
/// * `m10` - Power discharge temperature coefficient (heating)
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::compressor::Ahri540Coefficients;
|
||
///
|
||
/// let coeffs = Ahri540Coefficients::new(
|
||
/// 0.85, 2.5, // M1, M2 (flow)
|
||
/// 500.0, 1500.0, -2.5, 1.8, // M3-M6 (cooling)
|
||
/// 600.0, 1600.0, -3.0, 2.0 // M7-M10 (heating)
|
||
/// );
|
||
/// ```
|
||
pub fn new(
|
||
m1: f64,
|
||
m2: f64,
|
||
m3: f64,
|
||
m4: f64,
|
||
m5: f64,
|
||
m6: f64,
|
||
m7: f64,
|
||
m8: f64,
|
||
m9: f64,
|
||
m10: f64,
|
||
) -> Self {
|
||
Self {
|
||
m1,
|
||
m2,
|
||
m3,
|
||
m4,
|
||
m5,
|
||
m6,
|
||
m7,
|
||
m8,
|
||
m9,
|
||
m10,
|
||
}
|
||
}
|
||
|
||
/// Validates that coefficients are within reasonable ranges.
|
||
///
|
||
/// Returns an error if any coefficient is NaN or infinite,
|
||
/// or if critical coefficients are outside expected ranges.
|
||
pub fn validate(&self) -> Result<(), ComponentError> {
|
||
// Check for NaN or infinite values
|
||
let coefficients = [
|
||
("M1", self.m1),
|
||
("M2", self.m2),
|
||
("M3", self.m3),
|
||
("M4", self.m4),
|
||
("M5", self.m5),
|
||
("M6", self.m6),
|
||
("M7", self.m7),
|
||
("M8", self.m8),
|
||
("M9", self.m9),
|
||
("M10", self.m10),
|
||
];
|
||
|
||
for (name, value) in coefficients.iter() {
|
||
if value.is_nan() {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"Coefficient {} is NaN",
|
||
name
|
||
)));
|
||
}
|
||
if value.is_infinite() {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"Coefficient {} is infinite",
|
||
name
|
||
)));
|
||
}
|
||
}
|
||
|
||
// M2 should be positive (pressure ratio exponent)
|
||
if self.m2 <= 0.0 {
|
||
return Err(ComponentError::InvalidState(
|
||
"Coefficient M2 (pressure ratio exponent) must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Polynomial coefficients for SST/SDT based compressor model.
|
||
///
|
||
/// This model characterizes compressor performance as a function of
|
||
/// saturated suction temperature (SST) and saturated discharge temperature (SDT).
|
||
///
|
||
/// ## Model Equations
|
||
///
|
||
/// **Mass Flow Rate:**
|
||
/// ```text
|
||
/// ṁ = Σ a_ij × SST^i × SDT^j (kg/s)
|
||
/// ```
|
||
///
|
||
/// **Power Consumption:**
|
||
/// ```text
|
||
/// Ẇ = Σ b_ij × SST^i × SDT^j (W)
|
||
/// ```
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::compressor::SstSdtCoefficients;
|
||
///
|
||
/// // Simple bilinear model: m_dot = a00 + a10*SST + a01*SDT + a11*SST*SDT
|
||
/// let coeffs = SstSdtCoefficients::bilinear(
|
||
/// 0.05, 0.001, 0.0005, 0.00001, // mass flow coefficients
|
||
/// 1000.0, 50.0, 30.0, 0.5 // power coefficients
|
||
/// );
|
||
/// ```
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub struct SstSdtCoefficients {
|
||
/// Mass flow rate polynomial: ṁ = f(SST, SDT) in kg/s
|
||
pub mass_flow_curve: Polynomial2D,
|
||
/// Power consumption polynomial: Ẇ = f(SST, SDT) in W
|
||
pub power_curve: Polynomial2D,
|
||
}
|
||
|
||
impl SstSdtCoefficients {
|
||
/// Creates new SST/SDT coefficients from 2D polynomials.
|
||
pub fn new(mass_flow_curve: Polynomial2D, power_curve: Polynomial2D) -> Self {
|
||
Self {
|
||
mass_flow_curve,
|
||
power_curve,
|
||
}
|
||
}
|
||
|
||
/// Creates a bilinear SST/SDT model.
|
||
///
|
||
/// mass_flow = a00 + a10*SST + a01*SDT + a11*SST*SDT
|
||
/// power = b00 + b10*SST + b01*SDT + b11*SST*SDT
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn bilinear(
|
||
mass_a00: f64,
|
||
mass_a10: f64,
|
||
mass_a01: f64,
|
||
mass_a11: f64,
|
||
power_b00: f64,
|
||
power_b10: f64,
|
||
power_b01: f64,
|
||
power_b11: f64,
|
||
) -> Self {
|
||
Self {
|
||
mass_flow_curve: Polynomial2D::bilinear(mass_a00, mass_a10, mass_a01, mass_a11),
|
||
power_curve: Polynomial2D::bilinear(power_b00, power_b10, power_b01, power_b11),
|
||
}
|
||
}
|
||
|
||
/// Creates a biquadratic SST/SDT model (degree 2 in both variables).
|
||
pub fn biquadratic(mass_coeffs: [[f64; 3]; 3], power_coeffs: [[f64; 3]; 3]) -> Self {
|
||
let mass_vec: Vec<Vec<f64>> = mass_coeffs.iter().map(|row| row.to_vec()).collect();
|
||
let power_vec: Vec<Vec<f64>> = power_coeffs.iter().map(|row| row.to_vec()).collect();
|
||
|
||
Self {
|
||
mass_flow_curve: Polynomial2D::new(mass_vec),
|
||
power_curve: Polynomial2D::new(power_vec),
|
||
}
|
||
}
|
||
|
||
/// Calculates mass flow rate at given SST and SDT.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `sst_k` - Saturated suction temperature in Kelvin
|
||
/// * `sdt_k` - Saturated discharge temperature in Kelvin
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Mass flow rate in kg/s
|
||
pub fn mass_flow_at(&self, sst_k: f64, sdt_k: f64) -> f64 {
|
||
self.mass_flow_curve.evaluate(sst_k, sdt_k)
|
||
}
|
||
|
||
/// Calculates power consumption at given SST and SDT.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `sst_k` - Saturated suction temperature in Kelvin
|
||
/// * `sdt_k` - Saturated discharge temperature in Kelvin
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Power consumption in Watts
|
||
pub fn power_at(&self, sst_k: f64, sdt_k: f64) -> f64 {
|
||
self.power_curve.evaluate(sst_k, sdt_k)
|
||
}
|
||
|
||
/// Validates that coefficients are within reasonable ranges.
|
||
pub fn validate(&self) -> Result<(), ComponentError> {
|
||
self.mass_flow_curve.validate()?;
|
||
self.power_curve.validate()?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Compressor performance model selection.
|
||
///
|
||
/// Allows switching between AHRI 540 coefficients and SST/SDT polynomial model.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
pub enum CompressorModel {
|
||
/// AHRI 540 standard model with 10 coefficients
|
||
Ahri540(Ahri540Coefficients),
|
||
/// SST/SDT polynomial model
|
||
SstSdt(SstSdtCoefficients),
|
||
}
|
||
|
||
impl Default for CompressorModel {
|
||
fn default() -> Self {
|
||
CompressorModel::Ahri540(Ahri540Coefficients::new(
|
||
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
|
||
))
|
||
}
|
||
}
|
||
|
||
impl From<Ahri540Coefficients> for CompressorModel {
|
||
fn from(coeffs: Ahri540Coefficients) -> Self {
|
||
CompressorModel::Ahri540(coeffs)
|
||
}
|
||
}
|
||
|
||
impl From<SstSdtCoefficients> for CompressorModel {
|
||
fn from(coeffs: SstSdtCoefficients) -> Self {
|
||
CompressorModel::SstSdt(coeffs)
|
||
}
|
||
}
|
||
///
|
||
/// The compressor uses the Type-State pattern to ensure ports are connected
|
||
/// before use in simulations. It implements the [`Component`] trait for
|
||
/// integration with the solver.
|
||
///
|
||
/// # Type Parameters
|
||
///
|
||
/// * `State` - Either `Disconnected` or `Connected`, tracking connection state
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::compressor::{Compressor, Ahri540Coefficients};
|
||
/// use entropyk_components::port::{FluidId, Port};
|
||
/// use entropyk_core::{Pressure, Enthalpy};
|
||
///
|
||
/// // Create coefficients
|
||
/// let coeffs = Ahri540Coefficients::new(
|
||
/// 0.85, 2.5,
|
||
/// 500.0, 1500.0, -2.5, 1.8,
|
||
/// 600.0, 1600.0, -3.0, 2.0
|
||
/// );
|
||
///
|
||
/// // Create disconnected ports (Compressor::new accepts Disconnected ports)
|
||
/// let suction = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(3.5),
|
||
/// Enthalpy::from_joules_per_kg(400000.0)
|
||
/// );
|
||
/// let discharge = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(3.5), // Same pressure for validation
|
||
/// Enthalpy::from_joules_per_kg(400000.0) // Same enthalpy for validation
|
||
/// );
|
||
///
|
||
/// // Create compressor with disconnected ports
|
||
/// let compressor = Compressor::new(
|
||
/// coeffs,
|
||
/// suction,
|
||
/// discharge,
|
||
/// 2900.0, // RPM
|
||
/// 0.0001, // Displacement volume (m³/rev)
|
||
/// 0.85 // Mechanical efficiency
|
||
/// ).unwrap();
|
||
/// ```
|
||
#[derive(Debug, Clone)]
|
||
pub struct Compressor<State> {
|
||
/// Compressor performance model (AHRI 540 or SST/SDT polynomial)
|
||
model: CompressorModel,
|
||
/// Suction port (inlet)
|
||
port_suction: Port<State>,
|
||
/// Discharge port (outlet)
|
||
port_discharge: Port<State>,
|
||
/// Rotational speed in RPM
|
||
speed_rpm: f64,
|
||
/// Displacement volume in m³/revolution
|
||
displacement_m3_per_rev: f64,
|
||
/// Mechanical efficiency (0.0 to 1.0)
|
||
mechanical_efficiency: f64,
|
||
/// Calibration factors: ṁ_eff = f_m × ṁ_nominal, Ẇ_eff = f_power × Ẇ_nominal, etc.
|
||
calib: Calib,
|
||
/// Calibration indices to extract factors dynamically from SystemState
|
||
calib_indices: entropyk_core::CalibIndices,
|
||
/// Fluid identifier for density lookups
|
||
fluid_id: FluidId,
|
||
/// Circuit identifier for multi-circuit machines (FR9)
|
||
circuit_id: CircuitId,
|
||
/// Operational state: On, Off, or Bypass (FR6-FR8)
|
||
operational_state: OperationalState,
|
||
/// State-vector index: suction mass flow (incoming edge, CM1.3)
|
||
suction_m_idx: Option<usize>,
|
||
/// State-vector index: suction enthalpy (incoming edge, CM1.3)
|
||
suction_h_idx: Option<usize>,
|
||
/// State-vector index: discharge mass flow (outgoing edge, CM1.3)
|
||
discharge_m_idx: Option<usize>,
|
||
/// State-vector index: discharge enthalpy (outgoing edge, CM1.3)
|
||
discharge_h_idx: Option<usize>,
|
||
/// True when suction and discharge share the same ṁ state index (same
|
||
/// series branch). In this case the mass-conservation residual
|
||
/// `ṁ_dis − ṁ_suc = 0` is trivially satisfied and must be dropped to
|
||
/// keep the system square (CM1.4).
|
||
same_branch_m: bool,
|
||
/// Phantom data for type state
|
||
_state: PhantomData<State>,
|
||
}
|
||
|
||
impl Compressor<Connected> {
|
||
/// Returns both ports as a slice for solver topology.
|
||
///
|
||
/// # Note
|
||
///
|
||
/// This creates a temporary array on each call. For better performance
|
||
/// in hot loops, cache the ports directly via `port_suction()` and
|
||
/// `port_discharge()` methods.
|
||
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
|
||
[&self.port_suction, &self.port_discharge]
|
||
}
|
||
}
|
||
|
||
impl Compressor<Disconnected> {
|
||
/// Creates a new disconnected compressor with AHRI 540 model.
|
||
///
|
||
/// The compressor must have its ports connected before use in simulations.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `coefficients` - AHRI 540 performance coefficients
|
||
/// * `port_suction` - Suction port (disconnected)
|
||
/// * `port_discharge` - Discharge port (disconnected)
|
||
/// * `speed_rpm` - Rotational speed in RPM
|
||
/// * `displacement_m3_per_rev` - Displacement volume in m³/rev
|
||
/// * `mechanical_efficiency` - Mechanical efficiency (0.0 to 1.0)
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if:
|
||
/// - Coefficients are invalid
|
||
/// - Speed is negative or zero
|
||
/// - Displacement is negative or zero
|
||
/// - Mechanical efficiency is outside [0, 1]
|
||
/// - Ports have different fluid types
|
||
pub fn new(
|
||
coefficients: Ahri540Coefficients,
|
||
port_suction: Port<Disconnected>,
|
||
port_discharge: Port<Disconnected>,
|
||
speed_rpm: f64,
|
||
displacement_m3_per_rev: f64,
|
||
mechanical_efficiency: f64,
|
||
) -> Result<Self, ComponentError> {
|
||
Self::with_model(
|
||
CompressorModel::Ahri540(coefficients),
|
||
port_suction,
|
||
port_discharge,
|
||
speed_rpm,
|
||
displacement_m3_per_rev,
|
||
mechanical_efficiency,
|
||
)
|
||
}
|
||
|
||
/// Creates a new disconnected compressor with a specified model.
|
||
///
|
||
/// Use this constructor to select between AHRI 540 and SST/SDT polynomial models.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `model` - Compressor performance model (AHRI 540 or SST/SDT)
|
||
/// * `port_suction` - Suction port (disconnected)
|
||
/// * `port_discharge` - Discharge port (disconnected)
|
||
/// * `speed_rpm` - Rotational speed in RPM
|
||
/// * `displacement_m3_per_rev` - Displacement volume in m³/rev
|
||
/// * `mechanical_efficiency` - Mechanical efficiency (0.0 to 1.0)
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if:
|
||
/// - Model coefficients are invalid
|
||
/// - Speed is negative or zero
|
||
/// - Displacement is negative or zero
|
||
/// - Mechanical efficiency is outside [0, 1]
|
||
/// - Ports have different fluid types
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```
|
||
/// use entropyk_components::compressor::{Compressor, CompressorModel, SstSdtCoefficients};
|
||
/// use entropyk_components::port::{FluidId, Port};
|
||
/// use entropyk_core::{Pressure, Enthalpy};
|
||
///
|
||
/// // Create SST/SDT polynomial model
|
||
/// let sst_sdt = SstSdtCoefficients::bilinear(
|
||
/// 0.05, 0.001, 0.0005, 0.00001, // mass flow coefficients
|
||
/// 1000.0, 50.0, 30.0, 0.5 // power coefficients
|
||
/// );
|
||
///
|
||
/// let suction = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(3.5),
|
||
/// Enthalpy::from_joules_per_kg(400000.0)
|
||
/// );
|
||
/// let discharge = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(10.0),
|
||
/// Enthalpy::from_joules_per_kg(450000.0)
|
||
/// );
|
||
///
|
||
/// // Create compressor with SST/SDT model
|
||
/// let compressor = Compressor::with_model(
|
||
/// CompressorModel::SstSdt(sst_sdt),
|
||
/// suction,
|
||
/// discharge,
|
||
/// 2900.0, // RPM
|
||
/// 0.0001, // Displacement volume (m³/rev)
|
||
/// 0.85 // Mechanical efficiency
|
||
/// ).unwrap();
|
||
/// ```
|
||
pub fn with_model(
|
||
model: CompressorModel,
|
||
port_suction: Port<Disconnected>,
|
||
port_discharge: Port<Disconnected>,
|
||
speed_rpm: f64,
|
||
displacement_m3_per_rev: f64,
|
||
mechanical_efficiency: f64,
|
||
) -> Result<Self, ComponentError> {
|
||
// Validate model coefficients
|
||
match &model {
|
||
CompressorModel::Ahri540(coeffs) => coeffs.validate()?,
|
||
CompressorModel::SstSdt(coeffs) => coeffs.validate()?,
|
||
}
|
||
|
||
// Validate speed
|
||
if speed_rpm <= 0.0 {
|
||
return Err(ComponentError::InvalidState(
|
||
"Compressor speed must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
// Validate displacement
|
||
if displacement_m3_per_rev <= 0.0 {
|
||
return Err(ComponentError::InvalidState(
|
||
"Displacement volume must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
// Validate efficiency
|
||
if !(0.0..=1.0).contains(&mechanical_efficiency) {
|
||
return Err(ComponentError::InvalidState(
|
||
"Mechanical efficiency must be between 0.0 and 1.0".to_string(),
|
||
));
|
||
}
|
||
|
||
// Validate fluid compatibility
|
||
if port_suction.fluid_id() != port_discharge.fluid_id() {
|
||
return Err(ComponentError::InvalidState(
|
||
"Suction and discharge ports must have the same fluid type".to_string(),
|
||
));
|
||
}
|
||
|
||
let fluid_id = port_suction.fluid_id().clone();
|
||
|
||
Ok(Self {
|
||
model,
|
||
port_suction,
|
||
port_discharge,
|
||
speed_rpm,
|
||
displacement_m3_per_rev,
|
||
mechanical_efficiency,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id,
|
||
|
||
circuit_id: CircuitId::default(), // Default circuit
|
||
operational_state: OperationalState::default(), // Default to On
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
|
||
/// Returns the fluid identifier.
|
||
pub fn fluid_id(&self) -> &FluidId {
|
||
&self.fluid_id
|
||
}
|
||
|
||
/// Returns the suction port.
|
||
pub fn port_suction(&self) -> &Port<Disconnected> {
|
||
&self.port_suction
|
||
}
|
||
|
||
/// Returns the discharge port.
|
||
pub fn port_discharge(&self) -> &Port<Disconnected> {
|
||
&self.port_discharge
|
||
}
|
||
|
||
/// Returns the rotational speed in RPM.
|
||
pub fn speed_rpm(&self) -> f64 {
|
||
self.speed_rpm
|
||
}
|
||
|
||
/// Returns the displacement volume in m³/rev.
|
||
pub fn displacement_m3_per_rev(&self) -> f64 {
|
||
self.displacement_m3_per_rev
|
||
}
|
||
|
||
/// Returns the mechanical efficiency.
|
||
pub fn mechanical_efficiency(&self) -> f64 {
|
||
self.mechanical_efficiency
|
||
}
|
||
|
||
/// Returns the compressor model (AHRI 540 or SST/SDT).
|
||
pub fn model(&self) -> &CompressorModel {
|
||
&self.model
|
||
}
|
||
|
||
/// Returns the AHRI 540 coefficients if using AHRI 540 model.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some(&Ahri540Coefficients)` if the model is AHRI 540, `None` otherwise.
|
||
pub fn ahri540_coefficients(&self) -> Option<&Ahri540Coefficients> {
|
||
match &self.model {
|
||
CompressorModel::Ahri540(coeffs) => Some(coeffs),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Returns the SST/SDT coefficients if using SST/SDT polynomial model.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some(&SstSdtCoefficients)` if the model is SST/SDT, `None` otherwise.
|
||
pub fn sst_sdt_coefficients(&self) -> Option<&SstSdtCoefficients> {
|
||
match &self.model {
|
||
CompressorModel::SstSdt(coeffs) => Some(coeffs),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Returns calibration factors (f_m, f_power, etc.).
|
||
pub fn calib(&self) -> &Calib {
|
||
&self.calib
|
||
}
|
||
|
||
/// Sets calibration factors.
|
||
pub fn set_calib(&mut self, calib: Calib) {
|
||
self.calib = calib;
|
||
}
|
||
|
||
/// Returns the circuit identifier.
|
||
pub fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
/// Sets the circuit identifier.
|
||
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
|
||
/// Returns the operational state.
|
||
pub fn operational_state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
/// Sets the operational state.
|
||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||
self.operational_state = state;
|
||
}
|
||
/// Connects the compressor to suction and discharge ports.
|
||
///
|
||
/// This consumes the disconnected compressor and returns a connected one,
|
||
/// transitioning the state at compile time.
|
||
pub fn connect(
|
||
self,
|
||
suction: Port<Disconnected>,
|
||
discharge: Port<Disconnected>,
|
||
) -> Result<Compressor<Connected>, ComponentError> {
|
||
let (p_suction, _) = self
|
||
.port_suction
|
||
.connect(suction)
|
||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||
let (p_discharge, _) = self
|
||
.port_discharge
|
||
.connect(discharge)
|
||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||
|
||
Ok(Compressor {
|
||
model: self.model,
|
||
port_suction: p_suction,
|
||
port_discharge: p_discharge,
|
||
speed_rpm: self.speed_rpm,
|
||
displacement_m3_per_rev: self.displacement_m3_per_rev,
|
||
mechanical_efficiency: self.mechanical_efficiency,
|
||
calib: self.calib,
|
||
calib_indices: self.calib_indices,
|
||
fluid_id: self.fluid_id,
|
||
circuit_id: self.circuit_id,
|
||
operational_state: self.operational_state,
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl Compressor<Connected> {
|
||
/// Returns the suction port.
|
||
pub fn port_suction(&self) -> &Port<Connected> {
|
||
&self.port_suction
|
||
}
|
||
|
||
/// Returns the discharge port.
|
||
pub fn port_discharge(&self) -> &Port<Connected> {
|
||
&self.port_discharge
|
||
}
|
||
|
||
/// Computes the full thermodynamic state at the suction port.
|
||
pub fn suction_state(
|
||
&self,
|
||
backend: &impl entropyk_fluids::FluidBackend,
|
||
) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||
backend
|
||
.full_state(
|
||
entropyk_fluids::FluidId::new(self.port_suction.fluid_id().as_str()),
|
||
self.port_suction.pressure(),
|
||
self.port_suction.enthalpy(),
|
||
)
|
||
.map_err(|e| {
|
||
ComponentError::from_fluid_error_context("Failed to compute suction state", e)
|
||
})
|
||
}
|
||
|
||
/// Computes the full thermodynamic state at the discharge port.
|
||
pub fn discharge_state(
|
||
&self,
|
||
backend: &impl entropyk_fluids::FluidBackend,
|
||
) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||
backend
|
||
.full_state(
|
||
entropyk_fluids::FluidId::new(self.port_discharge.fluid_id().as_str()),
|
||
self.port_discharge.pressure(),
|
||
self.port_discharge.enthalpy(),
|
||
)
|
||
.map_err(|e| {
|
||
ComponentError::from_fluid_error_context("Failed to compute discharge state", e)
|
||
})
|
||
}
|
||
|
||
/// Calculates the mass flow rate through the compressor.
|
||
///
|
||
/// Uses the selected model (AHRI 540 or SST/SDT):
|
||
/// - AHRI 540: ṁ = M1 × (1 - (P_suction/P_discharge)^(1/M2)) × ρ_suction × V_disp × N/60
|
||
/// - SST/SDT: ṁ = Σ a_ij × SST^i × SDT^j
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `density_suction` - Suction gas density in kg/m³ (used for AHRI 540 model)
|
||
/// * `sst_k` - Saturated suction temperature in Kelvin (used for SST/SDT model)
|
||
/// * `sdt_k` - Saturated discharge temperature in Kelvin (used for SST/SDT model)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the mass flow rate as [`MassFlow`].
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if the calculation results in numerical errors
|
||
/// (e.g., negative pressure ratio, division by zero).
|
||
pub fn mass_flow_rate(
|
||
&self,
|
||
density_suction: f64,
|
||
sst_k: f64,
|
||
sdt_k: f64,
|
||
state: Option<&StateSlice>,
|
||
) -> Result<MassFlow, ComponentError> {
|
||
if density_suction < 0.0 {
|
||
return Err(ComponentError::InvalidState(
|
||
"Suction density cannot be negative".to_string(),
|
||
));
|
||
}
|
||
|
||
let p_suction = self.port_suction.pressure().to_pascals();
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
|
||
// Validate pressures
|
||
if p_suction <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Suction pressure must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
if p_discharge <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Discharge pressure must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
let mass_flow_kg_per_s = match &self.model {
|
||
CompressorModel::Ahri540(coeffs) => {
|
||
// Calculate volumetric efficiency using inverse pressure ratio
|
||
// η_vol = f_etav × (1 - (P_suction/P_discharge)^(1/M2))
|
||
// f_etav is read dynamically from the state vector when wired
|
||
// as a calibration unknown (Story 5.5), like f_m / f_power.
|
||
let f_etav = state
|
||
.and_then(|st| self.calib_indices.z_etav.map(|idx| st[idx]))
|
||
.unwrap_or(self.calib.z_etav);
|
||
let inverse_pressure_ratio = p_suction / p_discharge;
|
||
let volumetric_efficiency =
|
||
(1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2)) * f_etav;
|
||
|
||
if volumetric_efficiency < 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Volumetric efficiency is negative - check pressure ratio and M2 coefficient"
|
||
.to_string(),
|
||
));
|
||
}
|
||
|
||
// Convert RPM to rev/s
|
||
let speed_rev_per_s = self.speed_rpm / 60.0;
|
||
|
||
// Calculate mass flow (AHRI 540 nominal)
|
||
coeffs.m1
|
||
* volumetric_efficiency
|
||
* density_suction
|
||
* self.displacement_m3_per_rev
|
||
* speed_rev_per_s
|
||
}
|
||
CompressorModel::SstSdt(coeffs) => {
|
||
// SST/SDT polynomial model
|
||
coeffs.mass_flow_at(sst_k, sdt_k)
|
||
}
|
||
};
|
||
|
||
// Apply calibration: ṁ_eff = f_m × ṁ_nominal
|
||
let f_m = if let Some(st) = state {
|
||
self.calib_indices
|
||
.z_flow
|
||
.map(|idx| st[idx])
|
||
.unwrap_or(self.calib.z_flow)
|
||
} else {
|
||
self.calib.z_flow
|
||
};
|
||
Ok(MassFlow::from_kg_per_s(mass_flow_kg_per_s * f_m))
|
||
}
|
||
|
||
/// Calculates the power consumption (cooling mode).
|
||
///
|
||
/// Uses the selected model:
|
||
/// - AHRI 540: Ẇ = M3 + M4 × (P_discharge/P_suction) + M5 × T_suction + M6 × T_discharge
|
||
/// - SST/SDT: Ẇ = Σ b_ij × SST^i × SDT^j
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `t_suction` - Suction temperature
|
||
/// * `t_discharge` - Discharge temperature
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the power consumption in Watts.
|
||
pub fn power_consumption_cooling(
|
||
&self,
|
||
t_suction: Temperature,
|
||
t_discharge: Temperature,
|
||
state: Option<&StateSlice>,
|
||
) -> f64 {
|
||
let power_nominal = match &self.model {
|
||
CompressorModel::Ahri540(coeffs) => {
|
||
let pressure_ratio = self.port_discharge.pressure().to_pascals()
|
||
/ self.port_suction.pressure().to_pascals();
|
||
coeffs.m3
|
||
+ coeffs.m4 * pressure_ratio
|
||
+ coeffs.m5 * t_suction.to_kelvin()
|
||
+ coeffs.m6 * t_discharge.to_kelvin()
|
||
}
|
||
CompressorModel::SstSdt(coeffs) => {
|
||
coeffs.power_at(t_suction.to_kelvin(), t_discharge.to_kelvin())
|
||
}
|
||
};
|
||
// Ẇ_eff = f_power × Ẇ_nominal
|
||
let f_power = if let Some(st) = state {
|
||
self.calib_indices
|
||
.z_power
|
||
.map(|idx| st[idx])
|
||
.unwrap_or(self.calib.z_power)
|
||
} else {
|
||
self.calib.z_power
|
||
};
|
||
power_nominal * f_power
|
||
}
|
||
|
||
/// Calculates the power consumption (heating mode).
|
||
///
|
||
/// Uses the selected model:
|
||
/// - AHRI 540: Ẇ = M7 + M8 × (P_discharge/P_suction) + M9 × T_suction + M10 × T_discharge
|
||
/// - SST/SDT: Same as cooling mode (SST/SDT model doesn't distinguish modes)
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `t_suction` - Suction temperature
|
||
/// * `t_discharge` - Discharge temperature
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the power consumption in Watts.
|
||
pub fn power_consumption_heating(
|
||
&self,
|
||
t_suction: Temperature,
|
||
t_discharge: Temperature,
|
||
state: Option<&StateSlice>,
|
||
) -> f64 {
|
||
let power_nominal = match &self.model {
|
||
CompressorModel::Ahri540(coeffs) => {
|
||
let pressure_ratio = self.port_discharge.pressure().to_pascals()
|
||
/ self.port_suction.pressure().to_pascals();
|
||
coeffs.m7
|
||
+ coeffs.m8 * pressure_ratio
|
||
+ coeffs.m9 * t_suction.to_kelvin()
|
||
+ coeffs.m10 * t_discharge.to_kelvin()
|
||
}
|
||
CompressorModel::SstSdt(coeffs) => {
|
||
// SST/SDT model doesn't distinguish between cooling and heating
|
||
coeffs.power_at(t_suction.to_kelvin(), t_discharge.to_kelvin())
|
||
}
|
||
};
|
||
// Ẇ_eff = f_power × Ẇ_nominal
|
||
let f_power = if let Some(st) = state {
|
||
self.calib_indices
|
||
.z_power
|
||
.map(|idx| st[idx])
|
||
.unwrap_or(self.calib.z_power)
|
||
} else {
|
||
self.calib.z_power
|
||
};
|
||
power_nominal * f_power
|
||
}
|
||
|
||
/// Calculates the cooling capacity.
|
||
///
|
||
/// Q̇_cool = ṁ × (h_evap_out - h_evap_in)
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `mass_flow` - Mass flow rate
|
||
/// * `h_evap_in` - Evaporator inlet enthalpy
|
||
/// * `h_evap_out` - Evaporator outlet enthalpy
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the cooling capacity in Watts.
|
||
pub fn cooling_capacity(
|
||
&self,
|
||
mass_flow: MassFlow,
|
||
h_evap_in: Enthalpy,
|
||
h_evap_out: Enthalpy,
|
||
) -> f64 {
|
||
mass_flow.to_kg_per_s() * (h_evap_out.to_joules_per_kg() - h_evap_in.to_joules_per_kg())
|
||
}
|
||
|
||
/// Calculates the heating capacity.
|
||
///
|
||
/// Q̇_heat = ṁ × (h_cond_out - h_cond_in)
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `mass_flow` - Mass flow rate
|
||
/// * `h_cond_in` - Condenser inlet enthalpy
|
||
/// * `h_cond_out` - Condenser outlet enthalpy
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the heating capacity in Watts.
|
||
pub fn heating_capacity(
|
||
&self,
|
||
mass_flow: MassFlow,
|
||
h_cond_in: Enthalpy,
|
||
h_cond_out: Enthalpy,
|
||
) -> f64 {
|
||
mass_flow.to_kg_per_s() * (h_cond_out.to_joules_per_kg() - h_cond_in.to_joules_per_kg())
|
||
}
|
||
|
||
/// Calculates the Coefficient of Performance (COP).
|
||
///
|
||
/// COP = Q̇ / Ẇ
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `capacity` - Cooling or heating capacity in Watts
|
||
/// * `power` - Power consumption in Watts
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the COP. Returns an error if power is zero or negative.
|
||
pub fn coefficient_of_performance(
|
||
&self,
|
||
capacity: f64,
|
||
power: f64,
|
||
) -> Result<f64, ComponentError> {
|
||
if power <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Power must be positive for COP calculation".to_string(),
|
||
));
|
||
}
|
||
Ok(capacity / power)
|
||
}
|
||
|
||
/// Returns the rotational speed in RPM.
|
||
pub fn speed_rpm(&self) -> f64 {
|
||
self.speed_rpm
|
||
}
|
||
|
||
/// Returns the displacement volume in m³/rev.
|
||
pub fn displacement_m3_per_rev(&self) -> f64 {
|
||
self.displacement_m3_per_rev
|
||
}
|
||
|
||
/// Returns the mechanical efficiency.
|
||
pub fn mechanical_efficiency(&self) -> f64 {
|
||
self.mechanical_efficiency
|
||
}
|
||
|
||
/// Returns the compressor model (AHRI 540 or SST/SDT).
|
||
pub fn model(&self) -> &CompressorModel {
|
||
&self.model
|
||
}
|
||
|
||
/// Returns the AHRI 540 coefficients if using AHRI 540 model.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some(&Ahri540Coefficients)` if the model is AHRI 540, `None` otherwise.
|
||
pub fn ahri540_coefficients(&self) -> Option<&Ahri540Coefficients> {
|
||
match &self.model {
|
||
CompressorModel::Ahri540(coeffs) => Some(coeffs),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Returns the SST/SDT coefficients if using SST/SDT polynomial model.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some(&SstSdtCoefficients)` if the model is SST/SDT, `None` otherwise.
|
||
pub fn sst_sdt_coefficients(&self) -> Option<&SstSdtCoefficients> {
|
||
match &self.model {
|
||
CompressorModel::SstSdt(coeffs) => Some(coeffs),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Returns calibration factors (f_m, f_power, etc.).
|
||
pub fn calib(&self) -> &Calib {
|
||
&self.calib
|
||
}
|
||
|
||
/// Sets calibration factors.
|
||
pub fn set_calib(&mut self, calib: Calib) {
|
||
self.calib = calib;
|
||
}
|
||
|
||
/// Returns the circuit identifier.
|
||
pub fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
/// Sets the circuit identifier.
|
||
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
|
||
/// Returns the operational state.
|
||
pub fn operational_state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
/// Sets the operational state.
|
||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||
self.operational_state = state;
|
||
}
|
||
|
||
/// Live energy-retention factor \(f_w\): fraction of shaft work kept in the
|
||
/// refrigerant (`1` = adiabatic, `0` = all work lost to ambient).
|
||
///
|
||
/// Reads `state[calib_indices.f_w]` when free; otherwise stored calib.
|
||
/// Clamped to [0, 1].
|
||
fn live_f_w(&self, state: Option<&StateSlice>) -> f64 {
|
||
let raw = if let Some(st) = state {
|
||
self.calib_indices
|
||
.f_w
|
||
.map(|idx| st.get(idx).copied().unwrap_or(self.calib.f_w))
|
||
.unwrap_or(self.calib.f_w)
|
||
} else {
|
||
self.calib.f_w
|
||
};
|
||
raw.clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
impl Component for Compressor<Connected> {
|
||
fn set_system_context(
|
||
&mut self,
|
||
_state_offset: usize,
|
||
external_edge_state_indices: &[(usize, usize, usize)],
|
||
) {
|
||
// Layout: [0] = incoming suction edge, [1] = outgoing discharge edge.
|
||
// Triple: (m_idx, p_idx, h_idx)
|
||
if !external_edge_state_indices.is_empty() {
|
||
self.suction_m_idx = Some(external_edge_state_indices[0].0);
|
||
self.suction_h_idx = Some(external_edge_state_indices[0].2);
|
||
}
|
||
if external_edge_state_indices.len() >= 2 {
|
||
self.discharge_m_idx = Some(external_edge_state_indices[1].0);
|
||
self.discharge_h_idx = Some(external_edge_state_indices[1].2);
|
||
}
|
||
// CM1.4: detect same-branch topology → conservation residual becomes trivial.
|
||
self.same_branch_m = matches!(
|
||
(self.suction_m_idx, self.discharge_m_idx),
|
||
(Some(suc), Some(dis)) if suc == dis
|
||
);
|
||
}
|
||
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
// Validate residual vector length
|
||
if residuals.len() != self.n_equations() {
|
||
return Err(ComponentError::InvalidResidualDimensions {
|
||
expected: self.n_equations(),
|
||
actual: residuals.len(),
|
||
});
|
||
}
|
||
|
||
let (suc_m_idx, suc_h_idx, dis_m_idx, dis_h_idx) = match (
|
||
self.suction_m_idx,
|
||
self.suction_h_idx,
|
||
self.discharge_m_idx,
|
||
self.discharge_h_idx,
|
||
) {
|
||
(Some(sm), Some(sh), Some(dm), Some(dh)) => (sm, sh, dm, dh),
|
||
_ => {
|
||
return Err(ComponentError::InvalidState(
|
||
"Compressor requires live suction/discharge mass-flow and enthalpy indices"
|
||
.to_string(),
|
||
));
|
||
}
|
||
};
|
||
|
||
// Handle operational states (FR6-FR8)
|
||
match self.operational_state {
|
||
OperationalState::Off => {
|
||
// In Off state, mass flow is zero (FR7)
|
||
residuals[0] = state[suc_m_idx]; // ṁ_suction = 0
|
||
residuals[1] = 0.0; // No energy transfer
|
||
residuals[2] = state[dis_m_idx] - state[suc_m_idx]; // mass conservation
|
||
return Ok(());
|
||
}
|
||
OperationalState::Bypass => {
|
||
// In Bypass state, behaves as adiabatic pipe (FR8)
|
||
let p_suction = self.port_suction.pressure().to_pascals();
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
let h_suction = self.port_suction.enthalpy().to_joules_per_kg();
|
||
let h_discharge = self.port_discharge.enthalpy().to_joules_per_kg();
|
||
|
||
residuals[0] = p_suction - p_discharge; // Pressure continuity
|
||
residuals[1] = h_suction - h_discharge; // Enthalpy continuity (adiabatic)
|
||
residuals[2] = state[dis_m_idx] - state[suc_m_idx]; // mass conservation
|
||
return Ok(());
|
||
}
|
||
OperationalState::On => {
|
||
// Normal operation - continue with AHRI 540 calculations
|
||
}
|
||
}
|
||
|
||
// Extract state variables using stored edge indices (CM1.3)
|
||
let mass_flow_state = state[suc_m_idx]; // kg/s — ṁ at suction edge
|
||
let h_suction = state[suc_h_idx]; // J/kg
|
||
let h_discharge = state[dis_h_idx]; // J/kg
|
||
|
||
// Get port values
|
||
let p_suction = self.port_suction.pressure().to_pascals();
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
|
||
// Calculate temperatures for SST/SDT model
|
||
let t_suction_k = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let t_discharge_k = estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)?;
|
||
|
||
// Calculate mass flow from selected model
|
||
// For now, we use a simplified density calculation
|
||
// In the future, this will come from the fluid property backend
|
||
let density_suction = estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let mass_flow_calc = self
|
||
.mass_flow_rate(density_suction, t_suction_k, t_discharge_k, Some(state))?
|
||
.to_kg_per_s();
|
||
|
||
// Calculate power consumption
|
||
let power_calc = self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t_suction_k),
|
||
Temperature::from_kelvin(t_discharge_k),
|
||
Some(state),
|
||
);
|
||
|
||
// Residual 0: Mass flow continuity
|
||
// ṁ_calc - ṁ_state = 0
|
||
residuals[0] = mass_flow_calc - mass_flow_state;
|
||
|
||
// Residual 1: Energy balance with retention factor f_w
|
||
// (fraction of shaft work kept in the refrigerant):
|
||
// ṁ·Δh = Ẇ·f_w / η_mech ⇒ Ẇ·f_w − ṁ·Δh/η_mech = 0
|
||
// so h_dis ≈ h_suc + f_w·Ẇ/(ṁ·η_mech).
|
||
// f_w = 1 → adiabatic (default); f_w = 0 → all work lost to ambient.
|
||
let enthalpy_change = h_discharge - h_suction;
|
||
let f_w = self.live_f_w(Some(state));
|
||
|
||
// Prevent division by zero
|
||
if self.mechanical_efficiency.abs() < 1e-10 {
|
||
return Err(ComponentError::InvalidState(
|
||
"Mechanical efficiency is too close to zero".to_string(),
|
||
));
|
||
}
|
||
|
||
residuals[1] =
|
||
power_calc * f_w - mass_flow_state * enthalpy_change / self.mechanical_efficiency;
|
||
|
||
// r2: ṁ_discharge − ṁ_suction = 0 (mass conservation, CM1.3)
|
||
// CM1.4: skip when same_branch_m — ṁ_dis == ṁ_suc (same state index),
|
||
// so the residual would be trivially 0 and is excluded from n_equations().
|
||
if !self.same_branch_m {
|
||
residuals[2] = state[dis_m_idx] - state[suc_m_idx];
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
let (suc_m_idx, suc_h_idx, dis_m_idx, dis_h_idx) = match (
|
||
self.suction_m_idx,
|
||
self.suction_h_idx,
|
||
self.discharge_m_idx,
|
||
self.discharge_h_idx,
|
||
) {
|
||
(Some(sm), Some(sh), Some(dm), Some(dh)) => (sm, sh, dm, dh),
|
||
_ => {
|
||
return Err(ComponentError::InvalidState(
|
||
"Compressor Jacobian requires live suction/discharge mass-flow and enthalpy indices".to_string(),
|
||
));
|
||
}
|
||
};
|
||
|
||
let mass_flow_state = state[suc_m_idx];
|
||
let h_suction = state[suc_h_idx];
|
||
let h_discharge = state[dis_h_idx];
|
||
|
||
// Get port values
|
||
let p_suction = self.port_suction.pressure().to_pascals();
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
|
||
let t_suction_k = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let t_discharge_k = estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)?;
|
||
|
||
// Row 0: Mass flow residual r0 = ṁ_calc − ṁ_suction
|
||
// ∂r0/∂ṁ_suction = -1 (exact, CM1.3)
|
||
jacobian.add_entry(0, suc_m_idx, -1.0);
|
||
|
||
// ∂r0/∂h_suction — numerical (density depends on h)
|
||
let dr0_dh_suction = approximate_derivative_result(
|
||
|h| {
|
||
let density = estimate_density(self.fluid_id.as_str(), p_suction, h)?;
|
||
let t_k = estimate_temperature(self.fluid_id.as_str(), p_suction, h)?;
|
||
self.mass_flow_rate(density, t_k, t_discharge_k, Some(state))
|
||
.map(|m| m.to_kg_per_s())
|
||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
|
||
},
|
||
h_suction,
|
||
1.0,
|
||
)?;
|
||
jacobian.add_entry(0, suc_h_idx, dr0_dh_suction);
|
||
|
||
// Row 1: Energy residual r1 = power·f_w − ṁ·Δh/η_mech
|
||
let f_w = self.live_f_w(Some(state));
|
||
// ∂r1/∂ṁ_suction = −(h_discharge − h_suction) / η_mech
|
||
let dr1_dm = -(h_discharge - h_suction) / self.mechanical_efficiency;
|
||
jacobian.add_entry(1, suc_m_idx, dr1_dm);
|
||
|
||
// ∂r1/∂h_suction — numerical
|
||
let dr1_dh_suction = approximate_derivative_result(
|
||
|h| {
|
||
let t = estimate_temperature(self.fluid_id.as_str(), p_suction, h)?;
|
||
let t_discharge =
|
||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)?;
|
||
Ok(self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t),
|
||
Temperature::from_kelvin(t_discharge),
|
||
None,
|
||
) * f_w)
|
||
},
|
||
h_suction,
|
||
1.0,
|
||
)? + mass_flow_state / self.mechanical_efficiency;
|
||
jacobian.add_entry(1, suc_h_idx, dr1_dh_suction);
|
||
|
||
// ∂r1/∂h_discharge — numerical
|
||
let dr1_dh_discharge = approximate_derivative_result(
|
||
|h| {
|
||
let t_suction = estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let t = estimate_temperature(self.fluid_id.as_str(), p_discharge, h)?;
|
||
Ok(self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t_suction),
|
||
Temperature::from_kelvin(t),
|
||
None,
|
||
) * f_w)
|
||
},
|
||
h_discharge,
|
||
1.0,
|
||
)? - mass_flow_state / self.mechanical_efficiency;
|
||
jacobian.add_entry(1, dis_h_idx, dr1_dh_discharge);
|
||
|
||
// Row 2: Mass conservation r2 = ṁ_discharge − ṁ_suction (exact, CM1.3)
|
||
// CM1.4: omit when same_branch_m (trivial equation removed from n_equations).
|
||
if !self.same_branch_m {
|
||
jacobian.add_entry(2, dis_m_idx, 1.0);
|
||
jacobian.add_entry(2, suc_m_idx, -1.0);
|
||
}
|
||
|
||
// Calibration derivatives (Story 5.5)
|
||
if let Some(z_flow_idx) = self.calib_indices.z_flow {
|
||
let density_suction = estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let m_nominal = self
|
||
.mass_flow_rate(density_suction, t_suction_k, t_discharge_k, None)
|
||
.map(|m| m.to_kg_per_s())
|
||
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
|
||
jacobian.add_entry(0, z_flow_idx, m_nominal);
|
||
}
|
||
|
||
if let Some(z_power_idx) = self.calib_indices.z_power {
|
||
let p_nominal = self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t_suction_k),
|
||
Temperature::from_kelvin(t_discharge_k),
|
||
None,
|
||
);
|
||
// r1 = (z_power·Ẇ_nom)·f_w − … ⇒ ∂r1/∂z_power = Ẇ_nom·f_w
|
||
jacobian.add_entry(1, z_power_idx, p_nominal * f_w);
|
||
}
|
||
|
||
if let Some(f_w_idx) = self.calib_indices.f_w {
|
||
let p_live = self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t_suction_k),
|
||
Temperature::from_kelvin(t_discharge_k),
|
||
Some(state),
|
||
);
|
||
// r1 = Ẇ·f_w − … ⇒ ∂r1/∂f_w = +Ẇ
|
||
jacobian.add_entry(1, f_w_idx, p_live);
|
||
}
|
||
|
||
// ∂r0/∂f_etav (AHRI 540 only): ṁ_calc = f_m · f_etav · base with
|
||
// base = M1 · (1 − (P_suc/P_dis)^(1/M2)) · ρ_suc · V_disp · N/60,
|
||
// so the derivative is exactly f_m · base.
|
||
if let Some(z_etav_idx) = self.calib_indices.z_etav {
|
||
if let CompressorModel::Ahri540(coeffs) = &self.model {
|
||
let density_suction =
|
||
estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||
let inverse_pressure_ratio = p_suction / p_discharge;
|
||
let base = coeffs.m1
|
||
* (1.0 - inverse_pressure_ratio.powf(1.0 / coeffs.m2))
|
||
* density_suction
|
||
* self.displacement_m3_per_rev
|
||
* (self.speed_rpm / 60.0);
|
||
let f_m = self
|
||
.calib_indices
|
||
.z_flow
|
||
.map(|idx| state[idx])
|
||
.unwrap_or(self.calib.z_flow);
|
||
jacobian.add_entry(0, z_etav_idx, f_m * base);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
// CM1.4: when suction and discharge share the same ṁ state index (same
|
||
// series branch), the conservation residual r[2] = ṁ_dis − ṁ_suc is
|
||
// trivially 0 and must be dropped to keep the system square.
|
||
if self.same_branch_m {
|
||
2
|
||
} else {
|
||
3
|
||
}
|
||
}
|
||
|
||
fn port_mass_flows(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||
let suc_m_idx = self.suction_m_idx.ok_or_else(|| {
|
||
ComponentError::InvalidState(
|
||
"Compressor mass-flow reporting requires a live suction mass-flow index"
|
||
.to_string(),
|
||
)
|
||
})?;
|
||
let m = entropyk_core::MassFlow::from_kg_per_s(state[suc_m_idx]);
|
||
// Suction (inlet), Discharge (outlet), Oil (no flow modeled yet)
|
||
Ok(vec![
|
||
m,
|
||
entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()),
|
||
entropyk_core::MassFlow::from_kg_per_s(0.0),
|
||
])
|
||
}
|
||
|
||
fn port_enthalpies(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||
if state.len() < 4 {
|
||
return Err(ComponentError::InvalidStateDimensions {
|
||
expected: 4,
|
||
actual: state.len(),
|
||
});
|
||
}
|
||
Ok(vec![
|
||
entropyk_core::Enthalpy::from_joules_per_kg(state[1]),
|
||
entropyk_core::Enthalpy::from_joules_per_kg(state[2]),
|
||
entropyk_core::Enthalpy::from_joules_per_kg(0.0),
|
||
])
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
// FIXME: API LIMITATION - This method returns an empty slice due to lifetime constraints.
|
||
//
|
||
// The Component trait's get_ports() requires returning a reference with the same
|
||
// lifetime as &self, but the actual port storage (in Compressor<Connected>) has
|
||
// a different lifetime. This is a fundamental design issue in the trait.
|
||
//
|
||
// WORKAROUND: Use `get_ports_slice()` method on Compressor<Connected> for actual port access.
|
||
//
|
||
// TODO: Redesign Component trait to support owned port iterators or different lifetime bounds.
|
||
// See: https://github.com/your-org/entropyk/issues/XXX
|
||
&[]
|
||
}
|
||
|
||
fn energy_transfers(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||
match self.operational_state {
|
||
OperationalState::Off | OperationalState::Bypass => Some((
|
||
entropyk_core::Power::from_watts(0.0),
|
||
entropyk_core::Power::from_watts(0.0),
|
||
)),
|
||
OperationalState::On => {
|
||
if state.len() < 4 {
|
||
return None;
|
||
}
|
||
let h_suction = state[1]; // J/kg
|
||
let h_discharge = state[2]; // J/kg
|
||
|
||
let p_suction = self.port_suction.pressure().to_pascals();
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
|
||
let t_suction_k =
|
||
estimate_temperature(self.fluid_id.as_str(), p_suction, h_suction)
|
||
.unwrap_or(273.15);
|
||
let t_discharge_k =
|
||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)
|
||
.unwrap_or(320.0);
|
||
|
||
let power_calc = self.power_consumption_cooling(
|
||
Temperature::from_kelvin(t_suction_k),
|
||
Temperature::from_kelvin(t_discharge_k),
|
||
Some(state),
|
||
);
|
||
|
||
// Work is done *on* the compressor, so it is negative
|
||
Some((
|
||
entropyk_core::Power::from_watts(0.0),
|
||
entropyk_core::Power::from_watts(-power_calc),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn signature(&self) -> String {
|
||
format!(
|
||
"Compressor(fluid={}, circuit={})",
|
||
self.fluid_id.as_str(),
|
||
self.circuit_id.0
|
||
)
|
||
}
|
||
|
||
fn to_params(&self) -> crate::ComponentParams {
|
||
use crate::ComponentParams;
|
||
let mut params = ComponentParams::new("Compressor")
|
||
.with_param("fluid", self.fluid_id.as_str())
|
||
.with_param("circuitId", self.circuit_id.0)
|
||
.with_param("speedRpm", self.speed_rpm)
|
||
.with_param("displacementM3PerRev", self.displacement_m3_per_rev)
|
||
.with_param("mechanicalEfficiency", self.mechanical_efficiency)
|
||
.with_param(
|
||
"calib",
|
||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||
);
|
||
match &self.model {
|
||
CompressorModel::Ahri540(c) => {
|
||
params = params
|
||
.with_param("modelType", "Ahri540")
|
||
.with_param("m1", c.m1)
|
||
.with_param("m2", c.m2)
|
||
.with_param("m3", c.m3)
|
||
.with_param("m4", c.m4)
|
||
.with_param("m5", c.m5)
|
||
.with_param("m6", c.m6)
|
||
.with_param("m7", c.m7)
|
||
.with_param("m8", c.m8)
|
||
.with_param("m9", c.m9)
|
||
.with_param("m10", c.m10);
|
||
}
|
||
CompressorModel::SstSdt(c) => {
|
||
params = params
|
||
.with_param("modelType", "SstSdt")
|
||
.with_param(
|
||
"massFlowCurve",
|
||
serde_json::to_value(&c.mass_flow_curve).unwrap_or(serde_json::Value::Null),
|
||
)
|
||
.with_param(
|
||
"powerCurve",
|
||
serde_json::to_value(&c.power_curve).unwrap_or(serde_json::Value::Null),
|
||
);
|
||
}
|
||
}
|
||
params
|
||
}
|
||
|
||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||
let mut c = self.calib().clone();
|
||
if c.set_factor(factor, value) {
|
||
self.set_calib(c);
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
|
||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||
// Without this override the solver's finalize() wiring was silently
|
||
// dropped (trait default is a no-op): dynamic calibration factors
|
||
// (f_m, f_power, f_etav) registered as bounded variables never reached
|
||
// compute_residuals/jacobian_entries, which already read
|
||
// `self.calib_indices` (mass_flow_rate / power_consumption_cooling).
|
||
self.calib_indices = indices;
|
||
}
|
||
|
||
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
|
||
use crate::MeasuredOutput;
|
||
match kind {
|
||
// Discharge gas temperature (DGT) so a controls[] loop can hold a
|
||
// maximum-DGT limit on the AHRI-540 compressor (like the
|
||
// IsentropicCompressor's liquid-injection loop).
|
||
MeasuredOutput::Temperature => {
|
||
let dis_h_idx = self.discharge_h_idx?;
|
||
if dis_h_idx >= state.len() {
|
||
return None;
|
||
}
|
||
let h_discharge = state[dis_h_idx];
|
||
let p_discharge = self.port_discharge.pressure().to_pascals();
|
||
if !h_discharge.is_finite() || p_discharge <= 0.0 {
|
||
return None;
|
||
}
|
||
estimate_temperature(self.fluid_id.as_str(), p_discharge, h_discharge)
|
||
.ok()
|
||
.filter(|t| t.is_finite())
|
||
}
|
||
// Refrigerant mass flow rate at the suction edge.
|
||
MeasuredOutput::MassFlowRate => {
|
||
let m_idx = self.suction_m_idx.or(self.discharge_m_idx)?;
|
||
if m_idx >= state.len() {
|
||
return None;
|
||
}
|
||
let m = state[m_idx];
|
||
m.is_finite().then_some(m)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
use crate::state_machine::StateManageable;
|
||
|
||
impl StateManageable for Compressor<Connected> {
|
||
fn state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||
if self.operational_state.can_transition_to(state) {
|
||
let from = self.operational_state;
|
||
self.operational_state = state;
|
||
self.on_state_change(from, state);
|
||
Ok(())
|
||
} else {
|
||
Err(ComponentError::InvalidStateTransition {
|
||
from: self.operational_state,
|
||
to: state,
|
||
reason: "Transition not allowed".to_string(),
|
||
})
|
||
}
|
||
}
|
||
|
||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||
self.operational_state.can_transition_to(target)
|
||
}
|
||
|
||
fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
}
|
||
|
||
/// Enthalpy/density thresholds for R134a density estimation (J/kg)
|
||
mod r134a_density {
|
||
pub const ENTHALPY_VAPOR_THRESHOLD: f64 = 350_000.0;
|
||
pub const ENTHALPY_LIQUID_THRESHOLD: f64 = 200_000.0;
|
||
pub const DENSITY_VAPOR: f64 = 20.0;
|
||
pub const DENSITY_LIQUID: f64 = 1200.0;
|
||
}
|
||
|
||
/// Enthalpy/density thresholds for R410A/R454B density estimation (J/kg)
|
||
mod r410a_density {
|
||
pub const ENTHALPY_VAPOR_THRESHOLD: f64 = 380_000.0;
|
||
pub const ENTHALPY_LIQUID_THRESHOLD: f64 = 220_000.0;
|
||
pub const DENSITY_VAPOR: f64 = 25.0;
|
||
pub const DENSITY_LIQUID: f64 = 1100.0;
|
||
}
|
||
|
||
/// Estimates fluid density from pressure and enthalpy.
|
||
///
|
||
/// **PLACEHOLDER IMPLEMENTATION** - Will be replaced by CoolProp integration
|
||
/// in Story 2.2 (Fluid Properties Backend). Current implementation uses
|
||
/// rough approximations for R134a, R410A, and R454B.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `fluid_id` - Fluid identifier (e.g., "R134a")
|
||
/// * `pressure` - Pressure in Pascals
|
||
/// * `enthalpy` - Specific enthalpy in J/kg
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the estimated density in kg/m³.
|
||
fn estimate_density(fluid_id: &str, _pressure: f64, enthalpy: f64) -> Result<f64, ComponentError> {
|
||
// Placeholder: simple estimation based on enthalpy for common refrigerants
|
||
// This should be replaced with proper fluid property calculations
|
||
match fluid_id {
|
||
"R134a" => {
|
||
// Rough approximation for R134a at typical conditions
|
||
use r134a_density::*;
|
||
let density = if enthalpy > ENTHALPY_VAPOR_THRESHOLD {
|
||
DENSITY_VAPOR // Superheated vapor
|
||
} else if enthalpy < ENTHALPY_LIQUID_THRESHOLD {
|
||
DENSITY_LIQUID // Subcooled liquid
|
||
} else {
|
||
// Linear interpolation in two-phase region
|
||
DENSITY_VAPOR
|
||
+ (DENSITY_LIQUID - DENSITY_VAPOR) * (ENTHALPY_VAPOR_THRESHOLD - enthalpy)
|
||
/ (ENTHALPY_VAPOR_THRESHOLD - ENTHALPY_LIQUID_THRESHOLD)
|
||
};
|
||
Ok(density)
|
||
}
|
||
"R410A" | "R454B" => {
|
||
// Similar approximation for R410A and R454B (R454B is close to R410A properties)
|
||
use r410a_density::*;
|
||
let density = if enthalpy > ENTHALPY_VAPOR_THRESHOLD {
|
||
DENSITY_VAPOR
|
||
} else if enthalpy < ENTHALPY_LIQUID_THRESHOLD {
|
||
DENSITY_LIQUID
|
||
} else {
|
||
DENSITY_VAPOR
|
||
+ (DENSITY_LIQUID - DENSITY_VAPOR) * (ENTHALPY_VAPOR_THRESHOLD - enthalpy)
|
||
/ (ENTHALPY_VAPOR_THRESHOLD - ENTHALPY_LIQUID_THRESHOLD)
|
||
};
|
||
Ok(density)
|
||
}
|
||
_ => Err(ComponentError::InvalidState(format!(
|
||
"Unknown fluid: {}",
|
||
fluid_id
|
||
))),
|
||
}
|
||
}
|
||
|
||
/// Estimates fluid temperature from pressure and enthalpy.
|
||
///
|
||
/// **PLACEHOLDER IMPLEMENTATION** - Will be replaced by CoolProp integration
|
||
/// in Story 2.2 (Fluid Properties Backend). Current implementation uses
|
||
/// rough approximations for R134a, R410A, and R454B.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `fluid_id` - Fluid identifier (e.g., "R134a")
|
||
/// * `pressure` - Pressure in Pascals
|
||
/// * `enthalpy` - Specific enthalpy in J/kg
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns the estimated temperature in Kelvin.
|
||
fn estimate_temperature(
|
||
fluid_id: &str,
|
||
pressure: f64,
|
||
enthalpy: f64,
|
||
) -> Result<f64, ComponentError> {
|
||
// Placeholder: simple estimation based on pressure and enthalpy
|
||
match fluid_id {
|
||
"R134a" => {
|
||
// Rough approximation using ideal gas law as baseline
|
||
// T = h / cp (roughly)
|
||
let cp = 1200.0; // J/(kg·K) - approximate specific heat
|
||
let temperature_from_h = enthalpy / cp;
|
||
|
||
// Adjust based on pressure (saturation temperature approximation)
|
||
// For R134a, saturation pressure correlation is roughly:
|
||
// log10(P) ≈ A - B/(T + C)
|
||
// We'll use a simplified approximation
|
||
let p_bar = pressure / 100000.0;
|
||
let t_sat_k = if p_bar > 0.1 {
|
||
// Simplified saturation temperature for R134a
|
||
250.0 + 50.0 * (p_bar / 10.0).ln()
|
||
} else {
|
||
250.0
|
||
};
|
||
|
||
// Return the higher of the two (superheated or saturated)
|
||
Ok(temperature_from_h.max(t_sat_k))
|
||
}
|
||
"R410A" | "R454B" => {
|
||
// R454B is similar to R410A for temperature estimation
|
||
let cp = 1300.0;
|
||
let temperature_from_h = enthalpy / cp;
|
||
let p_bar = pressure / 100000.0;
|
||
let t_sat_k = if p_bar > 0.1 {
|
||
240.0 + 45.0 * (p_bar / 15.0).ln()
|
||
} else {
|
||
240.0
|
||
};
|
||
Ok(temperature_from_h.max(t_sat_k))
|
||
}
|
||
_ => Err(ComponentError::InvalidState(format!(
|
||
"Unknown fluid: {}",
|
||
fluid_id
|
||
))),
|
||
}
|
||
}
|
||
|
||
/// Approximates the derivative of a function using finite differences.
|
||
///
|
||
/// Uses a central difference approximation:
|
||
/// f'(x) ≈ (f(x + h) - f(x - h)) / (2h)
|
||
#[cfg(test)]
|
||
fn approximate_derivative<F>(f: F, x: f64, h: f64) -> f64
|
||
where
|
||
F: Fn(f64) -> f64,
|
||
{
|
||
(f(x + h) - f(x - h)) / (2.0 * h)
|
||
}
|
||
|
||
fn approximate_derivative_result<F>(f: F, x: f64, h: f64) -> Result<f64, ComponentError>
|
||
where
|
||
F: Fn(f64) -> Result<f64, ComponentError>,
|
||
{
|
||
Ok((f(x + h)? - f(x - h)?) / (2.0 * h))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use approx::assert_relative_eq;
|
||
use entropyk_core::Pressure;
|
||
|
||
// Test coefficients representing a typical small compressor
|
||
fn test_coefficients() -> Ahri540Coefficients {
|
||
Ahri540Coefficients::new(
|
||
0.85, // M1: Flow coefficient
|
||
2.5, // M2: Pressure ratio exponent (higher value allows reasonable pressure ratios)
|
||
500.0, // M3: Power constant (cooling)
|
||
1500.0, // M4: Power pressure ratio (cooling)
|
||
-2.5, // M5: Power suction temp (cooling)
|
||
1.8, // M6: Power discharge temp (cooling)
|
||
600.0, // M7: Power constant (heating)
|
||
1600.0, // M8: Power pressure ratio (heating)
|
||
-3.0, // M9: Power suction temp (heating)
|
||
2.0, // M10: Power discharge temp (heating)
|
||
)
|
||
}
|
||
|
||
fn create_test_compressor() -> Compressor<Connected> {
|
||
let coeffs = test_coefficients();
|
||
// Create ports with same initial pressure and enthalpy to allow connection
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5), // Same pressure for connection
|
||
Enthalpy::from_joules_per_kg(400000.0), // Same enthalpy for connection
|
||
);
|
||
let (suction_conn, mut discharge_conn) = suction.connect(discharge).unwrap();
|
||
|
||
// Modify discharge pressure and enthalpy after connection
|
||
// Use moderate pressure ratio (6/3.5 ≈ 1.71) to ensure positive volumetric efficiency
|
||
discharge_conn.set_pressure(Pressure::from_bar(6.0));
|
||
discharge_conn.set_enthalpy(Enthalpy::from_joules_per_kg(450000.0));
|
||
|
||
Compressor {
|
||
model: CompressorModel::Ahri540(coeffs),
|
||
port_suction: suction_conn,
|
||
port_discharge: discharge_conn,
|
||
speed_rpm: 2900.0,
|
||
displacement_m3_per_rev: 0.0001,
|
||
mechanical_efficiency: 0.85,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
operational_state: OperationalState::default(),
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_creation() {
|
||
let coeffs = test_coefficients();
|
||
assert_eq!(coeffs.m1, 0.85);
|
||
assert_eq!(coeffs.m2, 2.5);
|
||
assert_eq!(coeffs.m3, 500.0);
|
||
assert_eq!(coeffs.m10, 2.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_validation_valid() {
|
||
let coeffs = test_coefficients();
|
||
assert!(coeffs.validate().is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_validation_nan() {
|
||
let mut coeffs = test_coefficients();
|
||
coeffs.m1 = f64::NAN;
|
||
assert!(coeffs.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_validation_infinite() {
|
||
let mut coeffs = test_coefficients();
|
||
coeffs.m2 = f64::INFINITY;
|
||
assert!(coeffs.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_validation_negative_m2() {
|
||
let mut coeffs = test_coefficients();
|
||
coeffs.m2 = -0.5;
|
||
assert!(coeffs.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_disconnected_compressor_creation() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(15.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
|
||
let compressor = Compressor::new(coeffs, suction, discharge, 2900.0, 0.0001, 0.85);
|
||
|
||
assert!(compressor.is_ok());
|
||
let comp = compressor.unwrap();
|
||
assert_eq!(comp.speed_rpm(), 2900.0);
|
||
assert_eq!(comp.displacement_m3_per_rev(), 0.0001);
|
||
assert_eq!(comp.mechanical_efficiency(), 0.85);
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_creation_zero_speed() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(15.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
|
||
let result = Compressor::new(
|
||
coeffs, suction, discharge, 0.0, // Invalid speed
|
||
0.0001, 0.85,
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_creation_negative_displacement() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(15.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
|
||
let result = Compressor::new(
|
||
coeffs, suction, discharge, 2900.0, -0.0001, // Invalid displacement
|
||
0.85,
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_creation_invalid_efficiency() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(15.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
|
||
let result = Compressor::new(
|
||
coeffs, suction, discharge, 2900.0, 0.0001, 1.5, // Invalid efficiency
|
||
);
|
||
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_creation_different_fluids() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.5),
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R410A"), // Different fluid
|
||
Pressure::from_bar(15.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
|
||
let result = Compressor::new(coeffs, suction, discharge, 2900.0, 0.0001, 0.85);
|
||
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_flow_calculation() {
|
||
let compressor = create_test_compressor();
|
||
let density = 20.0; // kg/m³ (approximate vapor density)
|
||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||
|
||
let mass_flow = compressor
|
||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||
.unwrap();
|
||
|
||
// Verify mass flow is positive
|
||
assert!(mass_flow.to_kg_per_s() > 0.0);
|
||
|
||
// Verify calculation: M1 * (1 - (P_suction/P_discharge)^(1/M2)) * rho * Vdisp * N/60
|
||
// Using inverse pressure ratio from create_test_compressor (3.5/6.0)
|
||
let inverse_pressure_ratio: f64 = 3.5 / 6.0;
|
||
let volumetric_eff = 1.0 - inverse_pressure_ratio.powf(1.0 / 2.5);
|
||
let speed_rev_per_s = 2900.0 / 60.0;
|
||
let expected_mass_flow = 0.85 * volumetric_eff * density * 0.0001 * speed_rev_per_s;
|
||
|
||
assert_relative_eq!(mass_flow.to_kg_per_s(), expected_mass_flow, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_f_m_scales_mass_flow() {
|
||
let mut compressor = create_test_compressor();
|
||
let density = 20.0;
|
||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||
let m_default = compressor
|
||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||
.unwrap()
|
||
.to_kg_per_s();
|
||
|
||
compressor.set_calib(Calib {
|
||
z_flow: 1.1,
|
||
..Calib::default()
|
||
});
|
||
let m_calib = compressor
|
||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||
.unwrap()
|
||
.to_kg_per_s();
|
||
assert_relative_eq!(m_calib / m_default, 1.1, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_f_power_scales_compressor_power() {
|
||
let mut compressor = create_test_compressor();
|
||
let t_suction = Temperature::from_celsius(5.0);
|
||
let t_discharge = Temperature::from_celsius(45.0);
|
||
let p_default = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||
|
||
compressor.set_calib(Calib {
|
||
z_power: 1.1,
|
||
..Calib::default()
|
||
});
|
||
let p_calib = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||
assert_relative_eq!(p_calib / p_default, 1.1, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_f_etav_scales_volumetric_efficiency() {
|
||
let mut compressor = create_test_compressor();
|
||
let t_suction_k = 278.15;
|
||
let t_discharge_k = 318.15;
|
||
let rho = 15.0;
|
||
let m_default = compressor
|
||
.mass_flow_rate(rho, t_suction_k, t_discharge_k, None)
|
||
.unwrap()
|
||
.to_kg_per_s();
|
||
|
||
compressor.set_calib(Calib {
|
||
z_etav: 0.9,
|
||
..Calib::default()
|
||
});
|
||
let m_calib = compressor
|
||
.mass_flow_rate(rho, t_suction_k, t_discharge_k, None)
|
||
.unwrap()
|
||
.to_kg_per_s();
|
||
|
||
assert_relative_eq!(m_calib / m_default, 0.9, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_flow_negative_density() {
|
||
let compressor = create_test_compressor();
|
||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||
let result = compressor.mass_flow_rate(-10.0, t_suction_k, t_discharge_k, None);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_flow_zero_suction_pressure() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(0.0), // Zero pressure
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(0.0), // Same zero pressure for connection
|
||
Enthalpy::from_joules_per_kg(400000.0), // Same enthalpy for connection
|
||
);
|
||
let (suction_conn, mut discharge_conn) = suction.connect(discharge).unwrap();
|
||
|
||
// Modify discharge pressure and enthalpy after connection
|
||
discharge_conn.set_pressure(Pressure::from_bar(15.0));
|
||
discharge_conn.set_enthalpy(Enthalpy::from_joules_per_kg(450000.0));
|
||
|
||
let compressor = Compressor {
|
||
model: CompressorModel::Ahri540(coeffs),
|
||
port_suction: suction_conn,
|
||
port_discharge: discharge_conn,
|
||
speed_rpm: 2900.0,
|
||
displacement_m3_per_rev: 0.0001,
|
||
mechanical_efficiency: 0.85,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
operational_state: OperationalState::default(),
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||
let result = compressor.mass_flow_rate(20.0, t_suction_k, t_discharge_k, None);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_power_consumption_cooling() {
|
||
let compressor = create_test_compressor();
|
||
let t_suction = Temperature::from_celsius(5.0);
|
||
let t_discharge = Temperature::from_celsius(45.0);
|
||
|
||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||
|
||
// Verify power is positive
|
||
assert!(power > 0.0);
|
||
|
||
// Verify calculation: M3 + M4 * PR + M5 * T_suction + M6 * T_discharge
|
||
// Using 6.0/3.5 pressure ratio from create_test_compressor
|
||
let pressure_ratio: f64 = 6.0 / 3.5;
|
||
let expected_power = 500.0
|
||
+ 1500.0 * pressure_ratio
|
||
+ (-2.5) * t_suction.to_kelvin()
|
||
+ 1.8 * t_discharge.to_kelvin();
|
||
|
||
assert_relative_eq!(power, expected_power, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_power_consumption_heating() {
|
||
let compressor = create_test_compressor();
|
||
let t_suction = Temperature::from_celsius(5.0);
|
||
let t_discharge = Temperature::from_celsius(45.0);
|
||
|
||
let power = compressor.power_consumption_heating(t_suction, t_discharge, None);
|
||
|
||
// Verify calculation: M7 + M8 * PR + M9 * T_suction + M10 * T_discharge
|
||
// Using 6.0/3.5 pressure ratio from create_test_compressor
|
||
let pressure_ratio: f64 = 6.0 / 3.5;
|
||
let expected_power = 600.0
|
||
+ 1600.0 * pressure_ratio
|
||
+ (-3.0) * t_suction.to_kelvin()
|
||
+ 2.0 * t_discharge.to_kelvin();
|
||
|
||
assert_relative_eq!(power, expected_power, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_cooling_capacity() {
|
||
let compressor = create_test_compressor();
|
||
let mass_flow = MassFlow::from_kg_per_s(0.05);
|
||
let h_evap_in = Enthalpy::from_joules_per_kg(250000.0);
|
||
let h_evap_out = Enthalpy::from_joules_per_kg(400000.0);
|
||
|
||
let capacity = compressor.cooling_capacity(mass_flow, h_evap_in, h_evap_out);
|
||
|
||
// Q = ṁ * (h_out - h_in)
|
||
let expected_capacity = 0.05 * (400000.0 - 250000.0);
|
||
assert_relative_eq!(capacity, expected_capacity, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_heating_capacity() {
|
||
let compressor = create_test_compressor();
|
||
let mass_flow = MassFlow::from_kg_per_s(0.05);
|
||
let h_cond_in = Enthalpy::from_joules_per_kg(450000.0);
|
||
let h_cond_out = Enthalpy::from_joules_per_kg(250000.0);
|
||
|
||
let capacity = compressor.heating_capacity(mass_flow, h_cond_in, h_cond_out);
|
||
|
||
// Q = ṁ * (h_out - h_in)
|
||
let expected_capacity = 0.05 * (250000.0 - 450000.0); // Negative for heating
|
||
assert_relative_eq!(capacity, expected_capacity, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_of_performance() {
|
||
let compressor = create_test_compressor();
|
||
let capacity = 5000.0; // W
|
||
let power = 2000.0; // W
|
||
|
||
let cop = compressor
|
||
.coefficient_of_performance(capacity, power)
|
||
.unwrap();
|
||
assert_relative_eq!(cop, 2.5, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_coefficient_of_performance_zero_power() {
|
||
let compressor = create_test_compressor();
|
||
let result = compressor.coefficient_of_performance(5000.0, 0.0);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_n_equations() {
|
||
let compressor = create_test_compressor();
|
||
// CM1.3: 2 thermo + 1 mass-flow conservation = 3 equations
|
||
assert_eq!(compressor.n_equations(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_compute_residuals() {
|
||
let mut compressor = create_test_compressor();
|
||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||
let mut residuals = vec![0.0; 3];
|
||
|
||
let result = compressor.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_ok(), "jacobian error: {:?}", result);
|
||
|
||
// Verify residuals are calculated (actual values depend on fluid properties)
|
||
assert!(!residuals[0].is_nan());
|
||
assert!(!residuals[1].is_nan());
|
||
assert!(!residuals[2].is_nan());
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_compute_residuals_wrong_size() {
|
||
let mut compressor = create_test_compressor();
|
||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||
let mut residuals = vec![0.0; 2]; // Wrong size (n_equations is now 3)
|
||
|
||
let result = compressor.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_jacobian_entries() {
|
||
let mut compressor = create_test_compressor();
|
||
compressor.set_system_context(0, &[(0, 0, 1), (3, 0, 2)]);
|
||
let state = vec![0.05, 400000.0, 450000.0, 3500.0];
|
||
let mut jacobian = JacobianBuilder::new();
|
||
|
||
let result = compressor.jacobian_entries(&state, &mut jacobian);
|
||
assert!(result.is_ok(), "jacobian error: {:?}", result);
|
||
|
||
// Should have at least some entries
|
||
assert!(!jacobian.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_estimate_density_r134a() {
|
||
let density = estimate_density("R134a", 350000.0, 400000.0).unwrap();
|
||
// At high enthalpy (superheated), should be around 20 kg/m³
|
||
assert!(density > 0.0);
|
||
assert!(density < 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_estimate_temperature_r134a() {
|
||
let temp = estimate_temperature("R134a", 350000.0, 400000.0).unwrap();
|
||
assert!(temp > 200.0);
|
||
assert!(temp < 500.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_estimate_unknown_fluid() {
|
||
let result = estimate_density("UnknownFluid", 350000.0, 400000.0);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_approximate_derivative() {
|
||
let f = |x: f64| x * x;
|
||
let derivative = approximate_derivative(f, 2.0, 0.001);
|
||
// f'(x) = 2x, so at x=2, f'(2) = 4
|
||
assert_relative_eq!(derivative, 4.0, epsilon = 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_with_r410a() {
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R410A"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(380000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R410A"),
|
||
Pressure::from_bar(10.0), // Same pressure for connection
|
||
Enthalpy::from_joules_per_kg(380000.0), // Same enthalpy for connection
|
||
);
|
||
let (suction_conn, mut discharge_conn) = suction.connect(discharge).unwrap();
|
||
|
||
// Modify discharge pressure and enthalpy after connection
|
||
// Use moderate pressure ratio (18/10 = 1.8) to ensure positive volumetric efficiency
|
||
discharge_conn.set_pressure(Pressure::from_bar(18.0));
|
||
discharge_conn.set_enthalpy(Enthalpy::from_joules_per_kg(430000.0));
|
||
|
||
let compressor = Compressor {
|
||
model: CompressorModel::Ahri540(coeffs),
|
||
port_suction: suction_conn,
|
||
port_discharge: discharge_conn,
|
||
speed_rpm: 3600.0,
|
||
displacement_m3_per_rev: 0.00008,
|
||
mechanical_efficiency: 0.88,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id: FluidId::new("R410A"),
|
||
circuit_id: CircuitId::default(),
|
||
operational_state: OperationalState::default(),
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let density = 25.0; // kg/m³
|
||
let t_suction_k = 283.15; // 10°C in Kelvin
|
||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||
let mass_flow = compressor
|
||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||
.unwrap();
|
||
assert!(mass_flow.to_kg_per_s() > 0.0);
|
||
|
||
let t_suction = Temperature::from_celsius(10.0);
|
||
let t_discharge = Temperature::from_celsius(50.0);
|
||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||
assert!(power > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_with_r454b() {
|
||
// R454B (Opteon XL41) is a low-GWP replacement for R410A
|
||
let coeffs = test_coefficients();
|
||
let suction = Port::new(
|
||
FluidId::new("R454B"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(380000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R454B"),
|
||
Pressure::from_bar(10.0), // Same pressure for connection
|
||
Enthalpy::from_joules_per_kg(380000.0), // Same enthalpy for connection
|
||
);
|
||
let (suction_conn, mut discharge_conn) = suction.connect(discharge).unwrap();
|
||
|
||
// Modify discharge pressure and enthalpy after connection
|
||
discharge_conn.set_pressure(Pressure::from_bar(18.0));
|
||
discharge_conn.set_enthalpy(Enthalpy::from_joules_per_kg(430000.0));
|
||
|
||
let compressor = Compressor {
|
||
model: CompressorModel::Ahri540(coeffs),
|
||
port_suction: suction_conn,
|
||
port_discharge: discharge_conn,
|
||
speed_rpm: 3600.0,
|
||
displacement_m3_per_rev: 0.00008,
|
||
mechanical_efficiency: 0.88,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id: FluidId::new("R454B"),
|
||
circuit_id: CircuitId::default(),
|
||
operational_state: OperationalState::default(),
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
};
|
||
|
||
// R454B should use R410A properties as approximation
|
||
let density = 25.0; // kg/m³
|
||
let t_suction_k = 283.15; // 10°C in Kelvin
|
||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||
let mass_flow = compressor
|
||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||
.unwrap();
|
||
assert!(mass_flow.to_kg_per_s() > 0.0);
|
||
|
||
let t_suction = Temperature::from_celsius(10.0);
|
||
let t_discharge = Temperature::from_celsius(50.0);
|
||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||
assert!(power > 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_flow_with_high_pressure_ratio() {
|
||
let coeffs = Ahri540Coefficients::new(
|
||
0.85, 2.5, // M1, M2
|
||
500.0, 1500.0, -2.5, 1.8, // M3-M6
|
||
600.0, 1600.0, -3.0, 2.0, // M7-M10
|
||
);
|
||
let suction = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0), // Low suction pressure
|
||
Enthalpy::from_joules_per_kg(400000.0),
|
||
);
|
||
let discharge = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(1.0), // Same pressure for connection
|
||
Enthalpy::from_joules_per_kg(400000.0), // Same enthalpy for connection
|
||
);
|
||
let (suction_conn, mut discharge_conn) = suction.connect(discharge).unwrap();
|
||
|
||
// Modify discharge pressure and enthalpy after connection
|
||
discharge_conn.set_pressure(Pressure::from_bar(30.0));
|
||
discharge_conn.set_enthalpy(Enthalpy::from_joules_per_kg(450000.0));
|
||
|
||
let compressor = Compressor {
|
||
model: CompressorModel::Ahri540(coeffs),
|
||
port_suction: suction_conn,
|
||
port_discharge: discharge_conn,
|
||
speed_rpm: 2900.0,
|
||
displacement_m3_per_rev: 0.0001,
|
||
mechanical_efficiency: 0.85,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
operational_state: OperationalState::default(),
|
||
suction_m_idx: None,
|
||
suction_h_idx: None,
|
||
discharge_m_idx: None,
|
||
discharge_h_idx: None,
|
||
same_branch_m: false,
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let density = 20.0;
|
||
let t_suction_k = 283.15; // 10°C in Kelvin
|
||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||
// With high pressure ratio, volumetric efficiency might be negative
|
||
// depending on M2 value
|
||
let result = compressor.mass_flow_rate(density, t_suction_k, t_discharge_k, None);
|
||
// This may fail due to negative volumetric efficiency
|
||
// which is expected behavior
|
||
if result.is_ok() {
|
||
let mass_flow = result.unwrap();
|
||
assert!(mass_flow.to_kg_per_s() >= 0.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_compressor_clone() {
|
||
let compressor = create_test_compressor();
|
||
let cloned = compressor.clone();
|
||
|
||
assert_eq!(compressor.speed_rpm(), cloned.speed_rpm());
|
||
assert_eq!(
|
||
compressor.displacement_m3_per_rev(),
|
||
cloned.displacement_m3_per_rev()
|
||
);
|
||
assert_eq!(
|
||
compressor.mechanical_efficiency(),
|
||
cloned.mechanical_efficiency()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_state() {
|
||
let compressor = create_test_compressor();
|
||
assert_eq!(compressor.state(), OperationalState::On);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_state_on_to_off() {
|
||
let mut compressor = create_test_compressor();
|
||
let result = compressor.set_state(OperationalState::Off);
|
||
assert!(result.is_ok());
|
||
assert_eq!(compressor.state(), OperationalState::Off);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_state_on_to_bypass() {
|
||
let mut compressor = create_test_compressor();
|
||
let result = compressor.set_state(OperationalState::Bypass);
|
||
assert!(result.is_ok());
|
||
assert_eq!(compressor.state(), OperationalState::Bypass);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_can_transition_to() {
|
||
let compressor = create_test_compressor();
|
||
assert!(compressor.can_transition_to(OperationalState::Off));
|
||
assert!(compressor.can_transition_to(OperationalState::Bypass));
|
||
assert!(compressor.can_transition_to(OperationalState::On));
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_circuit_id() {
|
||
let compressor = create_test_compressor();
|
||
assert_eq!(*compressor.circuit_id(), CircuitId::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_circuit_id() {
|
||
let mut compressor = create_test_compressor();
|
||
compressor.set_circuit_id(CircuitId::from_number(5));
|
||
assert_eq!(compressor.circuit_id().as_number(), 5);
|
||
}
|
||
}
|