1730 lines
55 KiB
Rust
1730 lines
55 KiB
Rust
//! Expansion Valve Component Implementation
|
||
//!
|
||
//! This module provides an expansion valve component that models isenthalpic
|
||
//! expansion in refrigeration systems. The expansion valve reduces pressure
|
||
//! while maintaining constant enthalpy (throttling process).
|
||
//!
|
||
//! ## Thermodynamic Model
|
||
//!
|
||
//! The expansion valve is modeled as an isenthalpic device:
|
||
//! ```text
|
||
//! h_out = h_in (enthalpy conservation - isenthalpic)
|
||
//! ṁ_out = ṁ_in (mass flow continuity)
|
||
//! P_out < P_in (pressure drop - throttling)
|
||
//! W = 0 (no work done)
|
||
//! Q = 0 (adiabatic)
|
||
//! ```
|
||
//!
|
||
//! ## Operational States
|
||
//!
|
||
//! - **On**: Normal expansion with isenthalpic process
|
||
//! - **Off**: Zero mass flow through the valve
|
||
//! - **Bypass**: Acts as adiabatic pipe (P_in = P_out, h_in = h_out)
|
||
//!
|
||
//! ## Example
|
||
//!
|
||
//! ```rust
|
||
//! use entropyk_components::expansion_valve::ExpansionValve;
|
||
//! use entropyk_components::port::{FluidId, Port};
|
||
//! use entropyk_core::{Pressure, Enthalpy};
|
||
//!
|
||
//! // Create disconnected ports
|
||
//! let inlet = Port::new(
|
||
//! FluidId::new("R134a"),
|
||
//! Pressure::from_bar(10.0),
|
||
//! Enthalpy::from_joules_per_kg(250000.0)
|
||
//! );
|
||
//! let outlet = Port::new(
|
||
//! FluidId::new("R134a"),
|
||
//! Pressure::from_bar(10.0),
|
||
//! Enthalpy::from_joules_per_kg(250000.0)
|
||
//! );
|
||
//!
|
||
//! // Create expansion valve
|
||
//! let valve = ExpansionValve::new(inlet, outlet, None).unwrap();
|
||
//! ```
|
||
|
||
use crate::port::{Connected, Disconnected, FluidId, Port};
|
||
use crate::{
|
||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||
ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::Calib;
|
||
use std::marker::PhantomData;
|
||
|
||
const OPENING_THRESHOLD: f64 = 0.01;
|
||
|
||
const ENTHALPY_TOLERANCE_J_KG: f64 = 100.0;
|
||
|
||
const MIN_STATE_DIMENSIONS: usize = 2;
|
||
|
||
fn is_effectively_off_impl(operational_state: OperationalState, opening: Option<f64>) -> bool {
|
||
operational_state == OperationalState::Off || opening.is_some_and(|o| o < OPENING_THRESHOLD)
|
||
}
|
||
|
||
/// Expansion valve component for modeling isenthalpic expansion.
|
||
///
|
||
/// The expansion valve is a throttling device that reduces pressure while
|
||
/// maintaining constant enthalpy (isenthalpic process). It implements the
|
||
/// [`Component`] trait for integration with the solver.
|
||
///
|
||
/// # Type Parameters
|
||
///
|
||
/// * `State` - Either `Disconnected` or `Connected`, tracking connection state
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust
|
||
/// use entropyk_components::expansion_valve::ExpansionValve;
|
||
/// use entropyk_components::port::{FluidId, Port};
|
||
/// use entropyk_core::{Pressure, Enthalpy};
|
||
///
|
||
/// // Create disconnected ports
|
||
/// let inlet = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(10.0),
|
||
/// Enthalpy::from_joules_per_kg(250000.0)
|
||
/// );
|
||
/// let outlet = Port::new(
|
||
/// FluidId::new("R134a"),
|
||
/// Pressure::from_bar(10.0),
|
||
/// Enthalpy::from_joules_per_kg(250000.0)
|
||
/// );
|
||
///
|
||
/// // Create expansion valve with optional opening parameter
|
||
/// let valve = ExpansionValve::new(inlet, outlet, Some(1.0)).unwrap();
|
||
/// ```
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct ExpansionValve<State> {
|
||
port_inlet: Port<State>,
|
||
port_outlet: Port<State>,
|
||
/// Calibration: ṁ_eff = f_m × ṁ_nominal (mass flow scaling)
|
||
calib: Calib,
|
||
/// Calibration indices to extract factors dynamically from SystemState
|
||
pub calib_indices: entropyk_core::CalibIndices,
|
||
operational_state: OperationalState,
|
||
opening: Option<f64>,
|
||
fluid_id: FluidId,
|
||
circuit_id: CircuitId,
|
||
_state: PhantomData<State>,
|
||
}
|
||
|
||
impl ExpansionValve<Disconnected> {
|
||
/// Creates a new disconnected expansion valve.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `port_inlet` - Inlet port (high pressure, subcooled liquid)
|
||
/// * `port_outlet` - Outlet port (low pressure, two-phase)
|
||
/// * `opening` - Optional opening parameter (0.0 = closed, 1.0 = fully open)
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if:
|
||
/// - Opening is outside [0.0, 1.0] range
|
||
/// - Opening is NaN or infinite
|
||
/// - Ports have different fluid types
|
||
pub fn new(
|
||
port_inlet: Port<Disconnected>,
|
||
port_outlet: Port<Disconnected>,
|
||
opening: Option<f64>,
|
||
) -> Result<Self, ComponentError> {
|
||
if let Some(o) = opening {
|
||
if !(0.0..=1.0).contains(&o) {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"Opening must be between 0.0 and 1.0, got {}",
|
||
o
|
||
)));
|
||
}
|
||
if o.is_nan() || o.is_infinite() {
|
||
return Err(ComponentError::InvalidState(
|
||
"Opening must be a finite number".to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
if port_inlet.fluid_id() != port_outlet.fluid_id() {
|
||
return Err(ComponentError::InvalidState(
|
||
"Inlet and outlet ports must have the same fluid type".to_string(),
|
||
));
|
||
}
|
||
|
||
let fluid_id = port_inlet.fluid_id().clone();
|
||
|
||
Ok(Self {
|
||
port_inlet,
|
||
port_outlet,
|
||
calib: Calib::default(),
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
operational_state: OperationalState::default(),
|
||
opening,
|
||
fluid_id,
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
|
||
/// Returns the fluid identifier.
|
||
pub fn fluid_id(&self) -> &FluidId {
|
||
&self.fluid_id
|
||
}
|
||
|
||
/// Returns the inlet port.
|
||
pub fn port_inlet(&self) -> &Port<Disconnected> {
|
||
&self.port_inlet
|
||
}
|
||
|
||
/// Returns the outlet port.
|
||
pub fn port_outlet(&self) -> &Port<Disconnected> {
|
||
&self.port_outlet
|
||
}
|
||
|
||
/// Returns the optional opening parameter (0.0 to 1.0).
|
||
pub fn opening(&self) -> Option<f64> {
|
||
self.opening
|
||
}
|
||
|
||
/// Returns the current operational state.
|
||
pub fn operational_state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
/// Sets the operational state.
|
||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||
self.operational_state = state;
|
||
}
|
||
|
||
/// Returns the circuit identifier.
|
||
pub fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
/// Sets the circuit identifier.
|
||
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
|
||
/// Returns calibration factors (f_m for mass flow scaling).
|
||
pub fn calib(&self) -> &Calib {
|
||
&self.calib
|
||
}
|
||
|
||
/// Sets calibration factors.
|
||
pub fn set_calib(&mut self, calib: Calib) {
|
||
self.calib = calib;
|
||
}
|
||
|
||
/// Returns true if the valve is effectively off.
|
||
///
|
||
/// The valve is effectively off when:
|
||
/// - Operational state is Off, or
|
||
/// - Opening is below threshold (< 1%)
|
||
pub fn is_effectively_off(&self) -> bool {
|
||
is_effectively_off_impl(self.operational_state, self.opening)
|
||
}
|
||
/// Connects the expansion valve to inlet and outlet ports.
|
||
///
|
||
/// This consumes the disconnected valve and returns a connected one,
|
||
/// transitioning the state at compile time.
|
||
pub fn connect(
|
||
self,
|
||
inlet: Port<Disconnected>,
|
||
outlet: Port<Disconnected>,
|
||
) -> Result<ExpansionValve<Connected>, ComponentError> {
|
||
let (p_in, _) = self
|
||
.port_inlet
|
||
.connect(inlet)
|
||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||
let (p_out, _) = self
|
||
.port_outlet
|
||
.connect(outlet)
|
||
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
|
||
|
||
Ok(ExpansionValve {
|
||
port_inlet: p_in,
|
||
port_outlet: p_out,
|
||
calib: self.calib,
|
||
calib_indices: self.calib_indices,
|
||
operational_state: self.operational_state,
|
||
opening: self.opening,
|
||
fluid_id: self.fluid_id,
|
||
circuit_id: self.circuit_id,
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Phase region at a thermodynamic state point.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum PhaseRegion {
|
||
/// Subcooled liquid (below saturation line)
|
||
Subcooled,
|
||
/// Two-phase mixture (between saturated liquid and vapor)
|
||
TwoPhase,
|
||
/// Superheated vapor (above saturation line)
|
||
Superheated,
|
||
}
|
||
|
||
impl PhaseRegion {
|
||
/// Returns true if the region is two-phase.
|
||
pub fn is_two_phase(self) -> bool {
|
||
self == PhaseRegion::TwoPhase
|
||
}
|
||
}
|
||
|
||
impl ExpansionValve<Connected> {
|
||
/// Returns the inlet port.
|
||
pub fn port_inlet(&self) -> &Port<Connected> {
|
||
&self.port_inlet
|
||
}
|
||
|
||
/// Returns the outlet port.
|
||
pub fn port_outlet(&self) -> &Port<Connected> {
|
||
&self.port_outlet
|
||
}
|
||
|
||
/// Computes the full thermodynamic state at the inlet port.
|
||
pub fn inlet_state(
|
||
&self,
|
||
backend: &impl entropyk_fluids::FluidBackend,
|
||
) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||
backend
|
||
.full_state(
|
||
entropyk_fluids::FluidId::new(self.port_inlet.fluid_id().as_str()),
|
||
self.port_inlet.pressure(),
|
||
self.port_inlet.enthalpy(),
|
||
)
|
||
.map_err(|e| {
|
||
ComponentError::CalculationFailed(format!("Failed to compute inlet state: {}", e))
|
||
})
|
||
}
|
||
|
||
/// Computes the full thermodynamic state at the outlet port.
|
||
pub fn outlet_state(
|
||
&self,
|
||
backend: &impl entropyk_fluids::FluidBackend,
|
||
) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||
backend
|
||
.full_state(
|
||
entropyk_fluids::FluidId::new(self.port_outlet.fluid_id().as_str()),
|
||
self.port_outlet.pressure(),
|
||
self.port_outlet.enthalpy(),
|
||
)
|
||
.map_err(|e| {
|
||
ComponentError::CalculationFailed(format!("Failed to compute outlet state: {}", e))
|
||
})
|
||
}
|
||
|
||
/// Returns the optional opening parameter (0.0 to 1.0).
|
||
pub fn opening(&self) -> Option<f64> {
|
||
self.opening
|
||
}
|
||
|
||
/// Returns the current operational state.
|
||
pub fn operational_state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
/// Sets the operational state.
|
||
pub fn set_operational_state(&mut self, state: OperationalState) {
|
||
self.operational_state = state;
|
||
}
|
||
|
||
/// Returns the circuit identifier.
|
||
pub fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
/// Sets the circuit identifier.
|
||
pub fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
|
||
/// Returns the fluid identifier.
|
||
pub fn fluid_id(&self) -> &FluidId {
|
||
&self.fluid_id
|
||
}
|
||
|
||
/// Returns calibration factors (f_m for mass flow scaling).
|
||
pub fn calib(&self) -> &Calib {
|
||
&self.calib
|
||
}
|
||
|
||
/// Sets calibration factors.
|
||
pub fn set_calib(&mut self, calib: Calib) {
|
||
self.calib = calib;
|
||
}
|
||
|
||
/// Returns true if the valve is effectively off.
|
||
///
|
||
/// The valve is effectively off when:
|
||
/// - Operational state is Off, or
|
||
/// - Opening is below threshold (< 1%)
|
||
pub fn is_effectively_off(&self) -> bool {
|
||
is_effectively_off_impl(self.operational_state, self.opening)
|
||
}
|
||
|
||
/// Sets the valve opening parameter.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `opening` - New opening value (0.0 = closed, 1.0 = fully open), or None
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns an error if opening is outside [0.0, 1.0] range or is NaN/infinite.
|
||
pub fn set_opening(&mut self, opening: Option<f64>) -> Result<(), ComponentError> {
|
||
if let Some(o) = opening {
|
||
if !(0.0..=1.0).contains(&o) {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"Opening must be between 0.0 and 1.0, got {}",
|
||
o
|
||
)));
|
||
}
|
||
if o.is_nan() || o.is_infinite() {
|
||
return Err(ComponentError::InvalidState(
|
||
"Opening must be a finite number".to_string(),
|
||
));
|
||
}
|
||
}
|
||
self.opening = opening;
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns both ports as an array for solver topology.
|
||
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
|
||
[&self.port_inlet, &self.port_outlet]
|
||
}
|
||
|
||
/// Validates that the process is isenthalpic (h_in = h_out).
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns `Ok(true)` if inlet and outlet enthalpies are equal within tolerance.
|
||
pub fn validate_isenthalpic(&self) -> Result<bool, ComponentError> {
|
||
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
if h_in.is_nan() || h_out.is_nan() {
|
||
return Err(ComponentError::NumericalError(
|
||
"Enthalpy contains NaN value".to_string(),
|
||
));
|
||
}
|
||
|
||
Ok((h_in - h_out).abs() < ENTHALPY_TOLERANCE_J_KG)
|
||
}
|
||
|
||
/// Validates that outlet pressure is lower than inlet pressure.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns `Ok(true)` if P_out < P_in, indicating a pressure drop.
|
||
pub fn validate_pressure_drop(&self) -> Result<bool, ComponentError> {
|
||
let p_in = self.port_inlet.pressure().to_pascals();
|
||
let p_out = self.port_outlet.pressure().to_pascals();
|
||
|
||
if p_in <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Inlet pressure must be positive".to_string(),
|
||
));
|
||
}
|
||
if p_out <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Outlet pressure must be positive".to_string(),
|
||
));
|
||
}
|
||
|
||
Ok(p_out < p_in)
|
||
}
|
||
|
||
/// Returns the pressure ratio (P_out / P_in).
|
||
///
|
||
/// A value less than 1.0 indicates a pressure drop through the valve.
|
||
pub fn pressure_ratio(&self) -> f64 {
|
||
let p_in = self.port_inlet.pressure().to_pascals();
|
||
let p_out = self.port_outlet.pressure().to_pascals();
|
||
if p_in > 0.0 {
|
||
p_out / p_in
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
|
||
/// Detects the phase region of the outlet port based on pressure and enthalpy.
|
||
///
|
||
/// This method determines if the outlet is in subcooled, two-phase, or superheated
|
||
/// region by comparing against saturation enthalpy values at the outlet pressure.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `h_f` - Saturated liquid enthalpy at outlet pressure (J/kg)
|
||
/// * `h_g` - Saturated vapor enthalpy at outlet pressure (J/kg)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// The phase region at the outlet.
|
||
pub fn detect_phase_region(&self, h_f: f64, h_g: f64) -> PhaseRegion {
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
if h_out < h_f {
|
||
PhaseRegion::Subcooled
|
||
} else if h_out > h_g {
|
||
PhaseRegion::Superheated
|
||
} else {
|
||
PhaseRegion::TwoPhase
|
||
}
|
||
}
|
||
|
||
/// Calculates the vapor quality at the outlet if in two-phase region.
|
||
///
|
||
/// Quality is defined as: x = (h - h_f) / (h_g - h_f)
|
||
/// - x = 0: Saturated liquid
|
||
/// - x = 1: Saturated vapor
|
||
/// - 0 < x < 1: Two-phase mixture
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `h_f` - Saturated liquid enthalpy at outlet pressure (J/kg)
|
||
/// * `h_g` - Saturated vapor enthalpy at outlet pressure (J/kg)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns `Ok(quality)` if outlet is in two-phase region,
|
||
/// or `Err(ComponentError)` if quality calculation is not applicable.
|
||
pub fn outlet_quality(&self, h_f: f64, h_g: f64) -> Result<f64, ComponentError> {
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
let h_range = h_g - h_f;
|
||
if h_range <= 0.0 {
|
||
return Err(ComponentError::NumericalError(
|
||
"Invalid saturation enthalpy range (h_g must be greater than h_f)".to_string(),
|
||
));
|
||
}
|
||
|
||
if h_out < h_f || h_out > h_g {
|
||
return Err(ComponentError::InvalidState(format!(
|
||
"Outlet is not in two-phase region: h_out={} J/kg, h_f={} J/kg, h_g={} J/kg",
|
||
h_out, h_f, h_g
|
||
)));
|
||
}
|
||
|
||
Ok((h_out - h_f) / h_range)
|
||
}
|
||
|
||
/// Validates that phase change occurs from inlet to outlet.
|
||
///
|
||
/// For isenthalpic expansion, the outlet should typically be in two-phase
|
||
/// if the inlet was subcooled liquid.
|
||
///
|
||
/// # Arguments
|
||
///
|
||
/// * `h_f_out` - Saturated liquid enthalpy at outlet pressure (J/kg)
|
||
/// * `h_g_out` - Saturated vapor enthalpy at outlet pressure (J/kg)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// Returns `Ok(true)` if phase change occurs from inlet to outlet.
|
||
/// This is detected when the outlet is in two-phase region.
|
||
pub fn validate_phase_change(
|
||
&self,
|
||
h_f_out: f64,
|
||
h_g_out: f64,
|
||
) -> Result<bool, ComponentError> {
|
||
let _h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
if h_out >= h_f_out && h_out <= h_g_out {
|
||
return Ok(true);
|
||
}
|
||
|
||
Ok(false)
|
||
}
|
||
}
|
||
|
||
impl Component for ExpansionValve<Connected> {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
if residuals.len() != self.n_equations() {
|
||
return Err(ComponentError::InvalidResidualDimensions {
|
||
expected: self.n_equations(),
|
||
actual: residuals.len(),
|
||
});
|
||
}
|
||
|
||
if self.is_effectively_off() {
|
||
if state.is_empty() {
|
||
return Err(ComponentError::InvalidStateDimensions {
|
||
expected: MIN_STATE_DIMENSIONS,
|
||
actual: 0,
|
||
});
|
||
}
|
||
residuals[0] = state[0];
|
||
residuals[1] = 0.0;
|
||
return Ok(());
|
||
}
|
||
|
||
match self.operational_state {
|
||
OperationalState::Bypass => {
|
||
let p_in = self.port_inlet.pressure().to_pascals();
|
||
let p_out = self.port_outlet.pressure().to_pascals();
|
||
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
residuals[0] = p_out - p_in;
|
||
residuals[1] = h_out - h_in;
|
||
return Ok(());
|
||
}
|
||
OperationalState::On | OperationalState::Off => {}
|
||
}
|
||
|
||
if state.len() < MIN_STATE_DIMENSIONS {
|
||
return Err(ComponentError::InvalidStateDimensions {
|
||
expected: MIN_STATE_DIMENSIONS,
|
||
actual: state.len(),
|
||
});
|
||
}
|
||
|
||
let h_in = self.port_inlet.enthalpy().to_joules_per_kg();
|
||
let h_out = self.port_outlet.enthalpy().to_joules_per_kg();
|
||
|
||
residuals[0] = h_out - h_in;
|
||
|
||
// Mass flow: ṁ_out = f_m × ṁ_in (calibration factor on inlet flow)
|
||
let mass_flow_in = state[0];
|
||
let mass_flow_out = state[1];
|
||
let f_m = self
|
||
.calib_indices
|
||
.f_m
|
||
.map(|idx| state[idx])
|
||
.unwrap_or(self.calib.f_m);
|
||
residuals[1] = mass_flow_out - f_m * mass_flow_in;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
if self.is_effectively_off() {
|
||
jacobian.add_entry(0, 0, 1.0);
|
||
jacobian.add_entry(1, 0, 0.0);
|
||
return Ok(());
|
||
}
|
||
|
||
match self.operational_state {
|
||
OperationalState::Bypass => {
|
||
jacobian.add_entry(0, 0, 1.0);
|
||
jacobian.add_entry(0, 1, -1.0);
|
||
jacobian.add_entry(1, 0, 1.0);
|
||
jacobian.add_entry(1, 1, -1.0);
|
||
return Ok(());
|
||
}
|
||
OperationalState::On | OperationalState::Off => {}
|
||
}
|
||
|
||
let f_m = self
|
||
.calib_indices
|
||
.f_m
|
||
.map(|idx| _state[idx])
|
||
.unwrap_or(self.calib.f_m);
|
||
jacobian.add_entry(0, 0, 0.0);
|
||
jacobian.add_entry(0, 1, 0.0);
|
||
jacobian.add_entry(1, 0, -f_m);
|
||
jacobian.add_entry(1, 1, 1.0);
|
||
|
||
if let Some(idx) = self.calib_indices.f_m {
|
||
// d(R2)/d(f_m) = -mass_flow_in
|
||
// We need mass_flow_in here, which is _state[0]
|
||
let mass_flow_in = _state[0];
|
||
jacobian.add_entry(1, idx, -mass_flow_in);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
2
|
||
}
|
||
|
||
fn port_mass_flows(
|
||
&self,
|
||
state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||
if state.len() < MIN_STATE_DIMENSIONS {
|
||
return Err(ComponentError::InvalidStateDimensions {
|
||
expected: MIN_STATE_DIMENSIONS,
|
||
actual: state.len(),
|
||
});
|
||
}
|
||
let m_in = entropyk_core::MassFlow::from_kg_per_s(state[0]);
|
||
let m_out = entropyk_core::MassFlow::from_kg_per_s(-state[1]); // Negative because it's leaving
|
||
Ok(vec![m_in, m_out])
|
||
}
|
||
|
||
/// Returns the enthalpies at the inlet and outlet ports.
|
||
///
|
||
/// For an expansion valve (isenthalpic device), the inlet and outlet
|
||
/// enthalpies should be equal: h_in ≈ h_out.
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// A vector containing `[h_inlet, h_outlet]` in order.
|
||
fn port_enthalpies(
|
||
&self,
|
||
_state: &StateSlice,
|
||
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
|
||
Ok(vec![
|
||
self.port_inlet.enthalpy(),
|
||
self.port_outlet.enthalpy(),
|
||
])
|
||
}
|
||
|
||
/// Returns the energy transfers for the expansion valve.
|
||
///
|
||
/// An expansion valve is an isenthalpic throttling device:
|
||
/// - **Heat (Q)**: 0 W (adiabatic - no heat exchange with environment)
|
||
/// - **Work (W)**: 0 W (no moving parts - no mechanical work)
|
||
///
|
||
/// # Returns
|
||
///
|
||
/// `Some((Q=0, W=0))` always, since expansion valves are passive devices.
|
||
fn energy_transfers(
|
||
&self,
|
||
_state: &StateSlice,
|
||
) -> Option<(entropyk_core::Power, entropyk_core::Power)> {
|
||
match self.operational_state {
|
||
OperationalState::Off | OperationalState::Bypass | OperationalState::On => Some((
|
||
entropyk_core::Power::from_watts(0.0),
|
||
entropyk_core::Power::from_watts(0.0),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
|
||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||
self.calib_indices = indices;
|
||
}
|
||
|
||
fn signature(&self) -> String {
|
||
format!(
|
||
"ExpansionValve(fluid={}, circuit={})",
|
||
self.fluid_id.as_str(),
|
||
self.circuit_id.0
|
||
)
|
||
}
|
||
|
||
fn to_params(&self) -> crate::ComponentParams {
|
||
crate::ComponentParams::new("ExpansionValve")
|
||
.with_param("fluid", self.fluid_id.as_str())
|
||
.with_param("circuitId", self.circuit_id.0)
|
||
.with_param("opening", self.opening)
|
||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||
}
|
||
|
||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||
let mut c = self.calib().clone();
|
||
if c.set_factor(factor, value) {
|
||
self.set_calib(c);
|
||
true
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
use crate::state_machine::StateManageable;
|
||
|
||
impl StateManageable for ExpansionValve<Connected> {
|
||
fn state(&self) -> OperationalState {
|
||
self.operational_state
|
||
}
|
||
|
||
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||
if self.operational_state.can_transition_to(state) {
|
||
let from = self.operational_state;
|
||
self.operational_state = state;
|
||
self.on_state_change(from, state);
|
||
Ok(())
|
||
} else {
|
||
Err(ComponentError::InvalidStateTransition {
|
||
from: self.operational_state,
|
||
to: state,
|
||
reason: "Transition not allowed".to_string(),
|
||
})
|
||
}
|
||
}
|
||
|
||
fn can_transition_to(&self, target: OperationalState) -> bool {
|
||
self.operational_state.can_transition_to(target)
|
||
}
|
||
|
||
fn circuit_id(&self) -> &CircuitId {
|
||
&self.circuit_id
|
||
}
|
||
|
||
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
|
||
self.circuit_id = circuit_id;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use approx::assert_relative_eq;
|
||
use entropyk_core::{Enthalpy, Pressure};
|
||
|
||
fn create_test_valve() -> ExpansionValve<Connected> {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
}
|
||
}
|
||
|
||
fn create_disconnected_valve() -> ExpansionValve<Disconnected> {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
ExpansionValve::new(inlet, outlet, Some(1.0)).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation() {
|
||
let valve = create_disconnected_valve();
|
||
assert_eq!(valve.fluid_id().as_str(), "R134a");
|
||
assert_eq!(valve.opening(), Some(1.0));
|
||
assert_eq!(valve.operational_state(), OperationalState::On);
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation_without_opening() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let valve = ExpansionValve::new(inlet, outlet, None).unwrap();
|
||
assert_eq!(valve.opening(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation_invalid_opening_high() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let result = ExpansionValve::new(inlet, outlet, Some(1.5));
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation_invalid_opening_low() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let result = ExpansionValve::new(inlet, outlet, Some(-0.1));
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation_nan_opening() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let result = ExpansionValve::new(inlet, outlet, Some(f64::NAN));
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_valve_creation_incompatible_fluids() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R410A"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let result = ExpansionValve::new(inlet, outlet, Some(1.0));
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_isenthalpic_expansion() {
|
||
let valve = create_test_valve();
|
||
assert_relative_eq!(
|
||
valve.port_inlet().enthalpy().to_joules_per_kg(),
|
||
valve.port_outlet().enthalpy().to_joules_per_kg(),
|
||
epsilon = 1e-10
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_isenthalpic() {
|
||
let valve = create_test_valve();
|
||
let result = valve.validate_isenthalpic();
|
||
assert!(result.is_ok());
|
||
assert!(result.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_pressure_drop() {
|
||
let valve = create_test_valve();
|
||
let p_in = valve.port_inlet().pressure().to_bar();
|
||
let p_out = valve.port_outlet().pressure().to_bar();
|
||
assert!(p_out < p_in, "Outlet pressure should be less than inlet");
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_pressure_drop() {
|
||
let valve = create_test_valve();
|
||
let result = valve.validate_pressure_drop();
|
||
assert!(result.is_ok());
|
||
assert!(result.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_pressure_ratio() {
|
||
let valve = create_test_valve();
|
||
let ratio = valve.pressure_ratio();
|
||
assert_relative_eq!(ratio, 0.35, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_off_mode() {
|
||
let mut valve = create_test_valve();
|
||
valve.set_operational_state(OperationalState::Off);
|
||
|
||
let state = vec![0.05, 0.05];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
valve.compute_residuals(&state, &mut residuals).unwrap();
|
||
|
||
assert_eq!(valve.operational_state(), OperationalState::Off);
|
||
assert!(valve.is_effectively_off());
|
||
}
|
||
|
||
#[test]
|
||
fn test_bypass_mode() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::Bypass,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let state = vec![0.05, 0.05];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
valve.compute_residuals(&state, &mut residuals).unwrap();
|
||
|
||
assert_eq!(valve.operational_state(), OperationalState::Bypass);
|
||
assert_relative_eq!(residuals[0], 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(residuals[1], 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_opening_threshold_off() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(0.005),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
assert!(valve.is_effectively_off());
|
||
}
|
||
|
||
#[test]
|
||
fn test_opening_threshold_on() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(0.5),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
assert!(!valve.is_effectively_off());
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_n_equations() {
|
||
let valve = create_test_valve();
|
||
assert_eq!(valve.n_equations(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_compute_residuals() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
let result = valve.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_ok());
|
||
|
||
assert_relative_eq!(residuals[0], 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(residuals[1], 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_compute_residuals_wrong_size() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
let mut residuals = vec![0.0; 3];
|
||
|
||
let result = valve.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_component_jacobian_entries() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
let mut jacobian = JacobianBuilder::new();
|
||
|
||
let result = valve.jacobian_entries(&state, &mut jacobian);
|
||
assert!(result.is_ok());
|
||
assert!(!jacobian.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn test_circuit_id() {
|
||
let mut valve = create_disconnected_valve();
|
||
valve.set_circuit_id(CircuitId::from_number(5));
|
||
assert_eq!(valve.circuit_id().as_number(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_ports_slice() {
|
||
let valve = create_test_valve();
|
||
let ports = valve.get_ports_slice();
|
||
assert_eq!(ports.len(), 2);
|
||
assert_eq!(ports[0].fluid_id().as_str(), "R134a");
|
||
assert_eq!(ports[1].fluid_id().as_str(), "R134a");
|
||
}
|
||
|
||
#[test]
|
||
fn test_clone() {
|
||
let valve = create_test_valve();
|
||
let cloned = valve.clone();
|
||
assert_eq!(valve.opening(), cloned.opening());
|
||
assert_eq!(valve.operational_state(), cloned.operational_state());
|
||
}
|
||
|
||
#[test]
|
||
fn test_mass_flow_continuity_residual() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.06];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
valve.compute_residuals(&state, &mut residuals).unwrap();
|
||
|
||
assert_relative_eq!(residuals[1], 0.01, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_opening_valid() {
|
||
let mut valve = create_test_valve();
|
||
assert!(valve.set_opening(Some(0.5)).is_ok());
|
||
assert_eq!(valve.opening(), Some(0.5));
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_opening_invalid_high() {
|
||
let mut valve = create_test_valve();
|
||
assert!(valve.set_opening(Some(1.5)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_opening_invalid_low() {
|
||
let mut valve = create_test_valve();
|
||
assert!(valve.set_opening(Some(-0.1)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_opening_nan() {
|
||
let mut valve = create_test_valve();
|
||
assert!(valve.set_opening(Some(f64::NAN)).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_set_opening_none() {
|
||
let mut valve = create_test_valve();
|
||
assert!(valve.set_opening(None).is_ok());
|
||
assert_eq!(valve.opening(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_on_mode_empty_state_error() {
|
||
let valve = create_test_valve();
|
||
let state: Vec<f64> = vec![];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
let result = valve.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_off_mode_empty_state_error() {
|
||
let mut valve = create_test_valve();
|
||
valve.set_operational_state(OperationalState::Off);
|
||
|
||
let state: Vec<f64> = vec![];
|
||
let mut residuals = vec![0.0; 2];
|
||
|
||
let result = valve.compute_residuals(&state, &mut residuals);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_pressure_ratio_zero_inlet() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(0.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(0.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_pascals(0.0));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
assert_relative_eq!(valve.pressure_ratio(), 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_isenthalpic_with_tolerance() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250050.0),
|
||
);
|
||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let result = valve.validate_isenthalpic();
|
||
assert!(result.is_ok());
|
||
assert!(result.unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_bypass_mode_jacobian() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::Bypass,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let state = vec![0.05, 0.05];
|
||
let mut jacobian = JacobianBuilder::new();
|
||
|
||
valve.jacobian_entries(&state, &mut jacobian).unwrap();
|
||
|
||
let entries = jacobian.entries();
|
||
assert!(entries.len() >= 4);
|
||
|
||
let has_nonzero = entries.iter().any(|(_, _, v)| *v != 0.0);
|
||
assert!(has_nonzero, "Bypass jacobian should have non-zero entries");
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_state() {
|
||
let valve = create_test_valve();
|
||
assert_eq!(valve.state(), OperationalState::On);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_state_on_to_off() {
|
||
let mut valve = create_test_valve();
|
||
let result = valve.set_state(OperationalState::Off);
|
||
assert!(result.is_ok());
|
||
assert_eq!(valve.state(), OperationalState::Off);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_state_on_to_bypass() {
|
||
let mut valve = create_test_valve();
|
||
let result = valve.set_state(OperationalState::Bypass);
|
||
assert!(result.is_ok());
|
||
assert_eq!(valve.state(), OperationalState::Bypass);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_can_transition_to() {
|
||
let valve = create_test_valve();
|
||
assert!(valve.can_transition_to(OperationalState::Off));
|
||
assert!(valve.can_transition_to(OperationalState::Bypass));
|
||
assert!(valve.can_transition_to(OperationalState::On));
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_circuit_id() {
|
||
let valve = create_test_valve();
|
||
assert_eq!(*valve.circuit_id(), CircuitId::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_manageable_set_circuit_id() {
|
||
let mut valve = create_test_valve();
|
||
valve.set_circuit_id(CircuitId::from_number(2));
|
||
assert_eq!(valve.circuit_id().as_number(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_state_transition_cycle() {
|
||
let mut valve = create_test_valve();
|
||
|
||
// On -> Off
|
||
valve.set_state(OperationalState::Off).unwrap();
|
||
assert_eq!(valve.state(), OperationalState::Off);
|
||
|
||
// Off -> Bypass
|
||
valve.set_state(OperationalState::Bypass).unwrap();
|
||
assert_eq!(valve.state(), OperationalState::Bypass);
|
||
|
||
// Bypass -> On
|
||
valve.set_state(OperationalState::On).unwrap();
|
||
assert_eq!(valve.state(), OperationalState::On);
|
||
}
|
||
|
||
#[test]
|
||
fn test_detect_phase_region_subcooled() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(200000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(200000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
outlet_conn.set_enthalpy(Enthalpy::from_joules_per_kg(180000.0));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let region = valve.detect_phase_region(h_f, h_g);
|
||
assert_eq!(region, PhaseRegion::Subcooled);
|
||
}
|
||
|
||
#[test]
|
||
fn test_detect_phase_region_two_phase() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let region = valve.detect_phase_region(h_f, h_g);
|
||
assert_eq!(region, PhaseRegion::TwoPhase);
|
||
assert!(region.is_two_phase());
|
||
}
|
||
|
||
#[test]
|
||
fn test_detect_phase_region_superheated() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let region = valve.detect_phase_region(h_f, h_g);
|
||
assert_eq!(region, PhaseRegion::Superheated);
|
||
}
|
||
|
||
#[test]
|
||
fn test_outlet_quality_valid() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let quality = valve.outlet_quality(h_f, h_g).unwrap();
|
||
assert_relative_eq!(quality, 0.25, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_outlet_quality_saturated_liquid() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(200000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(200000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let quality = valve.outlet_quality(h_f, h_g).unwrap();
|
||
assert_relative_eq!(quality, 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_outlet_quality_invalid_not_two_phase() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(450000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f = 200000.0;
|
||
let h_g = 400000.0;
|
||
let result = valve.outlet_quality(h_f, h_g);
|
||
assert!(result.is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_validate_phase_change_detected() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let h_f_out = 180000.0;
|
||
let h_g_out = 380000.0;
|
||
let result = valve.validate_phase_change(h_f_out, h_g_out).unwrap();
|
||
assert!(result);
|
||
}
|
||
|
||
#[test]
|
||
fn test_phase_region_enum() {
|
||
assert!(PhaseRegion::Subcooled.is_two_phase() == false);
|
||
assert!(PhaseRegion::TwoPhase.is_two_phase() == true);
|
||
assert!(PhaseRegion::Superheated.is_two_phase() == false);
|
||
}
|
||
|
||
#[test]
|
||
fn test_energy_transfers_zero() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
|
||
let (heat, work) = valve.energy_transfers(&state).unwrap();
|
||
|
||
assert_relative_eq!(heat.to_watts(), 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(work.to_watts(), 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_energy_transfers_off_mode() {
|
||
let mut valve = create_test_valve();
|
||
valve.set_operational_state(OperationalState::Off);
|
||
let state = vec![0.05, 0.05];
|
||
|
||
let (heat, work) = valve.energy_transfers(&state).unwrap();
|
||
|
||
assert_relative_eq!(heat.to_watts(), 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(work.to_watts(), 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_energy_transfers_bypass_mode() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(250000.0),
|
||
);
|
||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::Bypass,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let state = vec![0.05, 0.05];
|
||
let (heat, work) = valve.energy_transfers(&state).unwrap();
|
||
|
||
assert_relative_eq!(heat.to_watts(), 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(work.to_watts(), 0.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_port_enthalpies_returns_two_values() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
|
||
let enthalpies = valve.port_enthalpies(&state).unwrap();
|
||
|
||
assert_eq!(enthalpies.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn test_port_enthalpies_isenthalpic() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
|
||
let enthalpies = valve.port_enthalpies(&state).unwrap();
|
||
|
||
assert_relative_eq!(
|
||
enthalpies[0].to_joules_per_kg(),
|
||
enthalpies[1].to_joules_per_kg(),
|
||
epsilon = 1e-10
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_port_enthalpies_inlet_value() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(300000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(300000.0),
|
||
);
|
||
let (inlet_conn, mut outlet_conn) = inlet.connect(outlet).unwrap();
|
||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||
|
||
let valve = ExpansionValve {
|
||
calib_indices: entropyk_core::CalibIndices::default(),
|
||
port_inlet: inlet_conn,
|
||
port_outlet: outlet_conn,
|
||
calib: Calib::default(),
|
||
operational_state: OperationalState::On,
|
||
opening: Some(1.0),
|
||
fluid_id: FluidId::new("R134a"),
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
};
|
||
|
||
let state = vec![0.05, 0.05];
|
||
let enthalpies = valve.port_enthalpies(&state).unwrap();
|
||
|
||
assert_relative_eq!(enthalpies[0].to_joules_per_kg(), 300000.0, epsilon = 1e-10);
|
||
assert_relative_eq!(enthalpies[1].to_joules_per_kg(), 300000.0, epsilon = 1e-10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_expansion_valve_energy_balance() {
|
||
let valve = create_test_valve();
|
||
let state = vec![0.05, 0.05];
|
||
|
||
let energy = valve.energy_transfers(&state);
|
||
let mass_flows = valve.port_mass_flows(&state);
|
||
let enthalpies = valve.port_enthalpies(&state);
|
||
|
||
assert!(energy.is_some());
|
||
assert!(mass_flows.is_ok());
|
||
assert!(enthalpies.is_ok());
|
||
|
||
let (heat, work) = energy.unwrap();
|
||
let m_flows = mass_flows.unwrap();
|
||
let h_flows = enthalpies.unwrap();
|
||
|
||
assert_eq!(m_flows.len(), h_flows.len());
|
||
|
||
assert_relative_eq!(heat.to_watts(), 0.0, epsilon = 1e-10);
|
||
assert_relative_eq!(work.to_watts(), 0.0, epsilon = 1e-10);
|
||
}
|
||
}
|