//! 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> = mass_coeffs.iter().map(|row| row.to_vec()).collect(); let power_vec: Vec> = 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 for CompressorModel { fn from(coeffs: Ahri540Coefficients) -> Self { CompressorModel::Ahri540(coeffs) } } impl From 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 { /// Compressor performance model (AHRI 540 or SST/SDT polynomial) model: CompressorModel, /// Suction port (inlet) port_suction: Port, /// Discharge port (outlet) port_discharge: Port, /// 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, /// State-vector index: suction enthalpy (incoming edge, CM1.3) suction_h_idx: Option, /// State-vector index: discharge mass flow (outgoing edge, CM1.3) discharge_m_idx: Option, /// State-vector index: discharge enthalpy (outgoing edge, CM1.3) discharge_h_idx: Option, /// 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, } impl Compressor { /// 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; 2] { [&self.port_suction, &self.port_discharge] } } impl Compressor { /// 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, port_discharge: Port, speed_rpm: f64, displacement_m3_per_rev: f64, mechanical_efficiency: f64, ) -> Result { 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, port_discharge: Port, speed_rpm: f64, displacement_m3_per_rev: f64, mechanical_efficiency: f64, ) -> Result { // 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 { &self.port_suction } /// Returns the discharge port. pub fn port_discharge(&self) -> &Port { &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, discharge: Port, ) -> Result, 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 { /// Returns the suction port. pub fn port_suction(&self) -> &Port { &self.port_suction } /// Returns the discharge port. pub fn port_discharge(&self) -> &Port { &self.port_discharge } /// Computes the full thermodynamic state at the suction port. pub fn suction_state( &self, backend: &impl entropyk_fluids::FluidBackend, ) -> Result { 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 { 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 { 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 { 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 { 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, 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, 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) has // a different lifetime. This is a fundamental design issue in the trait. // // WORKAROUND: Use `get_ports_slice()` method on Compressor 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 { 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 { 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 { // 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 { // 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, 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, x: f64, h: f64) -> Result where F: Fn(f64) -> Result, { 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 { 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); } }