Files
Entropyk/crates/components/src/isentropic_compressor.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

1711 lines
71 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Arc<dyn FluidBackend>>,
/// State-vector index: discharge pressure (outgoing edge — constrained by this component)
discharge_p_idx: Option<usize>,
/// State-vector index: discharge enthalpy (outgoing edge — constrained by this component)
discharge_h_idx: Option<usize>,
/// State-vector index: discharge mass flow (outgoing edge, CM1.3)
discharge_m_idx: Option<usize>,
/// State-vector index: suction pressure (incoming edge — read for actual compression)
suction_p_idx: Option<usize>,
/// State-vector index: suction enthalpy (incoming edge — read for actual compression)
suction_h_idx: Option<usize>,
/// State-vector index: suction mass flow (incoming edge, CM1.3)
suction_m_idx: Option<usize>,
/// 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<f64>,
/// Rotational speed [rev/s] — required in emergent mode.
speed_hz: Option<f64>,
/// 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<VsdSpeedMap>,
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<f64>,
/// 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<dyn FluidBackend>) -> 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<f64> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<f64, ComponentError> {
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<dyn FluidBackend>) {
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<crate::EquationRole> {
// 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<f64> {
// 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);
}
}