//! 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) -> 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 { port_inlet: Port, port_outlet: Port, /// 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, fluid_id: FluidId, circuit_id: CircuitId, _state: PhantomData, } impl ExpansionValve { /// 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, port_outlet: Port, opening: Option, ) -> Result { 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 { &self.port_inlet } /// Returns the outlet port. pub fn port_outlet(&self) -> &Port { &self.port_outlet } /// Returns the optional opening parameter (0.0 to 1.0). pub fn opening(&self) -> Option { 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, outlet: Port, ) -> Result, 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 { /// Returns the inlet port. pub fn port_inlet(&self) -> &Port { &self.port_inlet } /// Returns the outlet port. pub fn port_outlet(&self) -> &Port { &self.port_outlet } /// Computes the full thermodynamic state at the inlet port. pub fn inlet_state( &self, backend: &impl entropyk_fluids::FluidBackend, ) -> Result { 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 { 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 { 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) -> 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; 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 { 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 { 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 { 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 { 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 { 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, 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, 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 { 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 { 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 { 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 = 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 = 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); } }