//! Port and Connection System //! //! This module provides the foundation for connecting thermodynamic components //! using the Type-State pattern for compile-time connection safety. //! //! ## Type-State Pattern //! //! Ports have two states: //! - `Disconnected`: Initial state, cannot be used in solver //! - `Connected`: Linked to another port, ready for simulation //! //! State transitions are enforced at compile time: //! ```text //! Port --connect()--> Port //! ↑ │ //! └───────── (no way back) ────────────┘ //! ``` //! //! ## Connection Semantics //! //! Connected ports validate continuity (pressure/enthalpy match) at connection time, //! but track values independently afterward. This allows the solver to update port //! states during iteration without requiring synchronization. //! //! ## Example //! //! ```rust //! use entropyk_components::port::{Port, Disconnected, Connected, FluidId, ConnectionError}; //! use entropyk_core::{Pressure, Enthalpy}; //! //! // Create two disconnected ports //! let port1 = Port::new(FluidId::new("R134a"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(400000.0)); //! let port2 = Port::new(FluidId::new("R134a"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(400000.0)); //! //! // Connect them //! let (connected1, connected2) = port1.connect(port2)?; //! //! // Ports track values independently for solver flexibility //! assert_eq!(connected1.pressure().to_bar(), 1.0); //! # Ok::<(), ConnectionError>(()) //! ``` use entropyk_core::{Enthalpy, Pressure}; use std::fmt; use std::marker::PhantomData; use thiserror::Error; /// Default relative tolerance for pressure matching (0.01% = 100 ppm). /// For 1 bar = 100,000 Pa, this allows 10 Pa difference. const PRESSURE_TOLERANCE_FRACTION: f64 = 1e-4; /// Default absolute tolerance for enthalpy matching (100 J/kg). /// This is approximately 0.024 kJ/kg, reasonable for HVAC calculations. const ENTHALPY_TOLERANCE_J_KG: f64 = 100.0; /// Minimum absolute pressure tolerance (1 Pa) to avoid issues near zero. const MIN_PRESSURE_TOLERANCE_PA: f64 = 1.0; /// Errors that can occur during port operations. #[derive(Error, Debug, Clone, PartialEq)] pub enum ConnectionError { /// Attempted to connect ports with incompatible fluids. #[error("Incompatible fluids: cannot connect {from} to {to}")] IncompatibleFluid { /// Source fluid identifier from: String, /// Target fluid identifier to: String, }, /// Pressure mismatch at connection point. #[error( "Pressure mismatch: {from_pressure} Pa vs {to_pressure} Pa (tolerance: {tolerance} Pa)" )] PressureMismatch { /// Pressure at source port (Pa) from_pressure: f64, /// Pressure at target port (Pa) to_pressure: f64, /// Tolerance used for comparison (Pa) tolerance: f64, }, /// Enthalpy mismatch at connection point. #[error("Enthalpy mismatch: {from_enthalpy} J/kg vs {to_enthalpy} J/kg (tolerance: {tolerance} J/kg)")] EnthalpyMismatch { /// Enthalpy at source port (J/kg) from_enthalpy: f64, /// Enthalpy at target port (J/kg) to_enthalpy: f64, /// Tolerance used for comparison (J/kg) tolerance: f64, }, /// Attempted to connect a port that is already connected. #[error("Port is already connected and cannot be reconnected")] AlreadyConnected, /// Detected a cycle in the connection graph. #[error("Connection would create a cycle in the system topology")] CycleDetected, /// Invalid port index. #[error( "Invalid port index {index}: component has {port_count} ports (valid: 0..{max_index})" )] InvalidPortIndex { /// The invalid port index that was requested index: usize, /// Number of ports on the component port_count: usize, /// Maximum valid index (port_count - 1, or 0 if no ports) max_index: usize, }, /// Invalid node index. #[error("Invalid node index: {0}")] InvalidNodeIndex(usize), } /// Type-state marker for disconnected ports. /// /// Ports in this state cannot be used in the solver until connected. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Disconnected; /// Type-state marker for connected ports. /// /// Ports in this state are linked to another port and ready for simulation. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Connected; /// Identifier for thermodynamic fluids. /// /// Used to ensure only compatible fluids are connected. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct FluidId(String); impl FluidId { /// Creates a new fluid identifier. /// /// # Arguments /// /// * `id` - Unique identifier for the fluid (e.g., "R134a", "Water") /// /// # Examples /// /// ``` /// use entropyk_components::port::FluidId; /// /// let fluid = FluidId::new("R134a"); /// ``` pub fn new(id: impl Into) -> Self { FluidId(id.into()) } /// Returns the fluid identifier as a string slice. pub fn as_str(&self) -> &str { &self.0 } } impl fmt::Display for FluidId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } /// A thermodynamic port for connecting components. /// /// Ports use the Type-State pattern to enforce connection safety at compile time. /// A `Port` must be connected before it can be used in simulations. /// /// # Type Parameters /// /// * `State` - Either `Disconnected` or `Connected`, tracking the port's state /// /// # Examples /// /// ``` /// use entropyk_components::port::{Port, Disconnected, FluidId}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// // Create a disconnected port /// let port: Port = Port::new( /// FluidId::new("R134a"), /// Pressure::from_bar(1.0), /// Enthalpy::from_joules_per_kg(400000.0) /// ); /// ``` #[derive(Debug, Clone, PartialEq)] pub struct Port { fluid_id: FluidId, pressure: Pressure, enthalpy: Enthalpy, _state: PhantomData, } /// Helper to validate connection parameters. fn validate_connection_params( from_fluid: &FluidId, from_p: Pressure, from_h: Enthalpy, to_fluid: &FluidId, to_p: Pressure, to_h: Enthalpy, ) -> Result<(), ConnectionError> { if from_fluid != to_fluid { return Err(ConnectionError::IncompatibleFluid { from: from_fluid.to_string(), to: to_fluid.to_string(), }); } let pressure_tol = (from_p.to_pascals().abs() * PRESSURE_TOLERANCE_FRACTION).max(MIN_PRESSURE_TOLERANCE_PA); let pressure_diff = (from_p.to_pascals() - to_p.to_pascals()).abs(); if pressure_diff > pressure_tol { return Err(ConnectionError::PressureMismatch { from_pressure: from_p.to_pascals(), to_pressure: to_p.to_pascals(), tolerance: pressure_tol, }); } let enthalpy_diff = (from_h.to_joules_per_kg() - to_h.to_joules_per_kg()).abs(); if enthalpy_diff > ENTHALPY_TOLERANCE_J_KG { return Err(ConnectionError::EnthalpyMismatch { from_enthalpy: from_h.to_joules_per_kg(), to_enthalpy: to_h.to_joules_per_kg(), tolerance: ENTHALPY_TOLERANCE_J_KG, }); } Ok(()) } impl Port { /// Creates a new disconnected port. /// /// # Arguments /// /// * `fluid_id` - Identifier for the fluid flowing through this port /// * `pressure` - Initial pressure at the port /// * `enthalpy` - Initial specific enthalpy at the port /// /// # Examples /// /// ``` /// use entropyk_components::port::{Port, FluidId}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// let port = Port::new( /// FluidId::new("R134a"), /// Pressure::from_bar(1.0), /// Enthalpy::from_joules_per_kg(400000.0) /// ); /// ``` pub fn new(fluid_id: FluidId, pressure: Pressure, enthalpy: Enthalpy) -> Self { Self { fluid_id, pressure, enthalpy, _state: PhantomData, } } /// Returns the fluid identifier. pub fn fluid_id(&self) -> &FluidId { &self.fluid_id } /// Returns the current pressure. pub fn pressure(&self) -> Pressure { self.pressure } /// Returns the current enthalpy. pub fn enthalpy(&self) -> Enthalpy { self.enthalpy } /// Connects two disconnected ports. /// /// Validates that: /// - Both ports have the same fluid type /// - Pressures match within relative tolerance /// - Enthalpies match within absolute tolerance /// /// After connection, ports track values independently, allowing the solver /// to update states during iteration. /// /// # Arguments /// /// * `other` - The port to connect to /// /// # Returns /// /// Returns a tuple of `(Port, Port)` on success, /// or a `ConnectionError` if validation fails. /// /// # Examples /// /// ``` /// use entropyk_components::port::{Port, FluidId, ConnectionError}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// let port1 = Port::new( /// FluidId::new("R134a"), /// Pressure::from_pascals(100000.0), /// Enthalpy::from_joules_per_kg(400000.0) /// ); /// let port2 = Port::new( /// FluidId::new("R134a"), /// Pressure::from_pascals(100000.0), /// Enthalpy::from_joules_per_kg(400000.0) /// ); /// /// let (connected1, connected2) = port1.connect(port2)?; /// # Ok::<(), ConnectionError>(()) /// ``` pub fn connect( self, other: Port, ) -> Result<(Port, Port), ConnectionError> { validate_connection_params( &self.fluid_id, self.pressure, self.enthalpy, &other.fluid_id, other.pressure, other.enthalpy, )?; let avg_pressure = Pressure::from_pascals( (self.pressure.to_pascals() + other.pressure.to_pascals()) / 2.0, ); let avg_enthalpy = Enthalpy::from_joules_per_kg( (self.enthalpy.to_joules_per_kg() + other.enthalpy.to_joules_per_kg()) / 2.0, ); let connected1 = Port { fluid_id: self.fluid_id, pressure: avg_pressure, enthalpy: avg_enthalpy, _state: PhantomData, }; let connected2 = Port { fluid_id: other.fluid_id, pressure: avg_pressure, enthalpy: avg_enthalpy, _state: PhantomData, }; Ok((connected1, connected2)) } } impl Port { /// Returns the fluid identifier. pub fn fluid_id(&self) -> &FluidId { &self.fluid_id } /// Returns the current pressure. pub fn pressure(&self) -> Pressure { self.pressure } /// Returns the current enthalpy. pub fn enthalpy(&self) -> Enthalpy { self.enthalpy } /// Updates the pressure at this port. /// /// # Arguments /// /// * `pressure` - The new pressure value pub fn set_pressure(&mut self, pressure: Pressure) { self.pressure = pressure; } /// Updates the enthalpy at this port. /// /// # Arguments /// /// * `enthalpy` - The new enthalpy value pub fn set_enthalpy(&mut self, enthalpy: Enthalpy) { self.enthalpy = enthalpy; } } /// A connected port reference that can be stored in components. /// /// This type is object-safe and can be used in trait objects. pub type ConnectedPort = Port; /// Validates that two connected ports are compatible for a flow connection. /// /// Uses the same tolerance constants as [`Port::connect`](Port::connect): /// - Pressure: `max(P * 1e-4, 1 Pa)` /// - Enthalpy: 100 J/kg /// /// # Arguments /// /// * `outlet` - Source port (flow direction: outlet → inlet) /// * `inlet` - Target port /// /// # Returns /// /// `Ok(())` if ports are compatible, `Err(ConnectionError)` otherwise. pub fn validate_port_continuity( outlet: &ConnectedPort, inlet: &ConnectedPort, ) -> Result<(), ConnectionError> { validate_connection_params( &outlet.fluid_id, outlet.pressure, outlet.enthalpy, &inlet.fluid_id, inlet.pressure, inlet.enthalpy, ) } #[cfg(test)] mod tests { use super::*; use approx::assert_relative_eq; #[test] fn test_port_creation() { let port = Port::new( FluidId::new("R134a"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(400000.0), ); assert_eq!(port.fluid_id().as_str(), "R134a"); assert_relative_eq!(port.pressure().to_bar(), 1.0, epsilon = 1e-10); assert_relative_eq!( port.enthalpy().to_joules_per_kg(), 400000.0, epsilon = 1e-10 ); } #[test] fn test_successful_connection() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let (connected1, connected2) = port1.connect(port2).unwrap(); assert_eq!(connected1.fluid_id().as_str(), "R134a"); assert_eq!(connected2.fluid_id().as_str(), "R134a"); assert_relative_eq!( connected1.pressure().to_pascals(), 100000.0, epsilon = 1e-10 ); assert_relative_eq!( connected2.pressure().to_pascals(), 100000.0, epsilon = 1e-10 ); } #[test] fn test_incompatible_fluid_error() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("Water"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let result = port1.connect(port2); assert!(matches!( result, Err(ConnectionError::IncompatibleFluid { .. }) )); } #[test] fn test_pressure_mismatch_error() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(200000.0), Enthalpy::from_joules_per_kg(400000.0), ); let result = port1.connect(port2); assert!(matches!( result, Err(ConnectionError::PressureMismatch { .. }) )); } #[test] fn test_enthalpy_mismatch_error() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(500000.0), ); let result = port1.connect(port2); assert!(matches!( result, Err(ConnectionError::EnthalpyMismatch { .. }) )); } #[test] fn test_connected_port_setters() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let (mut connected1, _) = port1.connect(port2).unwrap(); connected1.set_pressure(Pressure::from_pascals(150000.0)); connected1.set_enthalpy(Enthalpy::from_joules_per_kg(450000.0)); assert_relative_eq!( connected1.pressure().to_pascals(), 150000.0, epsilon = 1e-10 ); assert_relative_eq!( connected1.enthalpy().to_joules_per_kg(), 450000.0, epsilon = 1e-10 ); } #[test] fn test_ports_track_independently() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let (mut connected1, connected2) = port1.connect(port2).unwrap(); connected1.set_pressure(Pressure::from_pascals(150000.0)); // connected2 should NOT see the change - ports are independent assert_relative_eq!( connected2.pressure().to_pascals(), 100000.0, epsilon = 1e-10 ); } #[test] fn test_fluid_id_creation() { let fluid1 = FluidId::new("R134a"); let fluid2 = FluidId::new(String::from("Water")); assert_eq!(fluid1.as_str(), "R134a"); assert_eq!(fluid2.as_str(), "Water"); } #[test] fn test_connection_error_display() { let err = ConnectionError::IncompatibleFluid { from: "R134a".to_string(), to: "Water".to_string(), }; let msg = format!("{}", err); assert!(msg.contains("Incompatible fluids")); assert!(msg.contains("R134a")); assert!(msg.contains("Water")); let err = ConnectionError::PressureMismatch { from_pressure: 100000.0, to_pressure: 200000.0, tolerance: 10.0, }; let msg = format!("{}", err); assert!(msg.contains("100000 Pa")); assert!(msg.contains("200000 Pa")); assert!(msg.contains("tolerance")); } #[test] fn test_pressure_averaging_on_connection() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let (connected1, connected2) = port1.connect(port2).unwrap(); assert_relative_eq!( connected1.pressure().to_pascals(), connected2.pressure().to_pascals(), epsilon = 1e-10 ); } #[test] fn test_pressure_tolerance_with_small_difference() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100005.0), Enthalpy::from_joules_per_kg(400000.0), ); let result = port1.connect(port2); assert!( result.is_ok(), "5 Pa difference should be within tolerance for 100 kPa pressure" ); } #[test] fn test_clone_disconnected_port() { let port1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100000.0), Enthalpy::from_joules_per_kg(400000.0), ); let port2 = port1.clone(); assert_eq!(port1, port2); } #[test] fn test_fluid_id_equality() { let f1 = FluidId::new("R134a"); let f2 = FluidId::new("R134a"); let f3 = FluidId::new("Water"); assert_eq!(f1, f2); assert_ne!(f1, f3); } #[test] fn test_already_connected_error() { let err = ConnectionError::AlreadyConnected; let msg = format!("{}", err); assert!(msg.contains("already connected")); } #[test] fn test_cycle_detected_error() { let err = ConnectionError::CycleDetected; let msg = format!("{}", err); assert!(msg.contains("cycle")); } #[test] fn test_validate_port_continuity_ok() { let p1 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), ); let p2 = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), ); let (c1, c2) = p1.connect(p2).unwrap(); assert!(validate_port_continuity(&c1, &c2).is_ok()); assert!(validate_port_continuity(&c2, &c1).is_ok()); } #[test] fn test_validate_port_continuity_incompatible_fluid() { let (r134a, _) = Port::new( FluidId::new("R134a"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), ) .connect(Port::new( FluidId::new("R134a"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), )) .unwrap(); let (water, _) = Port::new( FluidId::new("Water"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), ) .connect(Port::new( FluidId::new("Water"), Pressure::from_pascals(100_000.0), Enthalpy::from_joules_per_kg(400_000.0), )) .unwrap(); assert!(matches!( validate_port_continuity(&r134a, &water), Err(ConnectionError::IncompatibleFluid { .. }) )); } }