feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,753 @@
//! 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<Disconnected> --connect()--> Port<Connected>
//! ↑ │
//! └───────── (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<String>) -> 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<Disconnected>` 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<Disconnected> = Port::new(
/// FluidId::new("R134a"),
/// Pressure::from_bar(1.0),
/// Enthalpy::from_joules_per_kg(400000.0)
/// );
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct Port<State> {
fluid_id: FluidId,
pressure: Pressure,
enthalpy: Enthalpy,
_state: PhantomData<State>,
}
/// 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<Disconnected> {
/// 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<Connected>, Port<Connected>)` 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<Disconnected>,
) -> Result<(Port<Connected>, Port<Connected>), 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<Connected> {
/// 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<Connected>;
/// 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 { .. })
));
}
}