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>
1711 lines
71 KiB
Rust
1711 lines
71 KiB
Rust
//! 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);
|
||
}
|
||
}
|