//! IsentropicCompressor Component //! //! A simplified compressor model for vapor-compression cycle simulation. //! Uses fixed design-point parameters (t_evap_k, t_cond_k) to compute the //! discharge state, avoiding transient instability during Picard iterations. //! //! ## Model //! //! Given fixed operating point parameters (t_evap_k, t_cond_k, superheat_k): //! //! 1. P_evap = P_sat(T_evap) [CoolProp] //! 2. T_suc = T_evap + superheat_k [fixed suction temperature] //! 3. H_suc = H(P_evap, T_suc) [superheated vapor enthalpy] //! 4. T_dis_isen = T_suc × (P_cond/P_evap)^((γ-1)/γ) [polytropic approx, γ≈1.14] //! 5. H_dis_isen = H(P_cond, T_dis_isen) [isentropic discharge enthalpy] //! 6. H_dis = H_suc + (H_dis_isen - H_suc) / η_is [actual discharge] //! //! Residuals (2 equations, constrain the compressor outlet edge): //! //! - r0 = P_dis_state - P_cond_sat(T_cond) //! - r1 = H_dis_state - H_dis use crate::state_machine::{CircuitId, OperationalState, StateManageable}; use crate::{ Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput, ResidualVector, StateSlice, }; use entropyk_core::{CalibIndices, Enthalpy, Entropy, Power, Pressure}; use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property}; use std::sync::Arc; /// Volumetric-efficiency model for the compressor displacement mass-flow closure. /// /// The swept mass flow is `ṁ = ρ_suc · V_s · N · η_vol`, where `η_vol` captures /// re-expansion of the clearance volume as the pressure ratio grows. #[derive(Debug, Clone, Copy, PartialEq)] pub enum VolumetricEfficiency { /// Constant volumetric efficiency `η_vol` (0..1]. Constant(f64), /// Clearance-volume model `η_vol = 1 + C − C·(P_dis/P_suc)^(1/n)`. /// /// * `clearance` — clearance-volume ratio `C` (typically 0.02..0.10). /// * `polytropic_n` — re-expansion polytropic exponent `n` (≈1.0..1.2). Clearance { /// Clearance-volume ratio `C`. clearance: f64, /// Re-expansion polytropic exponent `n`. polytropic_n: f64, }, } impl VolumetricEfficiency { /// Evaluates the volumetric efficiency at the given pressure ratio `Pr = P_dis/P_suc`. /// /// The result is clamped to `[0, 1]` to keep the swept mass flow physical even /// during early Newton iterations when the pressure ratio is not yet realistic. pub fn eval(&self, pressure_ratio: f64) -> f64 { let eta = match *self { VolumetricEfficiency::Constant(eta) => eta, VolumetricEfficiency::Clearance { clearance, polytropic_n, } => { let pr = pressure_ratio.max(1.0); let n = polytropic_n.max(1e-3); 1.0 + clearance - clearance * pr.powf(1.0 / n) } }; eta.clamp(0.0, 1.0) } } /// Variable-speed-drive (VSD) efficiency map for an inverter-driven compressor. /// /// Real inverter compressors do not run at a single fixed efficiency: both the /// volumetric and the isentropic efficiency peak near a rated speed and fall off /// at very low speed (leakage-dominated) and very high speed /// (friction/throttling-dominated). This map applies a multiplicative speed /// correction `f(r)` to the base efficiencies, with `r = N / N_ref` the ratio to /// the reference (rating) speed: /// /// `f(r) = c0 + c1·r + c2·r²` (clamped to a physical band). /// /// The default coefficients `[1, 0, 0]` reproduce a speed-independent /// (constant-efficiency) compressor exactly, so the map is fully opt-in. #[derive(Debug, Clone, Copy, PartialEq)] pub struct VsdSpeedMap { /// Reference (rating) speed `N_ref` [rev/s] the correction is normalized to. pub reference_speed_hz: f64, /// Quadratic coefficients `[c0, c1, c2]` for the volumetric-efficiency /// speed correction `η_vol,eff = η_vol · (c0 + c1·r + c2·r²)`. pub volumetric_coeffs: [f64; 3], /// Quadratic coefficients `[c0, c1, c2]` for the isentropic-efficiency /// speed correction `η_is,eff = η_is · (c0 + c1·r + c2·r²)`. pub isentropic_coeffs: [f64; 3], } impl VsdSpeedMap { /// Lower/upper clamp band for the multiplicative correction factor. const MIN_FACTOR: f64 = 0.1; const MAX_FACTOR: f64 = 1.2; /// Creates an identity map (no speed correction) at the given reference speed. pub fn identity(reference_speed_hz: f64) -> Self { Self { reference_speed_hz: reference_speed_hz.max(1e-6), volumetric_coeffs: [1.0, 0.0, 0.0], isentropic_coeffs: [1.0, 0.0, 0.0], } } /// Creates a VSD map from explicit volumetric and isentropic coefficients. pub fn new( reference_speed_hz: f64, volumetric_coeffs: [f64; 3], isentropic_coeffs: [f64; 3], ) -> Self { Self { reference_speed_hz: reference_speed_hz.max(1e-6), volumetric_coeffs, isentropic_coeffs, } } #[inline] fn eval(coeffs: &[f64; 3], r: f64) -> f64 { (coeffs[0] + coeffs[1] * r + coeffs[2] * r * r).clamp(Self::MIN_FACTOR, Self::MAX_FACTOR) } /// Volumetric-efficiency speed-correction factor at speed `N` [rev/s]. #[inline] pub fn volumetric_correction(&self, speed_hz: f64) -> f64 { Self::eval(&self.volumetric_coeffs, speed_hz / self.reference_speed_hz) } /// Isentropic-efficiency speed-correction factor at speed `N` [rev/s]. #[inline] pub fn isentropic_correction(&self, speed_hz: f64) -> f64 { Self::eval(&self.isentropic_coeffs, speed_hz / self.reference_speed_hz) } } /// Simplified isentropic compressor for vapor-compression cycle simulation. /// /// Uses fixed design-point parameters (t_evap_k, t_cond_k, superheat_k) rather than /// reading suction conditions from the solver state vector. This avoids transient /// instability from unphysical intermediate states during Picard iterations. /// /// # Example (JSON CLI) /// /// ```json /// { /// "type": "IsentropicCompressor", /// "name": "comp", /// "isentropic_efficiency": 0.75, /// "t_cond_k": 323.15, /// "t_evap_k": 275.15, /// "superheat_k": 5.0 /// } /// ``` pub struct IsentropicCompressor { /// Isentropic efficiency (0..1) isentropic_efficiency: f64, /// Condensing saturation temperature [K] t_cond_k: f64, /// Evaporating saturation temperature [K] t_evap_k: f64, /// Suction superheat above evaporating temperature [K] superheat_k: f64, /// Refrigerant identifier refrigerant_id: String, /// Fluid backend for CoolProp property queries fluid_backend: Option>, /// State-vector index: discharge pressure (outgoing edge — constrained by this component) discharge_p_idx: Option, /// State-vector index: discharge enthalpy (outgoing edge — constrained by this component) discharge_h_idx: Option, /// State-vector index: discharge mass flow (outgoing edge, CM1.3) discharge_m_idx: Option, /// State-vector index: suction pressure (incoming edge — read for actual compression) suction_p_idx: Option, /// State-vector index: suction enthalpy (incoming edge — read for actual compression) suction_h_idx: Option, /// State-vector index: suction mass flow (incoming edge, CM1.3) suction_m_idx: Option, /// True when suction and discharge share the same ṁ state index (same /// series branch). The mass-conservation residual is dropped (CM1.4). same_branch_m: bool, /// When `true`, the condensing/evaporating pressures are NOT imposed by this /// compressor. Instead of driving `P_dis → P_sat(t_cond_k)`, residual r0 /// closes the shared mass flow via the volumetric displacement model, letting /// the discharge pressure emerge from the condenser ↔ secondary balance. emergent_pressure: bool, /// Swept (displacement) volume per revolution [m³/rev] — required in emergent mode. displacement_m3: Option, /// Rotational speed [rev/s] — required in emergent mode. speed_hz: Option, /// Volumetric-efficiency model used by the displacement mass-flow closure. volumetric_efficiency: VolumetricEfficiency, /// Optional variable-speed-drive efficiency map. When set (with a known /// `speed_hz`), it applies a multiplicative speed correction to both the /// volumetric and the isentropic efficiency. `None` = speed-independent. vsd_map: Option, circuit_id: CircuitId, operational_state: OperationalState, /// Inverse-control calibration state indices. When `f_m` is `Some(i)`, the /// volumetric mass-flow closure is scaled by the control variable at /// `state[i]`, turning the compressor into a capacity/mass-flow actuator. calib_indices: CalibIndices, /// When `true`, a screw-compressor slide valve modulates the effective swept /// volume to hold a target suction saturated temperature (SST). The slide /// position `σ ∈ [σ_min, 1]` is a free actuator that scales the displacement /// mass flow (`ṁ = σ · f_m · ṁ_calc`); one extra equation /// `r = T_sat(P_suc) − SST_target` is closed by `σ`. Requires emergent mode. slide_valve: bool, /// Target suction saturated temperature [K] held by the slide-valve actuator. sst_target_k: Option, /// When `true`, a liquid-injection port desuperheats the discharge gas. The /// injection ratio `φ_inj ∈ [0, φ_max]` is a *physical actuator* read from /// `CalibIndices.actuator`; it lowers the effective discharge enthalpy /// (`h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis))`). Unlike the slide /// valve, it emits NO internal setpoint equation — the closing equation is /// supplied by a user-declared `controls[]` loop (e.g. hold DGT ≤ max_DGT), /// so the controlled variable is selectable in configuration, not hard-coded. liquid_injection: bool, } impl std::fmt::Debug for IsentropicCompressor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("IsentropicCompressor") .field("isentropic_efficiency", &self.isentropic_efficiency) .field("t_cond_k", &self.t_cond_k) .field("t_evap_k", &self.t_evap_k) .field("superheat_k", &self.superheat_k) .field("refrigerant_id", &self.refrigerant_id) .field("backend_set", &self.fluid_backend.is_some()) .field("discharge_p_idx", &self.discharge_p_idx) .field("discharge_h_idx", &self.discharge_h_idx) .field("suction_p_idx", &self.suction_p_idx) .field("suction_h_idx", &self.suction_h_idx) .finish() } } impl IsentropicCompressor { /// Creates a new isentropic compressor. /// /// # Arguments /// /// * `isentropic_efficiency` - η_is in (0,1], typically 0.70-0.85 /// * `t_cond_k` - Condensing saturation temperature [K] (sets discharge pressure target) /// * `t_evap_k` - Evaporating saturation temperature [K] (sets suction conditions) /// * `superheat_k` - Suction superheat above evaporating temperature [K] pub fn new(isentropic_efficiency: f64, t_cond_k: f64, t_evap_k: f64, superheat_k: f64) -> Self { Self { isentropic_efficiency, t_cond_k, t_evap_k, superheat_k, refrigerant_id: String::new(), fluid_backend: None, discharge_p_idx: None, discharge_h_idx: None, discharge_m_idx: None, suction_p_idx: None, suction_h_idx: None, suction_m_idx: None, same_branch_m: false, emergent_pressure: false, displacement_m3: None, speed_hz: None, volumetric_efficiency: VolumetricEfficiency::Constant(1.0), vsd_map: None, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), calib_indices: CalibIndices::default(), slide_valve: false, sst_target_k: None, liquid_injection: false, } } /// Attaches a refrigerant identifier for property lookups. pub fn with_refrigerant(mut self, refrigerant: &str) -> Self { self.refrigerant_id = refrigerant.to_string(); self } /// Attaches a fluid backend for property lookups. pub fn with_fluid_backend(mut self, backend: Arc) -> Self { self.fluid_backend = Some(backend); self } /// Enables the emergent-pressure mode with a volumetric displacement /// mass-flow closure. /// /// In this mode the compressor no longer pins the discharge pressure to /// `P_sat(t_cond_k)`; instead residual r0 closes the shared mass flow via /// `ṁ = ρ_suc · V_s · N · η_vol(P_dis/P_suc)`, so the condensing pressure /// emerges from the downstream condenser ↔ secondary balance. /// /// # Arguments /// /// * `displacement_m3` — swept volume per revolution [m³/rev] /// * `speed_hz` — rotational speed [rev/s] /// * `volumetric_efficiency` — volumetric-efficiency model pub fn with_displacement( mut self, displacement_m3: f64, speed_hz: f64, volumetric_efficiency: VolumetricEfficiency, ) -> Self { self.emergent_pressure = true; self.displacement_m3 = Some(displacement_m3); self.speed_hz = Some(speed_hz); self.volumetric_efficiency = volumetric_efficiency; self } /// Attaches a variable-speed-drive efficiency map (see [`VsdSpeedMap`]). /// /// When set together with a known rotational speed, the volumetric and /// isentropic efficiencies are corrected by the map's speed factor, so the /// compressor performance reacts to inverter speed the way a real VSD unit /// does. Has no effect when the speed is unknown. pub fn with_vsd_map(mut self, map: VsdSpeedMap) -> Self { self.vsd_map = Some(map); self } /// Sets the variable-speed-drive efficiency map (see [`with_vsd_map`]). /// /// [`with_vsd_map`]: Self::with_vsd_map pub fn set_vsd_map(&mut self, map: VsdSpeedMap) { self.vsd_map = Some(map); } /// Volumetric-efficiency speed-correction factor from the VSD map (1.0 when /// no map or no speed is configured). #[inline] fn vsd_volumetric_correction(&self) -> f64 { match (self.vsd_map, self.speed_hz) { (Some(map), Some(n)) => map.volumetric_correction(n), _ => 1.0, } } /// Effective isentropic efficiency including the VSD speed correction. #[inline] pub fn effective_isentropic_efficiency(&self) -> f64 { let factor = match (self.vsd_map, self.speed_hz) { (Some(map), Some(n)) => map.isentropic_correction(n), _ => 1.0, }; (self.isentropic_efficiency * factor).clamp(1e-3, 1.0) } /// Returns `true` when the emergent-pressure displacement closure is active /// and every prerequisite (backend, refrigerant, wired suction indices, and /// displacement parameters) is available. fn displacement_ready(&self) -> bool { self.emergent_pressure && self.fluid_backend.is_some() && !self.refrigerant_id.is_empty() && self.displacement_m3.is_some() && self.speed_hz.is_some() && self.suction_p_idx.is_some() && self.suction_h_idx.is_some() && self.discharge_p_idx.is_some() && self.discharge_h_idx.is_some() && (self.same_branch_m || (self.suction_m_idx.is_some() && self.discharge_m_idx.is_some())) } /// Enables screw-compressor slide-valve capacity control holding a target /// suction saturated temperature `sst_target_k` [K]. /// /// The slide position `σ` becomes a free actuator that scales the effective /// swept volume (`ṁ = σ · f_m · ṁ_calc`), and one equation /// `T_sat(P_suc) = SST_target` is closed by `σ`. Genuine inverse capacity /// control — the slide unloads until suction saturation reaches the setpoint. /// Only active in emergent-pressure mode (enable via [`with_displacement`]). /// /// [`with_displacement`]: Self::with_displacement pub fn with_slide_valve(mut self, sst_target_k: f64) -> Self { self.slide_valve = true; self.sst_target_k = Some(sst_target_k); self } /// Returns the slide-valve target suction saturated temperature [K], if set. pub fn sst_target_k(&self) -> Option { self.sst_target_k } /// Static config test: the slide-valve actuator is requested with all /// build-time prerequisites present. Keeps `n_equations` consistent before /// the actuator index is wired. fn slide_active(&self) -> bool { self.slide_valve && self.emergent_pressure && self.sst_target_k.is_some() } /// `true` when the slide-valve actuator is fully wired (config + resolved /// actuator state index) so its residual/Jacobian can act. fn slide_ready(&self) -> bool { self.slide_active() && self.calib_indices.actuator.is_some() } /// Effective slide-valve factor `σ` (1.0 when no active/ready slide valve). /// Read from the free-actuator state slot and clamped to a small positive /// floor to keep the displacement closure well-posed. fn slide_factor(&self, state: &StateSlice) -> f64 { match (self.slide_ready(), self.calib_indices.actuator) { (true, Some(i)) if i < state.len() => { let v = state[i]; if v.is_finite() && v > 0.0 { v } else { 1.0 } } _ => 1.0, } } /// Residual row index of the slide-valve equation (appended after the /// thermodynamic + optional mass-conservation rows). fn slide_row(&self) -> usize { if self.same_branch_m { 2 } else { 3 } } /// Suction saturated (evaporating) temperature `T_sat(P_suc)` [K]. fn evap_temperature(&self, p_suc_pa: f64) -> Result { let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::CalculationFailed("Compressor: no fluid backend".to_string()) })?; backend .property( FluidId::new(&self.refrigerant_id), Property::Temperature, FluidState::from_px( Pressure::from_pascals(p_suc_pa), entropyk_fluids::Quality::new(0.5), ), ) .map_err(|e| ComponentError::CalculationFailed(e.to_string())) } /// Enables a liquid-injection port that desuperheats the discharge gas. /// /// The injection ratio `φ_inj` becomes a *physical actuator* read from the /// generic `CalibIndices.actuator` slot. It lowers the effective discharge /// enthalpy toward saturated-liquid enthalpy at the discharge pressure: /// `h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis))`. No internal /// setpoint equation is emitted; a user `controls[]` loop closes the system /// (e.g. modulate `φ_inj` to hold the discharge gas temperature at a limit). /// Only active in emergent-pressure mode. pub fn with_liquid_injection(mut self) -> Self { self.liquid_injection = true; self } /// `true` when the liquid-injection port is configured (build-time flag). fn injection_active(&self) -> bool { self.liquid_injection && self.emergent_pressure } /// `true` when the injection actuator is fully wired (config + resolved /// actuator state index) so its desuperheat term can act. fn injection_ready(&self) -> bool { self.injection_active() && self.calib_indices.actuator.is_some() } /// Effective injection ratio `φ_inj ≥ 0` (0.0 when no active/ready port). /// Read from the generic actuator state slot, clamped to a non-negative floor. fn injection_factor(&self, state: &StateSlice) -> f64 { match (self.injection_ready(), self.calib_indices.actuator) { (true, Some(i)) if i < state.len() => { let v = state[i]; if v.is_finite() && v > 0.0 { v } else { 0.0 } } _ => 0.0, } } /// Saturated-liquid enthalpy `h_f(P_dis)` [J/kg] at the discharge pressure — /// the enthalpy of the injected liquid stream. fn liquid_enthalpy(&self, p_dis_pa: f64) -> Result { let backend = self.fluid_backend.as_ref().ok_or_else(|| { ComponentError::CalculationFailed("Compressor: no fluid backend".to_string()) })?; backend .property( FluidId::new(&self.refrigerant_id), Property::Enthalpy, FluidState::from_px( Pressure::from_pascals(p_dis_pa), entropyk_fluids::Quality::new(0.0), ), ) .map_err(|e| ComponentError::CalculationFailed(e.to_string())) } /// Discharge gas temperature (DGT) `T(P_dis, H_dis)` [K] from the solved /// discharge edge — the measurable output a liquid-injection control loop /// regulates. fn discharge_gas_temperature(&self, state: &StateSlice) -> Option { let backend = self.fluid_backend.as_ref()?; let dis_p = self.discharge_p_idx?; let dis_h = self.discharge_h_idx?; if dis_p >= state.len() || dis_h >= state.len() { return None; } let p_dis = state[dis_p]; let h_dis = state[dis_h]; if !(p_dis > 1_000.0 && h_dis > 50_000.0) { return None; } backend .property( FluidId::new(&self.refrigerant_id), Property::Temperature, FluidState::PressureEnthalpy( Pressure::from_pascals(p_dis), Enthalpy::from_joules_per_kg(h_dis), ), ) .ok() } /// Swept mass flow `ṁ = ρ_suc · V_s · N · η_vol(P_dis/P_suc)` [kg/s]. fn swept_mass_flow( &self, backend: &dyn FluidBackend, fluid: FluidId, p_suc_pa: f64, h_suc_jkg: f64, p_dis_pa: f64, ) -> Result { let rho_suc = backend .property( fluid, Property::Density, FluidState::PressureEnthalpy( Pressure::from_pascals(p_suc_pa), Enthalpy::from_joules_per_kg(h_suc_jkg), ), ) .map_err(|e| ComponentError::CalculationFailed(format!("rho_suc: {}", e)))?; let v_s = self.displacement_m3.ok_or_else(|| { ComponentError::InvalidState( "IsentropicCompressor swept-flow closure requires displacement_m3".to_string(), ) })?; let n = self.speed_hz.ok_or_else(|| { ComponentError::InvalidState( "IsentropicCompressor swept-flow closure requires speed_hz".to_string(), ) })?; let pr = if p_suc_pa > 1.0 { p_dis_pa / p_suc_pa } else { 1.0 }; let eta_vol = self.volumetric_efficiency.eval(pr) * self.vsd_volumetric_correction(); Ok(rho_suc * v_s * n * eta_vol) } /// Returns the inverse-control mass-flow multiplier `f_m` read from the /// solver state when this compressor is used as an actuator, or `1.0` when /// no control variable is linked. Non-finite or non-positive values fall /// back to `1.0` to keep the closure well-posed during early iterations. fn control_f_m(&self, state: &StateSlice) -> f64 { match self.calib_indices.z_flow { Some(i) if i < state.len() => { let v = state[i]; if v.is_finite() && v > 0.0 { v } else { 1.0 } } _ => 1.0, } } /// Returns the isentropic efficiency [-]. pub fn isentropic_efficiency(&self) -> f64 { self.isentropic_efficiency } /// Returns the design-point condensing temperature [K]. pub fn t_cond_k(&self) -> f64 { self.t_cond_k } /// Returns the design-point evaporating temperature [K]. pub fn t_evap_k(&self) -> f64 { self.t_evap_k } /// Returns the design-point suction superheat [K]. pub fn superheat_k(&self) -> f64 { self.superheat_k } /// True isentropic compression using actual suction state (P_suc, H_suc) from the solver. /// /// Uses CoolProp's (P, H) → entropy and (P, S) → enthalpy to compute the real isentropic /// path, then applies isentropic efficiency. fn compute_h_dis_from_state( &self, backend: &dyn FluidBackend, fluid: FluidId, p_suc_pa: f64, h_suc_jkg: f64, p_dis_pa: f64, ) -> Result { let s_suc = backend .property( fluid.clone(), Property::Entropy, FluidState::PressureEnthalpy( Pressure::from_pascals(p_suc_pa), Enthalpy::from_joules_per_kg(h_suc_jkg), ), ) .map_err(|e| ComponentError::CalculationFailed(format!("S_suc: {}", e)))?; let h_dis_isen = backend .property( fluid, Property::Enthalpy, FluidState::PressureEntropy(Pressure::from_pascals(p_dis_pa), Entropy(s_suc)), ) .map_err(|e| ComponentError::CalculationFailed(format!("H_dis_isen: {}", e)))?; Ok(h_suc_jkg + (h_dis_isen - h_suc_jkg) / self.effective_isentropic_efficiency()) } } impl Component for IsentropicCompressor { fn set_system_context( &mut self, _state_offset: usize, external_edge_state_indices: &[(usize, usize, usize)], ) { // incoming[0] = evap:outlet → comp:inlet (suction) — read for actual compression // outgoing[0] = comp:outlet → cond:inlet (discharge) — constrained by residuals // 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_p_idx = Some(external_edge_state_indices[0].1); 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_p_idx = Some(external_edge_state_indices[1].1); self.discharge_h_idx = Some(external_edge_state_indices[1].2); } // CM1.4: detect same-branch topology. 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> { // Emergent-pressure path: close the shared mass flow via the volumetric // displacement model and let the discharge pressure float (set by the // downstream condenser outlet closure). r0 no longer pins P_dis. if self.displacement_ready() { if residuals.len() < self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } let backend = self.fluid_backend.as_ref().unwrap(); let fluid = FluidId::new(&self.refrigerant_id); let suc_p = self.suction_p_idx.unwrap(); let suc_h = self.suction_h_idx.unwrap(); let dis_p = self.discharge_p_idx.unwrap(); let dis_h = self.discharge_h_idx.unwrap(); let m_idx = self.suction_m_idx.or(self.discharge_m_idx).ok_or_else(|| { ComponentError::InvalidState( "IsentropicCompressor displacement closure requires a live mass-flow index" .to_string(), ) })?; let p_suc = state[suc_p]; let h_suc = state[suc_h]; let p_dis = state[dis_p]; if p_suc > 1_000.0 && h_suc > 50_000.0 && p_dis > 1_000.0 { let m_calc = self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis)?; let h_dis = self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?; // Inverse-control actuator: scale the swept mass flow by the // linked control variable f_m (1.0 when no control is attached) // and the slide-valve position σ (1.0 when no slide valve). let f_m = self.control_f_m(state); let sigma = self.slide_factor(state); // r0: mass-flow closure ṁ_state − σ·f_m·ṁ_calc = 0 (pins the branch ṁ). residuals[0] = state[m_idx] - sigma * f_m * m_calc; // r1: actual isentropic compression to the (floating) discharge // pressure, optionally desuperheated by liquid injection: // h_dis,eff = h_dis − φ_inj·(h_dis − h_liq_sat(P_dis)). let h_dis_eff = if self.injection_ready() { let phi = self.injection_factor(state); let h_liq = self.liquid_enthalpy(p_dis)?; h_dis - phi * (h_dis - h_liq) } else { h_dis }; residuals[1] = state[dis_h] - h_dis_eff; if !self.same_branch_m { residuals[2] = match (self.suction_m_idx, self.discharge_m_idx) { (Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc], _ => { return Err(ComponentError::InvalidState( "IsentropicCompressor mass conservation requires live suction and discharge mass-flow indices".to_string(), )); } }; } // Slide-valve equation: T_sat(P_suc) = SST_target, closed by σ. if self.slide_active() { residuals[self.slide_row()] = if self.slide_ready() { self.evap_temperature(p_suc)? - self.sst_target_k.ok_or_else(|| { ComponentError::InvalidState( "IsentropicCompressor slide valve requires an SST target" .to_string(), ) })? } else { return Err(ComponentError::InvalidState( "IsentropicCompressor slide valve requires a live actuator index" .to_string(), )); }; } return Ok(()); } return Err(ComponentError::InvalidState( "IsentropicCompressor displacement closure requires live physical suction and discharge states".to_string(), )); } if let (Some(backend), Some(dis_p), Some(dis_h)) = ( &self.fluid_backend, self.discharge_p_idx, self.discharge_h_idx, ) { if !self.refrigerant_id.is_empty() { if residuals.len() < self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } // r0: drive discharge pressure to condensing saturation pressure (unchanged) let p_cond_sat = backend .saturation_pressure_t(FluidId::new(&self.refrigerant_id), self.t_cond_k) .map_err(|e| ComponentError::CalculationFailed(format!("P_cond: {}", e)))?; // r1: true isentropic compression from actual suction state when available let h_dis = if let (Some(suc_p), Some(suc_h)) = (self.suction_p_idx, self.suction_h_idx) { let p_suc = state[suc_p]; let h_suc = state[suc_h]; if p_suc > 1_000.0 && h_suc > 50_000.0 { self.compute_h_dis_from_state( backend.as_ref(), FluidId::new(&self.refrigerant_id), p_suc, h_suc, p_cond_sat, )? } else { return Err(ComponentError::InvalidState( "IsentropicCompressor requires physical live suction pressure/enthalpy" .to_string(), )); } } else { return Err(ComponentError::InvalidState( "IsentropicCompressor requires live suction pressure/enthalpy indices" .to_string(), )); }; residuals[0] = state[dis_p] - p_cond_sat; residuals[1] = state[dis_h] - h_dis; // r2 = ṁ_discharge − ṁ_suction = 0 (mass conservation, CM1.3) // CM1.4: skip when same_branch_m — trivially zero. if !self.same_branch_m { residuals[2] = match (self.suction_m_idx, self.discharge_m_idx) { (Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc], _ => { return Err(ComponentError::InvalidState( "IsentropicCompressor mass conservation requires live suction and discharge mass-flow indices".to_string(), )); } }; } if self.slide_active() { residuals[self.slide_row()] = match (self.slide_ready(), self.calib_indices.actuator) { (true, Some(i)) if i < state.len() => state[i] - 1.0, _ => { return Err(ComponentError::InvalidState( "IsentropicCompressor slide valve requires a live actuator index" .to_string(), )); } }; } return Ok(()); } } Err(ComponentError::InvalidState( "IsentropicCompressor requires a refrigerant backend and live discharge pressure/enthalpy indices".to_string(), )) } fn jacobian_entries( &self, state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Emergent-pressure path: Jacobian of the displacement mass-flow closure // (r0) and the floating-pressure isentropic compression (r1). if self.displacement_ready() { let backend = self.fluid_backend.as_ref().unwrap(); let fluid = FluidId::new(&self.refrigerant_id); let suc_p = self.suction_p_idx.unwrap(); let suc_h = self.suction_h_idx.unwrap(); let dis_p = self.discharge_p_idx.unwrap(); let dis_h = self.discharge_h_idx.unwrap(); let m_idx = self.suction_m_idx.or(self.discharge_m_idx).ok_or_else(|| { ComponentError::InvalidState( "IsentropicCompressor displacement Jacobian requires a live mass-flow index" .to_string(), ) })?; let p_suc = state[suc_p]; let h_suc = state[suc_h]; let p_dis = state[dis_p]; if p_suc > 1_000.0 && h_suc > 50_000.0 && p_dis > 1_000.0 { // r0 = ṁ_state − σ·f_m·ṁ_calc(P_suc, H_suc, P_dis) let f_m = self.control_f_m(state); let sigma = self.slide_factor(state); let fm_sigma = f_m * sigma; jacobian.add_entry(0, m_idx, 1.0); let dpp = p_suc * 1e-4 + 100.0; let dph = h_suc * 1e-4 + 10.0; let dpd = p_dis * 1e-4 + 100.0; let mf = |ps: f64, hs: f64, pd: f64| { self.swept_mass_flow(backend.as_ref(), fluid.clone(), ps, hs, pd) }; if let (Ok(a), Ok(b)) = (mf(p_suc + dpp, h_suc, p_dis), mf(p_suc - dpp, h_suc, p_dis)) { jacobian.add_entry(0, suc_p, -fm_sigma * (a - b) / (2.0 * dpp)); } if let (Ok(a), Ok(b)) = (mf(p_suc, h_suc + dph, p_dis), mf(p_suc, h_suc - dph, p_dis)) { jacobian.add_entry(0, suc_h, -fm_sigma * (a - b) / (2.0 * dph)); } if let (Ok(a), Ok(b)) = (mf(p_suc, h_suc, p_dis + dpd), mf(p_suc, h_suc, p_dis - dpd)) { jacobian.add_entry(0, dis_p, -fm_sigma * (a - b) / (2.0 * dpd)); } // ∂r0/∂f_m = −σ·ṁ_calc and ∂r0/∂σ = −f_m·ṁ_calc — couple the // control/slide unknowns into the closure so the Newton system // stays non-singular. if let Ok(m_calc) = self.swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis) { if let Some(z_flow_idx) = self.calib_indices.z_flow { jacobian.add_entry(0, z_flow_idx, -sigma * m_calc); } if self.slide_ready() { if let Some(sigma_idx) = self.calib_indices.actuator { jacobian.add_entry(0, sigma_idx, -f_m * m_calc); } } } // r1 = H_dis_state − h_dis,eff(P_suc, H_suc, P_dis; φ_inj). // When liquid injection is active, h_dis,eff subtracts the // desuperheating term; the closure captures its P_dis dependence. jacobian.add_entry(1, dis_h, 1.0); let phi_inj = self.injection_factor(state); let inj_ready = self.injection_ready(); let hd = |ps: f64, hs: f64, pd: f64| -> Result { let h = self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)?; if inj_ready { let h_liq = self.liquid_enthalpy(pd)?; Ok(h - phi_inj * (h - h_liq)) } else { Ok(h) } }; if let (Ok(a), Ok(b)) = (hd(p_suc + dpp, h_suc, p_dis), hd(p_suc - dpp, h_suc, p_dis)) { jacobian.add_entry(1, suc_p, -(a - b) / (2.0 * dpp)); } if let (Ok(a), Ok(b)) = (hd(p_suc, h_suc + dph, p_dis), hd(p_suc, h_suc - dph, p_dis)) { jacobian.add_entry(1, suc_h, -(a - b) / (2.0 * dph)); } if let (Ok(a), Ok(b)) = (hd(p_suc, h_suc, p_dis + dpd), hd(p_suc, h_suc, p_dis - dpd)) { jacobian.add_entry(1, dis_p, -(a - b) / (2.0 * dpd)); } // ∂r1/∂φ_inj = +(h_dis − h_liq): the injection actuator couples // into the energy balance so a controls[] loop has a plant to act // on (higher injection ⇒ lower discharge enthalpy ⇒ lower DGT). if inj_ready { if let Some(inj_idx) = self.calib_indices.actuator { let h_dis = self.compute_h_dis_from_state( backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis, ); let h_liq = self.liquid_enthalpy(p_dis); if let (Ok(h_dis), Ok(h_liq)) = (h_dis, h_liq) { jacobian.add_entry(1, inj_idx, h_dis - h_liq); } } } if !self.same_branch_m { if let (Some(m_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) { jacobian.add_entry(2, m_dis, 1.0); jacobian.add_entry(2, m_suc, -1.0); } } // Slide-valve row: r_slide = T_sat(P_suc) − SST_target. // ∂/∂P_suc = dT_sat/dP_suc via central finite difference. if self.slide_ready() { let tp = self.evap_temperature(p_suc + dpp); let tm = self.evap_temperature((p_suc - dpp).max(1.0)); if let (Ok(tp), Ok(tm)) = (tp, tm) { jacobian.add_entry(self.slide_row(), suc_p, (tp - tm) / (2.0 * dpp)); } } return Ok(()); } } if let (Some(backend), Some(dis_p), Some(dis_h)) = ( &self.fluid_backend, self.discharge_p_idx, self.discharge_h_idx, ) { if !self.refrigerant_id.is_empty() { // r0: P_dis - P_cond_sat(T_cond_fixed) — constant target → diagonal only jacobian.add_entry(0, dis_p, 1.0); // r1: H_dis - h_dis(P_suc, H_suc) jacobian.add_entry(1, dis_h, 1.0); if let (Some(suc_p), Some(suc_h)) = (self.suction_p_idx, self.suction_h_idx) { let p_suc = state[suc_p]; let h_suc = state[suc_h]; if p_suc > 1_000.0 && h_suc > 50_000.0 { // Numerical ∂h_dis/∂P_suc let dp = p_suc * 1e-4 + 100.0; let p_cond_sat = backend .saturation_pressure_t( FluidId::new(&self.refrigerant_id), self.t_cond_k, ) .map_err(|e| { ComponentError::CalculationFailed(format!("P_cond: {}", e)) })?; let fluid = FluidId::new(&self.refrigerant_id); let hp = self.compute_h_dis_from_state( backend.as_ref(), fluid.clone(), p_suc + dp, h_suc, p_cond_sat, ); let hm = self.compute_h_dis_from_state( backend.as_ref(), fluid.clone(), p_suc - dp, h_suc, p_cond_sat, ); if let (Ok(hp), Ok(hm)) = (hp, hm) { jacobian.add_entry(1, suc_p, -(hp - hm) / (2.0 * dp)); } // Numerical ∂h_dis/∂H_suc let dh = h_suc * 1e-4 + 10.0; let hp = self.compute_h_dis_from_state( backend.as_ref(), fluid.clone(), p_suc, h_suc + dh, p_cond_sat, ); let hm = self.compute_h_dis_from_state( backend.as_ref(), fluid, p_suc, h_suc - dh, p_cond_sat, ); if let (Ok(hp), Ok(hm)) = (hp, hm) { jacobian.add_entry(1, suc_h, -(hp - hm) / (2.0 * dh)); } } } // r2 = ṁ_discharge − ṁ_suction → exact analytic entries (CM1.3) // CM1.4: omit when same_branch_m (dropped from n_equations). if !self.same_branch_m { if let (Some(m_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) { jacobian.add_entry(2, m_dis, 1.0); jacobian.add_entry(2, m_suc, -1.0); } } // Transient slide-valve row: r_slide = σ − 1, ∂/∂σ = 1. if self.slide_ready() { if let Some(sigma_idx) = self.calib_indices.actuator { jacobian.add_entry(self.slide_row(), sigma_idx, 1.0); } } return Ok(()); } } Ok(()) } fn set_fluid_backend_from_builder(&mut self, backend: Arc) { self.fluid_backend = Some(backend); } fn set_calib_indices(&mut self, indices: CalibIndices) { self.calib_indices = indices; } fn n_equations(&self) -> usize { // CM1.4: drop conservation equation when same-branch. let core = if self.same_branch_m { 2 } else { 3 }; // +1 for the slide-valve equation (T_sat(P_suc) = SST_target) closed by // the slide-position free actuator. Static config keeps DoF consistent. core + if self.slide_active() { 1 } else { 0 } } fn equation_roles(&self) -> Vec { // Same-branch: energy + volumetric ṁ closure. Distinct-branch adds mass eq. let mut roles = vec![crate::EquationRole::EnergyBalance { stream: "refrigerant", }]; if !self.same_branch_m { roles.push(crate::EquationRole::MassConservation { stream: "refrigerant", }); } roles.push(crate::EquationRole::BoundaryDirichlet { quantity: "m" }); if self.slide_active() { roles.push(crate::EquationRole::ActuatorClosure { name: "slide_valve", }); } roles } fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> { // A stopped or bypassed compressor exchanges no energy. if matches!( self.operational_state, OperationalState::Off | OperationalState::Bypass ) { return Some((Power::from_watts(0.0), Power::from_watts(0.0))); } // Shaft work is evaluated from the *solved* cycle state: the volumetric // closure has already fixed the branch mass flow ṁ and the isentropic // compression has fixed the discharge enthalpy, so at convergence // W = ṁ·(h_dis − h_suc) is the genuine electrical/shaft power draw. let suc_h = self.suction_h_idx?; let dis_h = self.discharge_h_idx?; let m_idx = self.suction_m_idx.or(self.discharge_m_idx)?; if suc_h >= state.len() || dis_h >= state.len() || m_idx >= state.len() { return None; } let h_suc = state[suc_h]; let h_dis = state[dis_h]; let m_dot = state[m_idx]; if !(h_suc.is_finite() && h_dis.is_finite() && m_dot.is_finite()) { return None; } // Electrical/shaft power. With liquid injection the discharge EDGE enthalpy // has been desuperheated (h_dis,eff), but the motor still performs the full // compression work. Recover the un-desuperheated compression enthalpy so // the reported power and COP stay physical (the injection cooling happens // downstream of the shaft and must not deflate the electrical input). let h_dis_work = if self.injection_ready() { match ( self.suction_p_idx, self.discharge_p_idx, self.fluid_backend.as_ref(), ) { (Some(sp), Some(dp), Some(backend)) if sp < state.len() && dp < state.len() => { let fluid = FluidId::new(&self.refrigerant_id); self.compute_h_dis_from_state( backend.as_ref(), fluid, state[sp], h_suc, state[dp], ) .unwrap_or(h_dis) } _ => h_dis, } } else { h_dis }; // The compressor is modelled as adiabatic (Q = 0); all the enthalpy rise // is shaft work done ON the fluid. Work done ON the component is reported // negative, matching the ΣQ − ΣW + Σṁh = 0 balance convention. let work_w = m_dot * (h_dis_work - h_suc); Some((Power::from_watts(0.0), Power::from_watts(-work_w))) } fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option { // Expose the discharge gas temperature (DGT) so a controls[] loop can // regulate liquid injection against a maximum-DGT limit (Carrier CL: // LI_ON when comp_out.T > DGT_max). Only meaningful in emergent mode. match kind { MeasuredOutput::Temperature => self.discharge_gas_temperature(state), _ => None, } } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn signature(&self) -> String { format!( "IsentropicCompressor(fluid={}, eta_is={:.2}, t_evap={:.1}K, t_cond={:.1}K)", self.refrigerant_id, self.isentropic_efficiency, self.t_evap_k, self.t_cond_k ) } fn to_params(&self) -> crate::ComponentParams { crate::ComponentParams::new("IsentropicCompressor") .with_param("isentropic_efficiency", self.isentropic_efficiency) .with_param("t_cond_k", self.t_cond_k) .with_param("t_evap_k", self.t_evap_k) .with_param("superheat_k", self.superheat_k) .with_param("fluid", self.refrigerant_id.as_str()) } } impl StateManageable for IsentropicCompressor { fn state(&self) -> OperationalState { self.operational_state } fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { self.operational_state = state; Ok(()) } fn can_transition_to(&self, _target: OperationalState) -> bool { true } fn circuit_id(&self) -> &CircuitId { &self.circuit_id } fn set_circuit_id(&mut self, circuit_id: CircuitId) { self.circuit_id = circuit_id; } } #[cfg(test)] mod tests { use super::*; #[test] fn test_isentropic_compressor_creation() { let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0); assert_eq!(comp.isentropic_efficiency(), 0.75); assert_eq!(comp.t_cond_k(), 323.15); assert_eq!(comp.t_evap_k(), 275.15); assert_eq!(comp.superheat_k(), 5.0); // CM1.3: 2 thermo + 1 mass-flow conservation = 3 assert_eq!(comp.n_equations(), 3); } #[test] fn test_builder_pattern() { let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0).with_refrigerant("R410A"); assert_eq!(comp.refrigerant_id, "R410A"); } #[test] fn test_volumetric_efficiency_models() { // Constant model is clamped to [0,1]. assert_eq!(VolumetricEfficiency::Constant(0.9).eval(3.0), 0.9); assert_eq!(VolumetricEfficiency::Constant(1.5).eval(3.0), 1.0); // Clearance model decreases with pressure ratio. let m = VolumetricEfficiency::Clearance { clearance: 0.05, polytropic_n: 1.1, }; let eta1 = m.eval(2.0); let eta2 = m.eval(5.0); assert!( eta1 > eta2, "η_vol must fall as Pr grows: {} !> {}", eta1, eta2 ); assert!(eta2 > 0.0 && eta1 <= 1.0); } #[test] fn test_control_f_m_reads_state_with_safe_fallbacks() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); // No control attached → neutral multiplier. let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.7]; assert_eq!(comp.control_f_m(&state), 1.0); // Control attached → reads f_m from the mapped state slot. comp.set_calib_indices(CalibIndices { z_flow: Some(5), ..CalibIndices::default() }); assert!((comp.control_f_m(&state) - 0.7).abs() < 1e-12); // Non-positive / non-finite / out-of-range → safe fallback to 1.0. let bad = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, -1.0]; assert_eq!(comp.control_f_m(&bad), 1.0); let short = vec![0.2, 350_000.0]; assert_eq!(comp.control_f_m(&short), 1.0); } #[test] fn test_control_f_m_emits_jacobian_column() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_calib_indices(CalibIndices { z_flow: Some(5), ..CalibIndices::default() }); let fluid = FluidId::new("R134a"); let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0); let m_calc = comp .swept_mass_flow(backend.as_ref(), fluid, p_suc, h_suc, p_dis) .unwrap(); let state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 1.0]; let mut jac = JacobianBuilder::new(); comp.jacobian_entries(&state, &mut jac).unwrap(); // ∂r0/∂f_m must equal −ṁ_calc so the control couples into the Newton system. let entry = jac .entries() .iter() .find(|(row, col, _)| *row == 0 && *col == 5) .expect("compressor must emit a ∂r0/∂f_m Jacobian column"); assert!( (entry.2 + m_calc).abs() < 1e-6, "∂r0/∂f_m={} expected {}", entry.2, -m_calc ); } #[test] fn test_emergent_mass_flow_closure_reacts_to_speed() { use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let fluid = FluidId::new("R134a"); let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0); let make = |speed_hz: f64| { let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_displacement(3.0e-5, speed_hz, VolumetricEfficiency::Constant(0.95)); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp }; let comp_slow = make(25.0); let comp_fast = make(50.0); assert!(comp_slow.displacement_ready()); let m_slow = comp_slow .swept_mass_flow(backend.as_ref(), fluid.clone(), p_suc, h_suc, p_dis) .unwrap(); let m_fast = comp_fast .swept_mass_flow(backend.as_ref(), fluid, p_suc, h_suc, p_dis) .unwrap(); // Doubling the speed doubles the swept mass flow — the displacement // closure genuinely reacts to the compressor, closing the branch ṁ. assert!(m_slow > 0.0); assert!( (m_fast - 2.0 * m_slow).abs() < 1e-9, "ṁ must scale linearly with speed ({} vs {})", m_fast, m_slow ); } // ---- Slide-valve actuator (arch-6) --------------------------------------- /// A slide-valve compressor emits one extra equation (T_sat(P_suc)=SST_target), /// closed by the slide-position free actuator. The count must be static so DoF /// stays balanced before the actuator index is wired. #[test] fn test_slide_valve_adds_one_equation() { use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_slide_valve(278.15); // Same-branch topology: suction (m=0,p=1,h=2), discharge (m=0,p=3,h=4). comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); // 2 thermo (same-branch) + 1 slide = 3. assert_eq!(comp.n_equations(), 3); assert_eq!(comp.slide_row(), 2); // Static: active before the actuator index is wired, not yet ready. assert!(comp.slide_active()); assert!(!comp.slide_ready()); } /// The slide residual `T_sat(P_suc) − SST_target` reacts to the setpoint, and /// the mass-flow closure r0 reacts to the slide position σ. Genuine unloading /// physics — no fixed design point. #[test] fn test_slide_valve_residual_reacts_to_target_and_sigma() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let sst_target = 278.15; let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend.clone()) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_slide_valve(sst_target); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_calib_indices(CalibIndices { actuator: Some(5), ..CalibIndices::default() }); assert!(comp.slide_ready()); let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0); // State: [ṁ, P_suc, H_suc, P_dis, H_dis, σ]. let mut state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 1.0]; let t_evap = comp.evap_temperature(p_suc).unwrap(); let mut r = vec![0.0; comp.n_equations()]; comp.compute_residuals(&state, &mut r).unwrap(); // Slide residual = T_sat(P_suc) − SST_target. assert!( (r[comp.slide_row()] - (t_evap - sst_target)).abs() < 1e-6, "slide residual must be T_sat − SST_target: {}", r[comp.slide_row()] ); // r0 = ṁ − σ·f_m·ṁ_calc: reducing σ raises r0 (less mass flow drawn). let r0_full = r[0]; state[5] = 0.5; comp.compute_residuals(&state, &mut r).unwrap(); assert!( r[0] > r0_full + 1e-6, "unloaded slide (smaller σ) ⇒ larger mass-flow residual: {} !> {}", r[0], r0_full ); } /// Analytic Jacobian of the slide-valve emergent path matches central finite /// differences, including the ∂r0/∂σ coupling and the ∂r_slide/∂P_suc entry. #[test] fn test_slide_valve_jacobian_matches_finite_difference() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_slide_valve(278.15); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_calib_indices(CalibIndices { actuator: Some(5), ..CalibIndices::default() }); let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.8]; let n_eq = comp.n_equations(); let n_var = state.len(); let mut jac = JacobianBuilder::new(); comp.jacobian_entries(&state, &mut jac).unwrap(); let mut analytic = vec![vec![0.0; n_var]; n_eq]; for &(row, col, val) in jac.entries() { if row < n_eq && col < n_var { analytic[row][col] += val; } } for col in 0..n_var { let h = (state[col].abs() * 1e-6).max(1e-3); let mut sp = state.clone(); let mut sm = state.clone(); sp[col] += h; sm[col] -= h; let mut rp = vec![0.0; n_eq]; let mut rm = vec![0.0; n_eq]; comp.compute_residuals(&sp, &mut rp).unwrap(); comp.compute_residuals(&sm, &mut rm).unwrap(); for row in 0..n_eq { let fd = (rp[row] - rm[row]) / (2.0 * h); let an = analytic[row][col]; let scale = 1.0 + fd.abs().max(an.abs()); assert!( (fd - an).abs() / scale < 1e-4, "J[{}][{}]: analytic {} vs FD {}", row, col, an, fd ); } } } // ---- Liquid-injection actuator (arch-6) ---------------------------------- /// Liquid injection is a *factor* (like f_m), not a setpoint equation: it must /// NOT change the compressor equation count. The closing equation is provided /// by the user `controls[]` loop, keeping DoF balanced. #[test] fn test_liquid_injection_adds_no_equation() { use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_liquid_injection(); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); // Same-branch topology ⇒ 2 thermo equations, injection adds none. assert_eq!(comp.n_equations(), 2); assert!(comp.injection_active()); // Not ready until the actuator slot is wired. assert!(!comp.injection_ready()); } /// Raising the injection ratio φ_inj desuperheats the discharge gas, lowering /// the effective discharge enthalpy in r1 (genuine energy coupling) and the /// measured discharge gas temperature (DGT) exposed to control loops. #[test] fn test_liquid_injection_desuperheats_discharge() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_liquid_injection(); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_calib_indices(CalibIndices { actuator: Some(5), ..CalibIndices::default() }); assert!(comp.injection_ready()); let (p_suc, h_suc, p_dis) = (350_000.0, 410_000.0, 1_200_000.0); // State: [ṁ, P_suc, H_suc, P_dis, H_dis, φ_inj]. let mut state = vec![0.2, p_suc, h_suc, p_dis, 440_000.0, 0.0]; let mut r = vec![0.0; comp.n_equations()]; comp.compute_residuals(&state, &mut r).unwrap(); let r1_no_inj = r[1]; // Non-zero injection ⇒ smaller effective h_dis ⇒ larger r1 = H_dis − h_dis,eff. state[5] = 0.2; comp.compute_residuals(&state, &mut r).unwrap(); assert!( r[1] > r1_no_inj + 1.0, "injection must desuperheat (raise r1): {} !> {}", r[1], r1_no_inj ); // DGT is exposed via measure_output(Temperature) for control loops. let dgt = comp.measure_output(MeasuredOutput::Temperature, &state); assert!( dgt.is_some(), "compressor must expose discharge gas temperature" ); } /// Analytic Jacobian of the liquid-injection emergent path matches central /// finite differences, including the ∂r1/∂φ_inj = (h_dis − h_liq) coupling. #[test] fn test_liquid_injection_jacobian_matches_finite_difference() { use entropyk_core::CalibIndices; use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)) .with_liquid_injection(); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_calib_indices(CalibIndices { actuator: Some(5), ..CalibIndices::default() }); let state = vec![0.2, 350_000.0, 410_000.0, 1_200_000.0, 440_000.0, 0.15]; let n_eq = comp.n_equations(); let n_var = state.len(); let mut jac = JacobianBuilder::new(); comp.jacobian_entries(&state, &mut jac).unwrap(); let mut analytic = vec![vec![0.0; n_var]; n_eq]; for &(row, col, val) in jac.entries() { if row < n_eq && col < n_var { analytic[row][col] += val; } } for col in 0..n_var { let h = (state[col].abs() * 1e-6).max(1e-3); let mut sp = state.clone(); let mut sm = state.clone(); sp[col] += h; sm[col] -= h; let mut rp = vec![0.0; n_eq]; let mut rm = vec![0.0; n_eq]; comp.compute_residuals(&sp, &mut rp).unwrap(); comp.compute_residuals(&sm, &mut rm).unwrap(); for row in 0..n_eq { let fd = (rp[row] - rm[row]) / (2.0 * h); let an = analytic[row][col]; let scale = 1.0 + fd.abs().max(an.abs()); assert!( (fd - an).abs() / scale < 1e-4, "J[{}][{}]: analytic {} vs FD {}", row, col, an, fd ); } } } #[test] fn test_energy_transfers_reports_shaft_work_from_state() { use entropyk_fluids::TestBackend; let backend = Arc::new(TestBackend::new()); let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_refrigerant("R134a") .with_fluid_backend(backend) .with_displacement(3.0e-5, 50.0, VolumetricEfficiency::Constant(0.95)); // Same-branch topology: suction (m=0,p=1,h=2), discharge (m=0,p=3,h=4). comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); // Solved cycle state: ṁ = 0.20 kg/s, h_suc = 410 kJ/kg, h_dis = 440 kJ/kg. let mut state = vec![0.0; 5]; state[0] = 0.20; // ṁ state[2] = 410_000.0; // h_suc state[4] = 440_000.0; // h_dis let (heat, work) = comp .energy_transfers(&state) .expect("compressor reports energy"); // Adiabatic: no heat exchange. assert!(heat.to_watts().abs() < 1e-9); // Work is done ON the compressor, so it is negative and equals −ṁ·Δh. let expected = -0.20 * (440_000.0 - 410_000.0); assert!( (work.to_watts() - expected).abs() < 1e-6, "shaft work {} != {}", work.to_watts(), expected ); assert!(work.to_watts() < 0.0, "compressor consumes work"); } #[test] fn test_energy_transfers_off_compressor_is_zero() { use crate::state_machine::StateManageable; let mut comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0); comp.set_system_context(0, &[(0, 1, 2), (0, 3, 4)]); comp.set_state(OperationalState::Off).unwrap(); let state = vec![0.20, 0.0, 410_000.0, 0.0, 440_000.0]; let (heat, work) = comp .energy_transfers(&state) .expect("off compressor reports zero"); assert!(heat.to_watts().abs() < 1e-12); assert!(work.to_watts().abs() < 1e-12); } #[test] fn test_vsd_map_identity_is_noop() { // Identity map must leave both efficiencies unchanged at any speed. let map = VsdSpeedMap::identity(50.0); assert!((map.volumetric_correction(25.0) - 1.0).abs() < 1e-12); assert!((map.isentropic_correction(80.0) - 1.0).abs() < 1e-12); let comp = IsentropicCompressor::new(0.75, 323.15, 275.15, 5.0) .with_displacement(3.0e-5, 30.0, VolumetricEfficiency::Constant(0.95)) .with_vsd_map(VsdSpeedMap::identity(50.0)); assert!((comp.effective_isentropic_efficiency() - 0.75).abs() < 1e-12); } #[test] fn test_vsd_map_corrects_efficiencies_with_speed() { // Concave map peaking at the reference speed: f(r) = -0.5 + 3r - 1.5r², // which gives f(1)=1.0 and falls off below/above the rated speed. let coeffs = [-0.5, 3.0, -1.5]; let map = VsdSpeedMap::new(50.0, coeffs, coeffs); let at_ref = map.isentropic_correction(50.0); let at_low = map.isentropic_correction(25.0); let at_high = map.isentropic_correction(75.0); assert!((at_ref - 1.0).abs() < 1e-9, "peak at rated speed: {at_ref}"); assert!(at_low < at_ref, "low-speed penalty: {at_low} !< {at_ref}"); assert!( at_high < at_ref, "high-speed penalty: {at_high} !< {at_ref}" ); // Effective isentropic efficiency drops away from the rated speed. let make = |speed: f64| { IsentropicCompressor::new(0.80, 323.15, 275.15, 5.0) .with_displacement(3.0e-5, speed, VolumetricEfficiency::Constant(0.95)) .with_vsd_map(map) }; let eff_ref = make(50.0).effective_isentropic_efficiency(); let eff_low = make(25.0).effective_isentropic_efficiency(); assert!((eff_ref - 0.80).abs() < 1e-9); assert!(eff_low < eff_ref); } #[test] fn test_vsd_correction_is_clamped() { // Extreme coefficients must stay within the physical band [0.1, 1.2]. let map = VsdSpeedMap::new(50.0, [100.0, 0.0, 0.0], [-100.0, 0.0, 0.0]); assert!((map.volumetric_correction(50.0) - 1.2).abs() < 1e-12); assert!((map.isentropic_correction(50.0) - 0.1).abs() < 1e-12); } }