Files
Entropyk/crates/components/src/compressor.rs
sepehr 5425685a48
Some checks failed
CI / check (push) Has been cancelled
Add model embeddings for Z-factor DoF, separate from SaturatedController.
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>
2026-07-19 22:42:31 +02:00

2518 lines
88 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.
//! 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);
}
}