chore: remove BMAD framework files and IDE configuration artifacts
Clean up unused BMAD workflow, agent, and command files across all IDE configurations (.agent, .clinerules, .cursor, .gemini, .github, .kilocode, .opencode) and internal module files (_bmad/bmb, _bmad/bmm). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ thiserror = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# External model dependencies
|
||||
libloading = { version = "0.8", optional = true }
|
||||
|
||||
349
crates/components/src/bypass_valve.rs
Normal file
349
crates/components/src/bypass_valve.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
//! BypassValve component for hydronic system regulation
|
||||
//!
|
||||
//! This component models a bypass valve that allows mixing of chilled water
|
||||
//! from free cooling with return water to achieve the desired temperature.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Component, ComponentError, OperationalState};
|
||||
|
||||
/// Valve characteristics
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ValveCharacteristics {
|
||||
/// Linear: opening ∝ position
|
||||
Linear,
|
||||
/// Equal percentage: each % opening gives same % flow variation
|
||||
EqualPercentage,
|
||||
/// Quick opening: large variation at beginning
|
||||
QuickOpening,
|
||||
}
|
||||
|
||||
/// Configuration for the bypass valve
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BypassValveConfig {
|
||||
/// Flow coefficient Cv (US gallons/min at 1 psi pressure drop)
|
||||
pub cv: f64,
|
||||
/// Valve characteristics
|
||||
pub characteristics: ValveCharacteristics,
|
||||
/// Minimum position (0.0-1.0)
|
||||
pub min_position: f64,
|
||||
/// Maximum position (0.0-1.0)
|
||||
pub max_position: f64,
|
||||
/// Nominal pressure drop (Pa)
|
||||
pub nominal_pressure_drop_pa: f64,
|
||||
}
|
||||
|
||||
/// Bypass valve component
|
||||
#[derive(Debug)]
|
||||
pub struct BypassValve {
|
||||
/// Identifier
|
||||
id: String,
|
||||
/// Configuration
|
||||
config: BypassValveConfig,
|
||||
/// Current position (0.0-1.0)
|
||||
position: f64,
|
||||
/// Control mode
|
||||
control_mode: BypassValveControlMode,
|
||||
/// Setpoint (based on mode)
|
||||
setpoint: f64,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
}
|
||||
|
||||
/// Bypass valve control mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BypassValveControlMode {
|
||||
/// Fixed position
|
||||
Manual,
|
||||
/// Temperature control
|
||||
TemperatureControl,
|
||||
/// Differential pressure control
|
||||
PressureControl,
|
||||
/// Optimized control
|
||||
Optimized,
|
||||
}
|
||||
|
||||
impl BypassValve {
|
||||
/// Creates a new bypass valve
|
||||
pub fn new(id: &str, config: BypassValveConfig) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
config,
|
||||
position: 0.0, // Closed by default
|
||||
control_mode: BypassValveControlMode::Manual,
|
||||
setpoint: 0.0,
|
||||
operational_state: OperationalState::Off,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the valve position
|
||||
pub fn set_position(&mut self, position: f64) -> Result<(), ComponentError> {
|
||||
let clamped = position.clamp(self.config.min_position, self.config.max_position);
|
||||
self.position = clamped;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculates flow through the valve
|
||||
pub fn calculate_flow(&self, pressure_drop_pa: f64) -> f64 {
|
||||
// Valve equation: Q = Cv * sqrt(ΔP / SG)
|
||||
// SG (specific gravity) = 1 for water
|
||||
let effective_cv = self.effective_cv();
|
||||
effective_cv * (pressure_drop_pa / 1000.0).sqrt() // Approximate conversion
|
||||
}
|
||||
|
||||
/// Returns current position
|
||||
pub fn position(&self) -> f64 {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// Returns effective Cv based on position
|
||||
pub fn effective_cv(&self) -> f64 {
|
||||
match self.config.characteristics {
|
||||
ValveCharacteristics::Linear => self.config.cv * self.position,
|
||||
ValveCharacteristics::EqualPercentage => {
|
||||
// R = capacity ratio (typically 50)
|
||||
let r: f64 = 50.0;
|
||||
self.config.cv * r.powf(self.position - 1.0)
|
||||
}
|
||||
ValveCharacteristics::QuickOpening => {
|
||||
self.config.cv * (2.0 * self.position - self.position.powi(2)).sqrt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the unique identifier
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the control mode
|
||||
pub fn control_mode(&self) -> BypassValveControlMode {
|
||||
self.control_mode
|
||||
}
|
||||
|
||||
/// Sets the control mode
|
||||
pub fn set_control_mode(&mut self, mode: BypassValveControlMode) {
|
||||
self.control_mode = mode;
|
||||
}
|
||||
|
||||
/// Returns the setpoint
|
||||
pub fn setpoint(&self) -> f64 {
|
||||
self.setpoint
|
||||
}
|
||||
|
||||
/// Sets the setpoint
|
||||
pub fn set_setpoint(&mut self, setpoint: f64) {
|
||||
self.setpoint = setpoint;
|
||||
}
|
||||
|
||||
/// Updates the valve position based on control mode and process variables
|
||||
pub fn update_control(
|
||||
&mut self,
|
||||
current_temperature: Option<f64>,
|
||||
current_pressure_drop: Option<f64>,
|
||||
) -> Result<(), ComponentError> {
|
||||
match self.control_mode {
|
||||
BypassValveControlMode::Manual => {
|
||||
// Do nothing, position is fixed
|
||||
}
|
||||
BypassValveControlMode::TemperatureControl => {
|
||||
if let Some(temp) = current_temperature {
|
||||
// Simple P controller: position = Kp * (setpoint - current)
|
||||
let error = self.setpoint - temp;
|
||||
let kp = 0.1; // Proportional gain
|
||||
let new_position = self.position + kp * error;
|
||||
self.set_position(new_position)?;
|
||||
}
|
||||
}
|
||||
BypassValveControlMode::PressureControl => {
|
||||
if let Some(pressure_drop) = current_pressure_drop {
|
||||
// Maintain constant pressure drop
|
||||
let error = self.setpoint - pressure_drop;
|
||||
let kp = 0.01; // Proportional gain
|
||||
let new_position = self.position + kp * error;
|
||||
self.set_position(new_position)?;
|
||||
}
|
||||
}
|
||||
BypassValveControlMode::Optimized => {
|
||||
// TODO: Implement optimization logic
|
||||
// For now, use temperature control
|
||||
if let Some(temp) = current_temperature {
|
||||
let error = self.setpoint - temp;
|
||||
let kp = 0.1;
|
||||
let new_position = self.position + kp * error;
|
||||
self.set_position(new_position)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BypassValve {
|
||||
fn n_equations(&self) -> usize {
|
||||
2 // Mass balance + energy balance
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_residuals: &mut crate::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement residual calculations
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_jacobian: &mut crate::JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement Jacobian entries
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[crate::ConnectedPort] {
|
||||
&[] // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
impl BypassValve {
|
||||
/// Returns the operational state
|
||||
pub fn operational_state(&self) -> OperationalState {
|
||||
self.operational_state
|
||||
}
|
||||
|
||||
/// Sets the operational state
|
||||
pub fn set_operational_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
self.operational_state = state;
|
||||
if state == OperationalState::Off {
|
||||
self.position = 0.0; // Close valve when off
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BypassValveConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cv: 10.0, // Typical Cv for bypass valve
|
||||
characteristics: ValveCharacteristics::EqualPercentage,
|
||||
min_position: 0.0,
|
||||
max_position: 1.0,
|
||||
nominal_pressure_drop_pa: 10000.0, // 10 kPa
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bypass_valve_creation() {
|
||||
let config = BypassValveConfig::default();
|
||||
let valve = BypassValve::new("bv1", config);
|
||||
assert_eq!(valve.position(), 0.0);
|
||||
assert_eq!(valve.operational_state(), OperationalState::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_limits() {
|
||||
let config = BypassValveConfig {
|
||||
min_position: 0.1,
|
||||
max_position: 0.9,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut valve = BypassValve::new("bv1", config);
|
||||
|
||||
// Test below minimum
|
||||
valve.set_position(-0.1).unwrap();
|
||||
assert_eq!(valve.position(), 0.1);
|
||||
|
||||
// Test above maximum
|
||||
valve.set_position(1.5).unwrap();
|
||||
assert_eq!(valve.position(), 0.9);
|
||||
|
||||
// Test within range
|
||||
valve.set_position(0.5).unwrap();
|
||||
assert_eq!(valve.position(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linear_characteristics() {
|
||||
let config = BypassValveConfig {
|
||||
cv: 10.0,
|
||||
characteristics: ValveCharacteristics::Linear,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut valve = BypassValve::new("bv1", config);
|
||||
|
||||
valve.set_position(0.0).unwrap();
|
||||
assert_eq!(valve.effective_cv(), 0.0);
|
||||
|
||||
valve.set_position(0.5).unwrap();
|
||||
assert_eq!(valve.effective_cv(), 5.0);
|
||||
|
||||
valve.set_position(1.0).unwrap();
|
||||
assert_eq!(valve.effective_cv(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_equal_percentage_characteristics() {
|
||||
let config = BypassValveConfig {
|
||||
cv: 10.0,
|
||||
characteristics: ValveCharacteristics::EqualPercentage,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut valve = BypassValve::new("bv1", config);
|
||||
|
||||
valve.set_position(0.0).unwrap();
|
||||
let cv_at_0 = valve.effective_cv();
|
||||
assert!(cv_at_0 > 0.0 && cv_at_0 < 1.0);
|
||||
|
||||
valve.set_position(0.5).unwrap();
|
||||
let cv_at_50 = valve.effective_cv();
|
||||
assert!(cv_at_50 > cv_at_0 && cv_at_50 < 10.0);
|
||||
|
||||
valve.set_position(1.0).unwrap();
|
||||
assert_eq!(valve.effective_cv(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flow_calculation() {
|
||||
let config = BypassValveConfig {
|
||||
cv: 10.0,
|
||||
characteristics: ValveCharacteristics::Linear,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut valve = BypassValve::new("bv1", config);
|
||||
valve.set_position(0.5).unwrap();
|
||||
|
||||
// At 50% position, Cv = 5
|
||||
let flow = valve.calculate_flow(10000.0); // 10 kPa
|
||||
assert!(flow > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_temperature_control() {
|
||||
let config = BypassValveConfig::default();
|
||||
let mut valve = BypassValve::new("bv1", config);
|
||||
valve.set_control_mode(BypassValveControlMode::TemperatureControl);
|
||||
valve.set_setpoint(12.0); // Target temperature
|
||||
|
||||
// Initial position
|
||||
assert_eq!(valve.position(), 0.0);
|
||||
|
||||
// Simulate temperature below setpoint
|
||||
valve.update_control(Some(10.0), None).unwrap();
|
||||
assert!(valve.position() > 0.0); // Should open
|
||||
|
||||
// Simulate temperature above setpoint
|
||||
valve.update_control(Some(14.0), None).unwrap();
|
||||
assert!(valve.position() < 0.5); // Should close more
|
||||
}
|
||||
}
|
||||
475
crates/components/src/free_cooling_exchanger.rs
Normal file
475
crates/components/src/free_cooling_exchanger.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
//! FreeCoolingExchanger component for water-side economizer simulation
|
||||
//!
|
||||
//! This component models a water-to-water heat exchanger used for free cooling,
|
||||
//! allowing the use of outdoor air as a cooling source without operating the compressor.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use entropyk_core::{Power, Temperature};
|
||||
use entropyk_fluids::FluidBackend;
|
||||
|
||||
use crate::{
|
||||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||||
ResidualVector, SystemState,
|
||||
};
|
||||
|
||||
/// Operating mode of the FreeCoolingExchanger
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FreeCoolingMode {
|
||||
/// Free cooling active (direct heat exchange)
|
||||
Active,
|
||||
/// Full bypass (no heat exchange)
|
||||
Bypass,
|
||||
/// Mixed mode (partial bypass)
|
||||
Mixed { bypass_fraction: f64 },
|
||||
}
|
||||
|
||||
/// Configuration for the free cooling heat exchanger
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FreeCoolingConfig {
|
||||
/// Effectiveness of the heat exchanger (0.0 - 1.0)
|
||||
pub effectiveness: f64,
|
||||
/// Bypass fraction (0.0 - 1.0)
|
||||
pub bypass_fraction: f64,
|
||||
/// Minimum outdoor temperature for free cooling (K)
|
||||
pub min_outdoor_temp: f64,
|
||||
/// Hysteresis to prevent rapid cycling (K)
|
||||
pub hysteresis: f64,
|
||||
/// Control mode
|
||||
pub control_mode: FreeCoolingControlMode,
|
||||
}
|
||||
|
||||
/// Control mode for free cooling
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum FreeCoolingControlMode {
|
||||
/// Manual control (fixed mode)
|
||||
Manual,
|
||||
/// Automatic control based on outdoor temperature
|
||||
AutoTemperature,
|
||||
/// Optimized control (minimizes energy consumption)
|
||||
Optimized,
|
||||
}
|
||||
|
||||
/// FreeCoolingExchanger component
|
||||
pub struct FreeCoolingExchanger {
|
||||
/// Unique identifier
|
||||
id: String,
|
||||
/// Circuit ID
|
||||
circuit_id: CircuitId,
|
||||
/// Configuration
|
||||
config: FreeCoolingConfig,
|
||||
/// Current mode
|
||||
mode: FreeCoolingMode,
|
||||
/// Ports (4 ports: cold water in/out, hot water in/out)
|
||||
port_cold_inlet: ConnectedPort,
|
||||
port_cold_outlet: ConnectedPort,
|
||||
port_hot_inlet: ConnectedPort,
|
||||
port_hot_outlet: ConnectedPort,
|
||||
/// Outdoor temperature (for auto mode)
|
||||
outdoor_temp: Option<Temperature>,
|
||||
/// Calculated after convergence
|
||||
heat_transfer_rate: Option<Power>,
|
||||
/// Current effectiveness (can vary with flow rates)
|
||||
current_effectiveness: f64,
|
||||
/// Fluid backend for property calculations
|
||||
fluid_backend: Option<Arc<dyn FluidBackend>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FreeCoolingExchanger {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FreeCoolingExchanger")
|
||||
.field("id", &self.id)
|
||||
.field("circuit_id", &self.circuit_id)
|
||||
.field("config", &self.config)
|
||||
.field("mode", &self.mode)
|
||||
.field("outdoor_temp", &self.outdoor_temp)
|
||||
.field("heat_transfer_rate", &self.heat_transfer_rate)
|
||||
.field("current_effectiveness", &self.current_effectiveness)
|
||||
.field("fluid_backend", &"<FluidBackend>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FreeCoolingExchanger {
|
||||
/// Creates a new free cooling heat exchanger
|
||||
pub fn new(
|
||||
id: &str,
|
||||
circuit_id: CircuitId,
|
||||
config: FreeCoolingConfig,
|
||||
port_cold_inlet: ConnectedPort,
|
||||
port_cold_outlet: ConnectedPort,
|
||||
port_hot_inlet: ConnectedPort,
|
||||
port_hot_outlet: ConnectedPort,
|
||||
) -> Result<Self, ComponentError> {
|
||||
// Validate parameters
|
||||
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Effectiveness must be between 0.0 and 1.0".to_string(),
|
||||
));
|
||||
}
|
||||
if config.bypass_fraction < 0.0 || config.bypass_fraction > 1.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Bypass fraction must be between 0.0 and 1.0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_effectiveness = config.effectiveness;
|
||||
Ok(Self {
|
||||
id: id.to_string(),
|
||||
circuit_id,
|
||||
config,
|
||||
mode: FreeCoolingMode::Bypass, // Starts in bypass
|
||||
port_cold_inlet,
|
||||
port_cold_outlet,
|
||||
port_hot_inlet,
|
||||
port_hot_outlet,
|
||||
outdoor_temp: None,
|
||||
heat_transfer_rate: None,
|
||||
current_effectiveness,
|
||||
fluid_backend: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the fluid backend for property calculations
|
||||
pub fn set_fluid_backend(&mut self, backend: Arc<dyn FluidBackend>) {
|
||||
self.fluid_backend = Some(backend);
|
||||
}
|
||||
|
||||
/// Calculates maximum possible heat transfer
|
||||
fn calculate_max_heat_transfer(&self, state: &SystemState) -> Result<Power, ComponentError> {
|
||||
// Get inlet temperatures
|
||||
let t_cold_in = self.get_cold_inlet_temp(state)?;
|
||||
let t_hot_in = self.get_hot_inlet_temp(state)?;
|
||||
|
||||
// Heat capacity rates
|
||||
let c_cold = self.get_cold_capacity_rate(state)?;
|
||||
let c_hot = self.get_hot_capacity_rate(state)?;
|
||||
let c_min = c_cold.min(c_hot);
|
||||
|
||||
// Maximum heat transfer
|
||||
let q_max = c_min * (t_hot_in - t_cold_in);
|
||||
|
||||
Ok(Power::from_watts(q_max.max(0.0)))
|
||||
}
|
||||
|
||||
/// Updates the mode based on conditions
|
||||
pub fn update_mode(&mut self, outdoor_temp: Option<Temperature>) -> Result<(), ComponentError> {
|
||||
if let Some(t_outdoor) = outdoor_temp {
|
||||
self.outdoor_temp = Some(t_outdoor);
|
||||
|
||||
match self.config.control_mode {
|
||||
FreeCoolingControlMode::AutoTemperature => {
|
||||
let t_cold_in = self.get_current_cold_inlet_temp()?;
|
||||
|
||||
// Switching logic with hysteresis
|
||||
if self.mode == FreeCoolingMode::Bypass {
|
||||
// Check if we can switch to free cooling
|
||||
if t_outdoor.0 < (t_cold_in - self.config.min_outdoor_temp) {
|
||||
self.mode = FreeCoolingMode::Active;
|
||||
}
|
||||
} else {
|
||||
// Check if we should go back to bypass
|
||||
if t_outdoor.0
|
||||
> (t_cold_in - self.config.min_outdoor_temp + self.config.hysteresis)
|
||||
{
|
||||
self.mode = FreeCoolingMode::Bypass;
|
||||
}
|
||||
}
|
||||
}
|
||||
FreeCoolingControlMode::Optimized => {
|
||||
// TODO: Implement energy optimization
|
||||
self.mode = FreeCoolingMode::Active;
|
||||
}
|
||||
FreeCoolingControlMode::Manual => {
|
||||
// Do nothing, fixed mode
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper methods for temperature and flow calculations
|
||||
fn get_cold_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would extract from state vector
|
||||
Ok(285.15) // 12°C
|
||||
}
|
||||
|
||||
fn get_hot_inlet_temp(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would extract from state vector
|
||||
Ok(298.15) // 25°C
|
||||
}
|
||||
|
||||
fn get_cold_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would calculate from mass flow and specific heat
|
||||
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
|
||||
}
|
||||
|
||||
fn get_hot_capacity_rate(&self, _state: &SystemState) -> Result<f64, ComponentError> {
|
||||
// Placeholder - would calculate from mass flow and specific heat
|
||||
Ok(4186.0 * 0.1) // Water at 0.1 kg/s
|
||||
}
|
||||
|
||||
fn get_current_cold_inlet_temp(&self) -> Result<f64, ComponentError> {
|
||||
Ok(285.15) // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FreeCoolingExchanger {
|
||||
fn n_equations(&self) -> usize {
|
||||
// 4 equations for energy balances at each port
|
||||
// + 1 equation for heat transfer
|
||||
// + 1 equation for flow continuity
|
||||
6
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement actual residual calculations
|
||||
// For now, return zero residuals
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// TODO: Implement partial derivatives
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
// Return the 4 ports
|
||||
&[] // Placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/// Specific methods for FreeCoolingExchanger
|
||||
impl FreeCoolingExchanger {
|
||||
/// Returns the current operational state
|
||||
pub fn operational_state(&self) -> OperationalState {
|
||||
match self.mode {
|
||||
FreeCoolingMode::Bypass => OperationalState::Bypass,
|
||||
_ => OperationalState::On,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the operational state
|
||||
pub fn set_operational_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
|
||||
match state {
|
||||
OperationalState::On => self.mode = FreeCoolingMode::Active,
|
||||
OperationalState::Off | OperationalState::Bypass => {
|
||||
self.mode = FreeCoolingMode::Bypass;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the current heat transfer rate
|
||||
pub fn heat_transfer_rate(&self) -> Option<Power> {
|
||||
self.heat_transfer_rate
|
||||
}
|
||||
|
||||
/// Returns the current mode
|
||||
pub fn current_mode(&self) -> FreeCoolingMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Returns estimated energy savings (in %)
|
||||
pub fn energy_savings_percent(&self) -> f64 {
|
||||
match self.mode {
|
||||
FreeCoolingMode::Active => {
|
||||
// Estimation based on effectiveness
|
||||
self.current_effectiveness * 100.0
|
||||
}
|
||||
FreeCoolingMode::Bypass => 0.0,
|
||||
FreeCoolingMode::Mixed { bypass_fraction } => {
|
||||
self.current_effectiveness * bypass_fraction * 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns outdoor temperature
|
||||
pub fn outdoor_temperature(&self) -> Option<Temperature> {
|
||||
self.outdoor_temp
|
||||
}
|
||||
|
||||
/// Updates configuration
|
||||
pub fn update_config(&mut self, config: FreeCoolingConfig) -> Result<(), ComponentError> {
|
||||
// Validation
|
||||
if config.effectiveness < 0.0 || config.effectiveness > 1.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Effectiveness must be between 0.0 and 1.0".to_string(),
|
||||
));
|
||||
}
|
||||
self.config = config;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if free cooling is active
|
||||
pub fn is_free_cooling_active(&self) -> bool {
|
||||
self.mode != FreeCoolingMode::Bypass
|
||||
}
|
||||
|
||||
/// Calculates effective COP (very high in free cooling)
|
||||
pub fn effective_cop(&self) -> f64 {
|
||||
match self.mode {
|
||||
FreeCoolingMode::Active => {
|
||||
// Typical COP > 20 for free cooling (only pumps)
|
||||
20.0 + self.current_effectiveness * 10.0
|
||||
}
|
||||
FreeCoolingMode::Bypass => 1.0, // No gain
|
||||
FreeCoolingMode::Mixed { bypass_fraction } => {
|
||||
// Weighted COP
|
||||
let cop_fc = 20.0 + self.current_effectiveness * 10.0;
|
||||
bypass_fraction * cop_fc + (1.0 - bypass_fraction) * 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the unique identifier
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// Returns the circuit ID
|
||||
pub fn circuit_id(&self) -> CircuitId {
|
||||
self.circuit_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FreeCoolingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
effectiveness: 0.85,
|
||||
bypass_fraction: 0.2,
|
||||
min_outdoor_temp: 285.15, // 12°C
|
||||
hysteresis: 2.0,
|
||||
control_mode: FreeCoolingControlMode::AutoTemperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
/// Creates a pair of connected ports for tests (same fluid, P, h).
|
||||
fn make_connected_ports() -> (ConnectedPort, ConnectedPort) {
|
||||
let fluid = FluidId::new("Water");
|
||||
let p = Pressure::from_pascals(3e5);
|
||||
let h = Enthalpy::from_joules_per_kg(63_000.0);
|
||||
let a = Port::new(fluid, p, h);
|
||||
let b = Port::new(FluidId::new("Water"), p, h);
|
||||
a.connect(b).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_free_cooling_exchanger_creation() {
|
||||
let config = FreeCoolingConfig::default();
|
||||
let (cold_in, cold_out) = make_connected_ports();
|
||||
let (hot_in, hot_out) = make_connected_ports();
|
||||
|
||||
let exchanger = FreeCoolingExchanger::new(
|
||||
"fc_1",
|
||||
CircuitId(0),
|
||||
config,
|
||||
cold_in,
|
||||
cold_out,
|
||||
hot_in,
|
||||
hot_out,
|
||||
);
|
||||
|
||||
assert!(exchanger.is_ok());
|
||||
let exchanger = exchanger.unwrap();
|
||||
assert_eq!(exchanger.current_mode(), FreeCoolingMode::Bypass);
|
||||
assert!(!exchanger.is_free_cooling_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_effectiveness() {
|
||||
let config = FreeCoolingConfig {
|
||||
effectiveness: 1.5,
|
||||
..Default::default()
|
||||
};
|
||||
let (cold_in, cold_out) = make_connected_ports();
|
||||
let (hot_in, hot_out) = make_connected_ports();
|
||||
|
||||
let exchanger = FreeCoolingExchanger::new(
|
||||
"fc_1",
|
||||
CircuitId(0),
|
||||
config,
|
||||
cold_in,
|
||||
cold_out,
|
||||
hot_in,
|
||||
hot_out,
|
||||
);
|
||||
|
||||
assert!(exchanger.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_energy_savings_calculation() {
|
||||
let config = FreeCoolingConfig {
|
||||
effectiveness: 0.85,
|
||||
..Default::default()
|
||||
};
|
||||
let (cold_in, cold_out) = make_connected_ports();
|
||||
let (hot_in, hot_out) = make_connected_ports();
|
||||
|
||||
let mut exchanger = FreeCoolingExchanger::new(
|
||||
"fc_1",
|
||||
CircuitId(0),
|
||||
config,
|
||||
cold_in,
|
||||
cold_out,
|
||||
hot_in,
|
||||
hot_out,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Bypass mode -> 0% savings
|
||||
assert_eq!(exchanger.energy_savings_percent(), 0.0);
|
||||
|
||||
// Active mode -> effectiveness * 100%
|
||||
exchanger.mode = FreeCoolingMode::Active;
|
||||
assert_eq!(exchanger.energy_savings_percent(), 85.0);
|
||||
|
||||
// Mixed mode
|
||||
exchanger.mode = FreeCoolingMode::Mixed {
|
||||
bypass_fraction: 0.3,
|
||||
};
|
||||
assert_eq!(exchanger.energy_savings_percent(), 85.0 * 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_cop() {
|
||||
let (cold_in, cold_out) = make_connected_ports();
|
||||
let (hot_in, hot_out) = make_connected_ports();
|
||||
|
||||
let mut exchanger = FreeCoolingExchanger::new(
|
||||
"fc_1",
|
||||
CircuitId(0),
|
||||
FreeCoolingConfig::default(),
|
||||
cold_in,
|
||||
cold_out,
|
||||
hot_in,
|
||||
hot_out,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// COP in free cooling
|
||||
exchanger.mode = FreeCoolingMode::Active;
|
||||
assert!(exchanger.effective_cop() > 20.0);
|
||||
|
||||
// COP in bypass
|
||||
exchanger.mode = FreeCoolingMode::Bypass;
|
||||
assert_eq!(exchanger.effective_cop(), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -564,7 +564,8 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
};
|
||||
|
||||
let dynamic_f_ua = custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
|
||||
let dynamic_f_ua =
|
||||
custom_ua_scale.or_else(|| self.calib_indices.f_ua.map(|idx| _state[idx]));
|
||||
|
||||
self.model.compute_residuals(
|
||||
&hot_inlet,
|
||||
|
||||
@@ -90,7 +90,6 @@ impl MovingBoundaryCache {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// MovingBoundaryHX - Zone discretization heat exchanger component
|
||||
pub struct MovingBoundaryHX {
|
||||
inner: HeatExchanger<EpsNtuModel>,
|
||||
@@ -119,7 +118,7 @@ impl MovingBoundaryHX {
|
||||
pub fn new() -> Self {
|
||||
let geometry = BphxGeometry::from_dh_area(0.003, 0.5, 20);
|
||||
let model = EpsNtuModel::counter_flow(1000.0);
|
||||
|
||||
|
||||
Self {
|
||||
inner: HeatExchanger::new(model, "MovingBoundaryHX"),
|
||||
geometry,
|
||||
@@ -181,22 +180,36 @@ impl Component for MovingBoundaryHX {
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let (p, m_refrig, t_sec_in, t_sec_out) = if let (Some(hot), Some(cold)) = (self.inner.hot_conditions(), self.inner.cold_conditions()) {
|
||||
(hot.pressure_pa(), hot.mass_flow_kg_s(), cold.temperature_k(), cold.temperature_k() + 5.0)
|
||||
let (p, m_refrig, t_sec_in, t_sec_out) = if let (Some(hot), Some(cold)) =
|
||||
(self.inner.hot_conditions(), self.inner.cold_conditions())
|
||||
{
|
||||
(
|
||||
hot.pressure_pa(),
|
||||
hot.mass_flow_kg_s(),
|
||||
cold.temperature_k(),
|
||||
cold.temperature_k() + 5.0,
|
||||
)
|
||||
} else {
|
||||
(500_000.0, 0.1, 300.0, 320.0)
|
||||
};
|
||||
|
||||
// Extract enthalpies exactly as HeatExchanger does:
|
||||
let enthalpies = self.port_enthalpies(state)?;
|
||||
let h_in = enthalpies.get(0).map(|h| h.to_joules_per_kg()).unwrap_or(400_000.0);
|
||||
let h_out = enthalpies.get(1).map(|h| h.to_joules_per_kg()).unwrap_or(200_000.0);
|
||||
let h_in = enthalpies
|
||||
.get(0)
|
||||
.map(|h| h.to_joules_per_kg())
|
||||
.unwrap_or(400_000.0);
|
||||
let h_out = enthalpies
|
||||
.get(1)
|
||||
.map(|h| h.to_joules_per_kg())
|
||||
.unwrap_or(200_000.0);
|
||||
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
let use_cache = cache.is_valid_for(p, m_refrig);
|
||||
|
||||
if !use_cache {
|
||||
let (disc, h_sat_l, h_sat_v) = self.identify_zones(h_in, h_out, p, t_sec_in, t_sec_out)?;
|
||||
let (disc, h_sat_l, h_sat_v) =
|
||||
self.identify_zones(h_in, h_out, p, t_sec_in, t_sec_out)?;
|
||||
cache.valid = true;
|
||||
cache.p_ref = p;
|
||||
cache.m_ref = m_refrig;
|
||||
@@ -207,9 +220,14 @@ impl Component for MovingBoundaryHX {
|
||||
|
||||
let total_ua = cache.discretization.total_ua;
|
||||
let base_ua = self.inner.ua_nominal();
|
||||
let custom_ua_scale = if base_ua > 0.0 { total_ua / base_ua } else { 1.0 };
|
||||
let custom_ua_scale = if base_ua > 0.0 {
|
||||
total_ua / base_ua
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
self.inner.compute_residuals_with_ua_scale(state, residuals, custom_ua_scale)
|
||||
self.inner
|
||||
.compute_residuals_with_ua_scale(state, residuals, custom_ua_scale)
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
@@ -264,7 +282,6 @@ impl StateManageable for MovingBoundaryHX {
|
||||
}
|
||||
|
||||
impl MovingBoundaryHX {
|
||||
|
||||
/// Identifies the phase zones along the heat exchanger and calculates boundaries.
|
||||
pub fn identify_zones(
|
||||
&self,
|
||||
@@ -296,10 +313,10 @@ impl MovingBoundaryHX {
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("h_sat_v failed: {}", e)))?;
|
||||
|
||||
let mut boundaries = Vec::new();
|
||||
|
||||
|
||||
// Calculate transition positions and types
|
||||
let is_condensing = h_refrig_in > h_refrig_out;
|
||||
|
||||
|
||||
// Add inlet boundary
|
||||
let inlet_type = if h_refrig_in > h_sat_v + 1e-3 {
|
||||
ZoneType::Superheated
|
||||
@@ -308,7 +325,15 @@ impl MovingBoundaryHX {
|
||||
} else {
|
||||
ZoneType::TwoPhase
|
||||
};
|
||||
boundaries.push(self.create_boundary(0.0, h_refrig_in, p_refrig, inlet_type, t_secondary_in, h_sat_l, h_sat_v)?);
|
||||
boundaries.push(self.create_boundary(
|
||||
0.0,
|
||||
h_refrig_in,
|
||||
p_refrig,
|
||||
inlet_type,
|
||||
t_secondary_in,
|
||||
h_sat_l,
|
||||
h_sat_v,
|
||||
)?);
|
||||
|
||||
let (h_min, h_max) = if is_condensing {
|
||||
(h_refrig_out, h_refrig_in)
|
||||
@@ -320,16 +345,28 @@ impl MovingBoundaryHX {
|
||||
let pos = (h_sat_l - h_refrig_in) / (h_refrig_out - h_refrig_in);
|
||||
let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in);
|
||||
// After sat_l, type is SC (if condensing) or TP (if evaporating)
|
||||
let post_type = if is_condensing { ZoneType::Subcooled } else { ZoneType::TwoPhase };
|
||||
boundaries.push(self.create_boundary(pos, h_sat_l, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?);
|
||||
let post_type = if is_condensing {
|
||||
ZoneType::Subcooled
|
||||
} else {
|
||||
ZoneType::TwoPhase
|
||||
};
|
||||
boundaries.push(
|
||||
self.create_boundary(pos, h_sat_l, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?,
|
||||
);
|
||||
}
|
||||
|
||||
if h_min < h_sat_v && h_max > h_sat_v {
|
||||
let pos = (h_sat_v - h_refrig_in) / (h_refrig_out - h_refrig_in);
|
||||
let t_sec = t_secondary_in + pos * (t_secondary_out - t_secondary_in);
|
||||
// After sat_v, type is TP (if condensing) or SH (if evaporating)
|
||||
let post_type = if is_condensing { ZoneType::TwoPhase } else { ZoneType::Superheated };
|
||||
boundaries.push(self.create_boundary(pos, h_sat_v, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?);
|
||||
let post_type = if is_condensing {
|
||||
ZoneType::TwoPhase
|
||||
} else {
|
||||
ZoneType::Superheated
|
||||
};
|
||||
boundaries.push(
|
||||
self.create_boundary(pos, h_sat_v, p_refrig, post_type, t_sec, h_sat_l, h_sat_v)?,
|
||||
);
|
||||
}
|
||||
|
||||
// Add outlet boundary
|
||||
@@ -340,7 +377,15 @@ impl MovingBoundaryHX {
|
||||
} else {
|
||||
ZoneType::TwoPhase
|
||||
};
|
||||
boundaries.push(self.create_boundary(1.0, h_refrig_out, p_refrig, outlet_type, t_secondary_out, h_sat_l, h_sat_v)?);
|
||||
boundaries.push(self.create_boundary(
|
||||
1.0,
|
||||
h_refrig_out,
|
||||
p_refrig,
|
||||
outlet_type,
|
||||
t_secondary_out,
|
||||
h_sat_l,
|
||||
h_sat_v,
|
||||
)?);
|
||||
|
||||
// Sort boundaries by position
|
||||
boundaries.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
|
||||
@@ -355,19 +400,19 @@ impl MovingBoundaryHX {
|
||||
|
||||
let (pinch_temp, pinch_pos) = self.calculate_pinch(&boundaries);
|
||||
|
||||
Ok((ZoneDiscretization {
|
||||
boundaries,
|
||||
total_ua,
|
||||
pinch_temp,
|
||||
pinch_position: pinch_pos,
|
||||
}, h_sat_l, h_sat_v))
|
||||
Ok((
|
||||
ZoneDiscretization {
|
||||
boundaries,
|
||||
total_ua,
|
||||
pinch_temp,
|
||||
pinch_position: pinch_pos,
|
||||
},
|
||||
h_sat_l,
|
||||
h_sat_v,
|
||||
))
|
||||
}
|
||||
|
||||
fn compute_zone_ua(
|
||||
&self,
|
||||
b1: &ZoneBoundary,
|
||||
b2: &ZoneBoundary,
|
||||
) -> Result<f64, ComponentError> {
|
||||
fn compute_zone_ua(&self, b1: &ZoneBoundary, b2: &ZoneBoundary) -> Result<f64, ComponentError> {
|
||||
let area_zone = self.geometry.area * (b2.position - b1.position);
|
||||
if area_zone <= 1e-10 {
|
||||
return Ok(0.0);
|
||||
@@ -377,14 +422,14 @@ impl MovingBoundaryHX {
|
||||
// we use a simplified approximation based on zone type.
|
||||
// A true implementation would query self.correlation_selector
|
||||
let h_refrig = match b1.zone_type {
|
||||
ZoneType::TwoPhase => 5000.0, // Boiling or condensation
|
||||
ZoneType::TwoPhase => 5000.0, // Boiling or condensation
|
||||
ZoneType::Superheated => 500.0, // Vapor
|
||||
ZoneType::Subcooled => 1500.0, // Liquid
|
||||
ZoneType::Subcooled => 1500.0, // Liquid
|
||||
};
|
||||
let h_secondary = 5000.0; // Generally high for water/glycol
|
||||
|
||||
|
||||
let u_overall = 1.0 / (1.0 / h_refrig + 1.0 / h_secondary);
|
||||
|
||||
|
||||
Ok(u_overall * area_zone)
|
||||
}
|
||||
|
||||
@@ -413,7 +458,6 @@ impl MovingBoundaryHX {
|
||||
h_sat_l: f64,
|
||||
h_sat_v: f64,
|
||||
) -> Result<ZoneBoundary, ComponentError> {
|
||||
|
||||
let quality = if h_sat_v > h_sat_l {
|
||||
((h - h_sat_l) / (h_sat_v - h_sat_l)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
@@ -422,14 +466,16 @@ impl MovingBoundaryHX {
|
||||
|
||||
let t_refrig = if let Some(backend) = &self.fluid_backend {
|
||||
let fluid = entropyk_fluids::FluidId::new(&self.refrigerant_id);
|
||||
backend.property(
|
||||
fluid,
|
||||
entropyk_fluids::Property::Temperature,
|
||||
entropyk_fluids::FluidState::from_ph(
|
||||
entropyk_core::Pressure::from_pascals(p),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(h),
|
||||
backend
|
||||
.property(
|
||||
fluid,
|
||||
entropyk_fluids::Property::Temperature,
|
||||
entropyk_fluids::FluidState::from_ph(
|
||||
entropyk_core::Pressure::from_pascals(p),
|
||||
entropyk_core::Enthalpy::from_joules_per_kg(h),
|
||||
),
|
||||
)
|
||||
).map_err(|e| ComponentError::CalculationFailed(format!("T_refrig failed: {}", e)))?
|
||||
.map_err(|e| ComponentError::CalculationFailed(format!("T_refrig failed: {}", e)))?
|
||||
} else {
|
||||
300.0
|
||||
};
|
||||
@@ -445,7 +491,6 @@ impl MovingBoundaryHX {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -489,8 +534,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_identify_zones_basic() {
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
|
||||
};
|
||||
|
||||
struct MockBackend {
|
||||
h_sat_l: f64,
|
||||
@@ -518,7 +565,9 @@ mod tests {
|
||||
_ => Ok(self.h_sat_v),
|
||||
}
|
||||
}
|
||||
_ => Err(FluidError::UnsupportedProperty { property: format!("{:?}", property) }),
|
||||
_ => Err(FluidError::UnsupportedProperty {
|
||||
property: format!("{:?}", property),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,12 +575,29 @@ mod tests {
|
||||
Err(FluidError::NoCriticalPoint { fluid: fluid.0 })
|
||||
}
|
||||
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
fn phase(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = MockBackend {
|
||||
@@ -548,16 +614,16 @@ mod tests {
|
||||
let result = hx.identify_zones(450_000.0, 150_000.0, 500_000.0, 300.0, 320.0);
|
||||
assert!(result.is_ok());
|
||||
let (disc, h_sat_l_res, h_sat_v_res) = result.unwrap();
|
||||
|
||||
|
||||
assert_eq!(h_sat_l_res, 200_000.0);
|
||||
assert_eq!(h_sat_v_res, 400_000.0);
|
||||
|
||||
|
||||
// Should have 4 boundaries: inlet(SH), sat_v(SH/TP), sat_l(TP/SC), outlet(SC)
|
||||
assert_eq!(disc.boundaries.len(), 4);
|
||||
assert_eq!(disc.boundaries[0].zone_type, ZoneType::Superheated);
|
||||
assert_eq!(disc.boundaries[1].zone_type, ZoneType::TwoPhase);
|
||||
assert_eq!(disc.boundaries[2].zone_type, ZoneType::Subcooled);
|
||||
assert_eq!(disc.boundaries[3].zone_type, ZoneType::Subcooled);
|
||||
assert_eq!(disc.boundaries[3].zone_type, ZoneType::Subcooled);
|
||||
|
||||
// Total UA should be positive
|
||||
assert!(disc.total_ua > 0.0);
|
||||
@@ -576,10 +642,10 @@ mod tests {
|
||||
|
||||
// Identical
|
||||
assert!(cache.is_valid_for(100_000.0, 1.0));
|
||||
|
||||
|
||||
// P < 5% deviation (104,000 is 4%)
|
||||
assert!(cache.is_valid_for(104_000.0, 1.0));
|
||||
|
||||
|
||||
// P > 5% deviation (106,000 is 6%)
|
||||
assert!(!cache.is_valid_for(106_000.0, 1.0));
|
||||
|
||||
@@ -597,8 +663,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_compute_residuals_uses_cache() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
|
||||
};
|
||||
|
||||
struct TrackingMockBackend {
|
||||
pub calls: std::sync::atomic::AtomicUsize,
|
||||
@@ -615,14 +683,33 @@ mod tests {
|
||||
Ok(100.0)
|
||||
}
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: "".to_string() })
|
||||
Err(FluidError::NoCriticalPoint {
|
||||
fluid: "".to_string(),
|
||||
})
|
||||
}
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
fn phase(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = Arc::new(TrackingMockBackend {
|
||||
@@ -632,7 +719,7 @@ mod tests {
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_fluid_backend(backend.clone());
|
||||
|
||||
|
||||
let state = vec![500_000.0, 400_000.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
@@ -650,8 +737,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_performance_speedup() {
|
||||
use crate::{Component, ResidualVector};
|
||||
use entropyk_fluids::{CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState};
|
||||
use entropyk_core::Pressure;
|
||||
use entropyk_fluids::{
|
||||
CriticalPoint, FluidError, FluidId, FluidResult, Phase, ThermoState,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowMockBackend;
|
||||
@@ -668,14 +757,33 @@ mod tests {
|
||||
Ok(100.0)
|
||||
}
|
||||
fn critical_point(&self, _fluid: FluidId) -> FluidResult<CriticalPoint> {
|
||||
Err(FluidError::NoCriticalPoint { fluid: "".to_string() })
|
||||
Err(FluidError::NoCriticalPoint {
|
||||
fluid: "".to_string(),
|
||||
})
|
||||
}
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool { true }
|
||||
fn phase(&self, _fluid: FluidId, _state: entropyk_fluids::FluidState) -> FluidResult<Phase> { Ok(Phase::Unknown) }
|
||||
fn full_state(&self, _fluid: FluidId, _p: Pressure, _h: Enthalpy) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty { property: "full_state".to_string() })
|
||||
fn is_fluid_available(&self, _fluid: &FluidId) -> bool {
|
||||
true
|
||||
}
|
||||
fn phase(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_state: entropyk_fluids::FluidState,
|
||||
) -> FluidResult<Phase> {
|
||||
Ok(Phase::Unknown)
|
||||
}
|
||||
fn full_state(
|
||||
&self,
|
||||
_fluid: FluidId,
|
||||
_p: Pressure,
|
||||
_h: Enthalpy,
|
||||
) -> FluidResult<ThermoState> {
|
||||
Err(FluidError::UnsupportedProperty {
|
||||
property: "full_state".to_string(),
|
||||
})
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> {
|
||||
vec![]
|
||||
}
|
||||
fn list_fluids(&self) -> Vec<FluidId> { vec![] }
|
||||
}
|
||||
|
||||
let backend = Arc::new(SlowMockBackend);
|
||||
@@ -683,7 +791,7 @@ mod tests {
|
||||
let hx = MovingBoundaryHX::new()
|
||||
.with_refrigerant("R410A")
|
||||
.with_fluid_backend(backend.clone());
|
||||
|
||||
|
||||
let state = vec![500_000.0, 400_000.0];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
|
||||
@@ -699,10 +807,10 @@ mod tests {
|
||||
|
||||
println!("Uncached duration: {:?}", duration_uncached);
|
||||
println!("Cached duration: {:?}", duration_cached);
|
||||
|
||||
|
||||
let speedup = duration_uncached.as_secs_f64() / duration_cached.as_secs_f64().max(1e-9);
|
||||
println!("Speedup multiplier: {:.1}x", speedup);
|
||||
|
||||
|
||||
assert!(duration_cached < duration_uncached);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub mod fan;
|
||||
pub mod flow_junction;
|
||||
pub mod heat_exchanger;
|
||||
pub mod node;
|
||||
pub mod params;
|
||||
pub mod pipe;
|
||||
pub mod polynomials;
|
||||
pub mod port;
|
||||
@@ -95,6 +96,7 @@ pub use heat_exchanger::{
|
||||
HeatTransferModel, HxSideConditions, LmtdModel, MchxCondenserCoil,
|
||||
};
|
||||
pub use node::{Node, NodeMeasurements, NodePhase};
|
||||
pub use params::ComponentParams;
|
||||
pub use pipe::{friction_factor, roughness, Pipe, PipeGeometry};
|
||||
pub use polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D, Polynomial2D};
|
||||
pub use port::{
|
||||
@@ -531,6 +533,72 @@ pub trait Component {
|
||||
/// ```
|
||||
fn get_ports(&self) -> &[ConnectedPort];
|
||||
|
||||
/// Returns the names of this component's ports in index order.
|
||||
///
|
||||
/// The default implementation returns an empty vector. Components with
|
||||
/// named ports should override this to return human-readable names
|
||||
/// (e.g., `["suction", "discharge"]` for a compressor).
|
||||
///
|
||||
/// Port names are used by [`SystemBuilder::edge_with_ports`] to create
|
||||
/// validated connections using string identifiers instead of integer indices.
|
||||
fn port_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Resolves a port name string to a port index for this component.
|
||||
///
|
||||
/// First checks the explicit [`port_names`](Self::port_names) override. If the
|
||||
/// component defines named ports and `name` matches one, returns the matching index.
|
||||
///
|
||||
/// If no explicit port names are defined, falls back to a convention-based lookup
|
||||
/// using standard thermodynamic port naming:
|
||||
///
|
||||
/// | Name pattern | Index |
|
||||
/// |-------------------------------------------|-------|
|
||||
/// | `inlet`, `in`, `suction`, `cold_in` | 0 |
|
||||
/// | `outlet`, `out`, `discharge`, `cold_out` | 1 |
|
||||
/// | `hot_in`, `hot_inlet`, `refrigerant_in` | 2 |
|
||||
/// | `hot_out`, `hot_outlet`, `refrigerant_out` | 3 |
|
||||
/// | `economizer`, `eco`, `economiser` | 2 |
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string describing why the port name could not be resolved.
|
||||
fn resolve_port_name(&self, name: &str) -> Result<usize, String> {
|
||||
let names = self.port_names();
|
||||
if !names.is_empty() {
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
if n.eq_ignore_ascii_case(name) {
|
||||
return Ok(i);
|
||||
}
|
||||
}
|
||||
return Err(format!(
|
||||
"Port '{}' not found on component (valid ports: {})",
|
||||
name,
|
||||
names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| format!("{i}: {n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let lower = name.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"inlet" | "in" | "suction" | "cold_in" => Ok(0),
|
||||
"outlet" | "out" | "discharge" | "cold_out" => Ok(1),
|
||||
"hot_in" | "hot_inlet" | "refrigerant_in" | "feed_inlet" | "evaporator_return" => {
|
||||
Ok(2 % self.n_equations().max(2))
|
||||
}
|
||||
"hot_out" | "hot_outlet" | "refrigerant_out" | "liquid_outlet" | "vapor_outlet" => {
|
||||
Ok(3 % self.n_equations().max(2))
|
||||
}
|
||||
"economizer" | "eco" | "economiser" | "flash_in" => Ok(2),
|
||||
other => Err(format!("Unknown port name '{other}' for component")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Injects system-level context into a component during topology finalization.
|
||||
///
|
||||
/// Called by [`System::finalize()`] after all edge state indices are computed.
|
||||
@@ -633,6 +701,34 @@ pub trait Component {
|
||||
fn signature(&self) -> String {
|
||||
"Component".to_string()
|
||||
}
|
||||
|
||||
/// Extracts component parameters for serialization.
|
||||
///
|
||||
/// Returns a `ComponentParams` struct containing all information needed to
|
||||
/// reconstruct this component later (component type, configuration parameters).
|
||||
///
|
||||
/// The default implementation returns a generic "Component" type with no parameters.
|
||||
/// Component implementations should override this to provide their specific parameters.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_components::{Component, ComponentParams};
|
||||
///
|
||||
/// struct MyComponent;
|
||||
/// impl Component for MyComponent {
|
||||
/// // ... other required methods ...
|
||||
///
|
||||
/// fn to_params(&self) -> ComponentParams {
|
||||
/// ComponentParams::new("MyComponent")
|
||||
/// .with_param("value1", 42.0)
|
||||
/// .with_param("value2", "test")
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
fn to_params(&self) -> ComponentParams {
|
||||
ComponentParams::new(self.signature())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
490
crates/components/src/mode_switch.rs
Normal file
490
crates/components/src/mode_switch.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
//! ModeSwitch component for automatic mode switching between mechanical cooling and free cooling
|
||||
//!
|
||||
//! This component manages transitions between operating modes with hysteresis,
|
||||
//! minimum duration requirements, and safety interlocks.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// System operating modes
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SystemMode {
|
||||
/// Mechanical cooling (compressor active)
|
||||
MechanicalCooling,
|
||||
/// Free cooling (direct heat exchange)
|
||||
FreeCooling,
|
||||
/// Mixed mode (both)
|
||||
Mixed,
|
||||
/// Off
|
||||
Off,
|
||||
/// Emergency
|
||||
Emergency,
|
||||
}
|
||||
|
||||
/// Transition conditions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransitionCondition {
|
||||
/// Outdoor temperature threshold (K)
|
||||
pub outdoor_temp_threshold: f64,
|
||||
/// Hysteresis (K)
|
||||
pub hysteresis: f64,
|
||||
/// Minimum time in mode before transition (seconds)
|
||||
pub min_mode_duration_secs: u64,
|
||||
/// Cold water temperature threshold (optional)
|
||||
pub cold_water_temp_threshold: Option<f64>,
|
||||
}
|
||||
|
||||
/// Configuration for ModeSwitch
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModeSwitchConfig {
|
||||
/// Condition for mechanical to free cooling
|
||||
pub mechanical_to_free: TransitionCondition,
|
||||
/// Condition for free cooling to mechanical
|
||||
pub free_to_mechanical: TransitionCondition,
|
||||
/// Safety interlocks
|
||||
pub safety_interlocks: Vec<SafetyInterlock>,
|
||||
/// Stabilization time between transitions (seconds)
|
||||
pub stabilization_time_secs: u64,
|
||||
}
|
||||
|
||||
/// Safety interlock
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SafetyInterlock {
|
||||
/// Interlock name
|
||||
pub name: String,
|
||||
/// Minimum temperature (K) - freeze protection
|
||||
pub min_temperature: Option<f64>,
|
||||
/// Maximum temperature (K)
|
||||
pub max_temperature: Option<f64>,
|
||||
/// Minimum pressure (Pa)
|
||||
pub min_pressure: Option<f64>,
|
||||
/// Maximum pressure (Pa)
|
||||
pub max_pressure: Option<f64>,
|
||||
/// Action to take
|
||||
pub action: InterlockAction,
|
||||
}
|
||||
|
||||
/// Interlock action
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum InterlockAction {
|
||||
/// Warning only
|
||||
Alarm,
|
||||
/// Forced mode change
|
||||
ForceMode(SystemMode),
|
||||
/// Emergency shutdown
|
||||
EmergencyShutdown,
|
||||
}
|
||||
|
||||
/// Transition state
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TransitionState {
|
||||
/// Stable in a mode
|
||||
Stable,
|
||||
/// Currently transitioning
|
||||
Transitioning {
|
||||
from: SystemMode,
|
||||
to: SystemMode,
|
||||
start_time: Instant,
|
||||
},
|
||||
/// Waiting for stabilization
|
||||
Stabilizing {
|
||||
mode: SystemMode,
|
||||
start_time: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
/// Mode switch controller
|
||||
#[derive(Debug)]
|
||||
pub struct ModeSwitch {
|
||||
/// Configuration
|
||||
config: ModeSwitchConfig,
|
||||
/// Current mode
|
||||
current_mode: SystemMode,
|
||||
/// Transition state
|
||||
transition_state: TransitionState,
|
||||
/// Last transition
|
||||
last_transition: Option<(SystemMode, Instant)>,
|
||||
/// Active interlocks
|
||||
active_interlocks: Vec<String>,
|
||||
/// Current outdoor temperature
|
||||
current_outdoor_temp: Option<f64>,
|
||||
/// Current cold water temperature
|
||||
current_cold_water_temp: Option<f64>,
|
||||
}
|
||||
|
||||
impl ModeSwitch {
|
||||
/// Creates a new ModeSwitch
|
||||
pub fn new(config: ModeSwitchConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
current_mode: SystemMode::Off,
|
||||
transition_state: TransitionState::Stable,
|
||||
last_transition: None,
|
||||
active_interlocks: Vec::new(),
|
||||
current_outdoor_temp: None,
|
||||
current_cold_water_temp: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates conditions and calculates new mode
|
||||
pub fn update(
|
||||
&mut self,
|
||||
outdoor_temp: Option<f64>,
|
||||
cold_water_temp: Option<f64>,
|
||||
) -> Result<SystemMode, ModeSwitchError> {
|
||||
// Update conditions
|
||||
self.current_outdoor_temp = outdoor_temp;
|
||||
self.current_cold_water_temp = cold_water_temp;
|
||||
|
||||
// Check safety interlocks first
|
||||
self.check_safety_interlocks()?;
|
||||
|
||||
// If an interlock is active, apply its action
|
||||
if let Some(interlock_name) = self.active_interlocks.first() {
|
||||
let interlock = self
|
||||
.config
|
||||
.safety_interlocks
|
||||
.iter()
|
||||
.find(|i| &i.name == interlock_name)
|
||||
.ok_or(ModeSwitchError::InterlockNotFound)?;
|
||||
|
||||
match interlock.action {
|
||||
InterlockAction::ForceMode(mode) => {
|
||||
if self.current_mode != mode {
|
||||
self.transition_to(mode)?;
|
||||
}
|
||||
return Ok(self.current_mode);
|
||||
}
|
||||
InterlockAction::EmergencyShutdown => {
|
||||
if self.current_mode != SystemMode::Emergency {
|
||||
self.transition_to(SystemMode::Emergency)?;
|
||||
}
|
||||
return Ok(self.current_mode);
|
||||
}
|
||||
InterlockAction::Alarm => {
|
||||
// Continue with normal logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal transition logic
|
||||
match self.transition_state {
|
||||
TransitionState::Stable => {
|
||||
self.evaluate_transitions()?;
|
||||
}
|
||||
TransitionState::Transitioning {
|
||||
from,
|
||||
to,
|
||||
start_time,
|
||||
} => {
|
||||
let elapsed = start_time.elapsed();
|
||||
if elapsed >= Duration::from_secs(self.config.stabilization_time_secs) {
|
||||
// Transition complete
|
||||
self.current_mode = to;
|
||||
self.transition_state = TransitionState::Stabilizing {
|
||||
mode: to,
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
self.last_transition = Some((from, Instant::now()));
|
||||
}
|
||||
}
|
||||
TransitionState::Stabilizing {
|
||||
mode: _,
|
||||
start_time,
|
||||
} => {
|
||||
let elapsed = start_time.elapsed();
|
||||
if elapsed
|
||||
>= Duration::from_secs(self.config.mechanical_to_free.min_mode_duration_secs)
|
||||
{
|
||||
// Stabilization complete
|
||||
self.transition_state = TransitionState::Stable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self.current_mode)
|
||||
}
|
||||
|
||||
/// Evaluates transition conditions
|
||||
fn evaluate_transitions(&mut self) -> Result<(), ModeSwitchError> {
|
||||
let outdoor_temp = self
|
||||
.current_outdoor_temp
|
||||
.ok_or(ModeSwitchError::MissingTemperature)?;
|
||||
|
||||
match self.current_mode {
|
||||
SystemMode::MechanicalCooling => {
|
||||
// Check if we can switch to free cooling
|
||||
if outdoor_temp < self.config.mechanical_to_free.outdoor_temp_threshold {
|
||||
// Check minimum duration
|
||||
if self.check_min_duration()? {
|
||||
self.transition_to(SystemMode::FreeCooling)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
SystemMode::FreeCooling => {
|
||||
// Check if we should go back to mechanical
|
||||
if outdoor_temp > self.config.free_to_mechanical.outdoor_temp_threshold {
|
||||
if self.check_min_duration()? {
|
||||
self.transition_to(SystemMode::MechanicalCooling)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
SystemMode::Mixed => {
|
||||
// TODO: Implement mixed mode logic
|
||||
}
|
||||
_ => {
|
||||
// Other modes (Off, Emergency) - no automatic transition
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks minimum duration in current mode
|
||||
fn check_min_duration(&self) -> Result<bool, ModeSwitchError> {
|
||||
if let Some((_, last_transition_time)) = self.last_transition {
|
||||
let elapsed = last_transition_time.elapsed();
|
||||
let min_duration = match self.current_mode {
|
||||
SystemMode::MechanicalCooling => {
|
||||
self.config.mechanical_to_free.min_mode_duration_secs
|
||||
}
|
||||
SystemMode::FreeCooling => self.config.free_to_mechanical.min_mode_duration_secs,
|
||||
_ => return Ok(true),
|
||||
};
|
||||
|
||||
Ok(elapsed >= Duration::from_secs(min_duration))
|
||||
} else {
|
||||
Ok(true) // First transition
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a transition to a new mode
|
||||
fn transition_to(&mut self, new_mode: SystemMode) -> Result<(), ModeSwitchError> {
|
||||
// Check if transition is valid
|
||||
if !self.is_valid_transition(self.current_mode, new_mode) {
|
||||
return Err(ModeSwitchError::InvalidTransition {
|
||||
from: self.current_mode,
|
||||
to: new_mode,
|
||||
});
|
||||
}
|
||||
|
||||
// Start transition
|
||||
self.transition_state = TransitionState::Transitioning {
|
||||
from: self.current_mode,
|
||||
to: new_mode,
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if a transition is valid
|
||||
fn is_valid_transition(&self, from: SystemMode, to: SystemMode) -> bool {
|
||||
match (from, to) {
|
||||
// Valid transitions
|
||||
(SystemMode::MechanicalCooling, SystemMode::FreeCooling) => true,
|
||||
(SystemMode::FreeCooling, SystemMode::MechanicalCooling) => true,
|
||||
(SystemMode::MechanicalCooling, SystemMode::Mixed) => true,
|
||||
(SystemMode::FreeCooling, SystemMode::Mixed) => true,
|
||||
(SystemMode::Mixed, SystemMode::MechanicalCooling) => true,
|
||||
(SystemMode::Mixed, SystemMode::FreeCooling) => true,
|
||||
(SystemMode::Off, SystemMode::MechanicalCooling) => true,
|
||||
(SystemMode::Off, SystemMode::FreeCooling) => true,
|
||||
(_, SystemMode::Emergency) => true, // Always possible
|
||||
(SystemMode::Emergency, SystemMode::Off) => true, // Recovery
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks safety interlocks
|
||||
fn check_safety_interlocks(&mut self) -> Result<(), ModeSwitchError> {
|
||||
self.active_interlocks.clear();
|
||||
|
||||
for interlock in &self.config.safety_interlocks {
|
||||
let mut triggered = false;
|
||||
|
||||
// Check temperature
|
||||
if let (Some(min_temp), Some(current)) =
|
||||
(interlock.min_temperature, self.current_outdoor_temp)
|
||||
{
|
||||
if current < min_temp {
|
||||
triggered = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(max_temp), Some(current)) =
|
||||
(interlock.max_temperature, self.current_outdoor_temp)
|
||||
{
|
||||
if current > max_temp {
|
||||
triggered = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Check pressure when sensors are available
|
||||
|
||||
if triggered {
|
||||
self.active_interlocks.push(interlock.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns current mode
|
||||
pub fn current_mode(&self) -> SystemMode {
|
||||
self.current_mode
|
||||
}
|
||||
|
||||
/// Returns transition state
|
||||
pub fn transition_state(&self) -> &TransitionState {
|
||||
&self.transition_state
|
||||
}
|
||||
|
||||
/// Returns active interlocks
|
||||
pub fn active_interlocks(&self) -> &[String] {
|
||||
&self.active_interlocks
|
||||
}
|
||||
|
||||
/// Forces a mode manually (for maintenance or testing)
|
||||
pub fn force_mode(&mut self, mode: SystemMode) -> Result<(), ModeSwitchError> {
|
||||
self.current_mode = mode;
|
||||
self.transition_state = TransitionState::Stable;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns outdoor temperature
|
||||
pub fn outdoor_temperature(&self) -> Option<f64> {
|
||||
self.current_outdoor_temp
|
||||
}
|
||||
|
||||
/// Returns cold water temperature
|
||||
pub fn cold_water_temperature(&self) -> Option<f64> {
|
||||
self.current_cold_water_temp
|
||||
}
|
||||
}
|
||||
|
||||
/// ModeSwitch errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ModeSwitchError {
|
||||
#[error("Missing outdoor temperature")]
|
||||
MissingTemperature,
|
||||
|
||||
#[error("Invalid transition from {from:?} to {to:?}")]
|
||||
InvalidTransition { from: SystemMode, to: SystemMode },
|
||||
|
||||
#[error("Interlock not found")]
|
||||
InterlockNotFound,
|
||||
|
||||
#[error("Invalid transition condition")]
|
||||
InvalidCondition,
|
||||
}
|
||||
|
||||
impl Default for TransitionCondition {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
outdoor_temp_threshold: 285.15, // 12°C
|
||||
hysteresis: 2.0,
|
||||
min_mode_duration_secs: 1800, // 30 minutes
|
||||
cold_water_temp_threshold: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ModeSwitchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mechanical_to_free: TransitionCondition::default(),
|
||||
free_to_mechanical: TransitionCondition {
|
||||
outdoor_temp_threshold: 287.15, // 14°C (with hysteresis)
|
||||
..Default::default()
|
||||
},
|
||||
safety_interlocks: vec![SafetyInterlock {
|
||||
name: "freeze_protection".to_string(),
|
||||
min_temperature: Some(277.15), // 4°C
|
||||
max_temperature: None,
|
||||
min_pressure: None,
|
||||
max_pressure: None,
|
||||
action: InterlockAction::ForceMode(SystemMode::MechanicalCooling),
|
||||
}],
|
||||
stabilization_time_secs: 300, // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mode_switch_creation() {
|
||||
let config = ModeSwitchConfig::default();
|
||||
let mode_switch = ModeSwitch::new(config);
|
||||
assert_eq!(mode_switch.current_mode(), SystemMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_switching_auto_temperature() {
|
||||
let mut config = ModeSwitchConfig::default();
|
||||
config.mechanical_to_free.outdoor_temp_threshold = 285.15; // 12°C
|
||||
|
||||
let mut mode_switch = ModeSwitch::new(config);
|
||||
|
||||
// Start in mechanical cooling
|
||||
mode_switch
|
||||
.force_mode(SystemMode::MechanicalCooling)
|
||||
.unwrap();
|
||||
|
||||
// High outdoor temperature -> stay mechanical
|
||||
let mode = mode_switch.update(Some(293.15), None).unwrap(); // 20°C
|
||||
assert_eq!(mode, SystemMode::MechanicalCooling);
|
||||
|
||||
// Low outdoor temperature -> switch to free cooling
|
||||
let mode = mode_switch.update(Some(283.15), None).unwrap(); // 10°C
|
||||
// Should be transitioning or stabilized
|
||||
assert!(
|
||||
mode == SystemMode::FreeCooling
|
||||
|| *mode_switch.transition_state() != TransitionState::Stable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_freeze_protection_interlock() {
|
||||
let mut config = ModeSwitchConfig::default();
|
||||
config.safety_interlocks = vec![SafetyInterlock {
|
||||
name: "freeze_protection".to_string(),
|
||||
min_temperature: Some(277.15), // 4°C
|
||||
max_temperature: None,
|
||||
min_pressure: None,
|
||||
max_pressure: None,
|
||||
action: InterlockAction::ForceMode(SystemMode::MechanicalCooling),
|
||||
}];
|
||||
|
||||
let mut mode_switch = ModeSwitch::new(config);
|
||||
mode_switch.force_mode(SystemMode::FreeCooling).unwrap();
|
||||
|
||||
// Temperature below freeze protection
|
||||
let mode = mode_switch.update(Some(273.15), None).unwrap(); // 0°C
|
||||
assert_eq!(mode, SystemMode::MechanicalCooling);
|
||||
assert!(!mode_switch.active_interlocks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_transitions() {
|
||||
let mut mode_switch = ModeSwitch::new(ModeSwitchConfig::default());
|
||||
|
||||
// Valid transitions
|
||||
mode_switch.force_mode(SystemMode::Off).unwrap();
|
||||
assert!(mode_switch
|
||||
.transition_to(SystemMode::MechanicalCooling)
|
||||
.is_ok());
|
||||
|
||||
mode_switch
|
||||
.force_mode(SystemMode::MechanicalCooling)
|
||||
.unwrap();
|
||||
assert!(mode_switch.transition_to(SystemMode::FreeCooling).is_ok());
|
||||
|
||||
// Invalid transitions
|
||||
mode_switch.force_mode(SystemMode::Emergency).unwrap();
|
||||
assert!(mode_switch.transition_to(SystemMode::FreeCooling).is_err());
|
||||
}
|
||||
}
|
||||
81
crates/components/src/params.rs
Normal file
81
crates/components/src/params.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! Component parameter serialization
|
||||
//!
|
||||
//! Provides types for extracting and serializing component-specific parameters.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Serializable component parameters
|
||||
///
|
||||
/// This type captures all component-specific configuration in a flexible format
|
||||
/// that can be serialized to JSON and later used to reconstruct components.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ComponentParams {
|
||||
/// Component type (e.g., "Compressor", "Condenser", "ExpansionValve")
|
||||
pub component_type: String,
|
||||
/// Component-specific parameters as key-value pairs
|
||||
#[serde(flatten)]
|
||||
pub params: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ComponentParams {
|
||||
/// Create a new ComponentParams for a given component type
|
||||
pub fn new(component_type: impl Into<String>) -> Self {
|
||||
Self {
|
||||
component_type: component_type.into(),
|
||||
params: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a parameter
|
||||
pub fn with_param(
|
||||
mut self,
|
||||
key: impl Into<String>,
|
||||
value: impl Into<serde_json::Value>,
|
||||
) -> Self {
|
||||
self.params.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Get a parameter value
|
||||
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
|
||||
self.params.get(key)
|
||||
}
|
||||
|
||||
/// Check if component is of a specific type
|
||||
pub fn is_type(&self, component_type: &str) -> bool {
|
||||
self.component_type == component_type
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_component_params_creation() {
|
||||
let params = ComponentParams::new("Compressor")
|
||||
.with_param("m1", 0.85)
|
||||
.with_param("m2", 2.5)
|
||||
.with_param("fluid", "R134a");
|
||||
|
||||
assert_eq!(params.component_type, "Compressor");
|
||||
assert_eq!(params.get("m1"), Some(&json!(0.85)));
|
||||
assert_eq!(params.get("fluid"), Some(&json!("R134a")));
|
||||
assert!(params.is_type("Compressor"));
|
||||
assert!(!params.is_type("Condenser"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_params_serialization() {
|
||||
let params = ComponentParams::new("TestComponent")
|
||||
.with_param("value1", 42)
|
||||
.with_param("value2", "test");
|
||||
|
||||
let json = serde_json::to_string(¶ms).unwrap();
|
||||
let deserialized: ComponentParams = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(params, deserialized);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,36 @@ impl Pump<Disconnected> {
|
||||
self.speed_ratio = ratio;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Connects the pump to inlet and outlet ports.
|
||||
///
|
||||
/// This consumes the disconnected pump and returns a connected one,
|
||||
/// transitioning the state at compile time.
|
||||
pub fn connect(
|
||||
self,
|
||||
inlet: Port<Disconnected>,
|
||||
outlet: Port<Disconnected>,
|
||||
) -> Result<Pump<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(Pump {
|
||||
curves: self.curves,
|
||||
port_inlet: p_in,
|
||||
port_outlet: p_out,
|
||||
fluid_density_kg_per_m3: self.fluid_density_kg_per_m3,
|
||||
speed_ratio: self.speed_ratio,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Pump<Connected> {
|
||||
|
||||
716
crates/components/src/pump_controller.rs
Normal file
716
crates/components/src/pump_controller.rs
Normal file
@@ -0,0 +1,716 @@
|
||||
//! PumpController component for intelligent pump sequencing and VFD optimization
|
||||
//!
|
||||
//! This component manages multiple pumps with optimal sequencing, runtime-based rotation,
|
||||
//! and energy-efficient VFD control.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::OperationalState;
|
||||
|
||||
/// Sequencing strategy for pump selection
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SequencingStrategy {
|
||||
/// Fixed rotation (pump 1, 2, 3, 1, 2, 3...)
|
||||
FixedRotation,
|
||||
/// Based on operating hours
|
||||
RuntimeBased,
|
||||
/// Based on efficiency (energy optimization)
|
||||
EfficiencyBased,
|
||||
/// Alternation based on start count
|
||||
StartCountBased,
|
||||
}
|
||||
|
||||
/// Configuration for an individual pump
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PumpConfig {
|
||||
/// Pump identifier
|
||||
pub id: String,
|
||||
/// Nominal power (W)
|
||||
pub nominal_power_w: f64,
|
||||
/// Nominal flow rate (m³/s)
|
||||
pub nominal_flow_m3s: f64,
|
||||
/// Nominal head (m)
|
||||
pub nominal_head_m: f64,
|
||||
/// Nominal speed (RPM)
|
||||
pub nominal_rpm: f64,
|
||||
/// Supports VFD
|
||||
pub supports_vfd: bool,
|
||||
/// VFD speed range (min, max) as fraction of nominal
|
||||
pub vfd_range: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// State of an individual pump
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PumpState {
|
||||
/// Identifier
|
||||
pub id: String,
|
||||
/// Current operational state
|
||||
pub operational_state: OperationalState,
|
||||
/// Cumulative operating hours
|
||||
pub runtime_hours: f64,
|
||||
/// Cumulative start count
|
||||
pub start_count: u64,
|
||||
/// Current speed (fraction of nominal, 0.0-1.0)
|
||||
pub speed_fraction: f64,
|
||||
/// Current power consumption (W)
|
||||
pub current_power_w: f64,
|
||||
/// Current flow rate (m³/s)
|
||||
pub current_flow_m3s: f64,
|
||||
/// Last start time
|
||||
pub last_start: Option<Instant>,
|
||||
/// Last stop time
|
||||
pub last_stop: Option<Instant>,
|
||||
/// Is in fault state
|
||||
pub is_faulted: bool,
|
||||
}
|
||||
|
||||
/// Configuration for the PumpController
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PumpControllerConfig {
|
||||
/// Configured pumps
|
||||
pub pumps: Vec<PumpConfig>,
|
||||
/// Minimum number of active pumps
|
||||
pub min_active_pumps: usize,
|
||||
/// Maximum number of active pumps
|
||||
pub max_active_pumps: usize,
|
||||
/// Sequencing strategy
|
||||
pub sequencing_strategy: SequencingStrategy,
|
||||
/// Rotation interval (hours)
|
||||
pub rotation_interval_hours: f64,
|
||||
/// Energy optimization enabled
|
||||
pub energy_optimization: bool,
|
||||
/// Minimum time between changes (seconds)
|
||||
pub min_switch_interval_secs: u64,
|
||||
/// Anti-short-cycle time (seconds)
|
||||
pub anti_short_cycle_time_secs: u64,
|
||||
}
|
||||
|
||||
/// Pump controller for intelligent pump management
|
||||
#[derive(Debug)]
|
||||
pub struct PumpController {
|
||||
/// Configuration
|
||||
config: PumpControllerConfig,
|
||||
/// Pump states
|
||||
pump_states: Vec<PumpState>,
|
||||
/// Rotation queue
|
||||
rotation_queue: VecDeque<String>,
|
||||
/// Last rotation time
|
||||
last_rotation: Option<Instant>,
|
||||
/// Last pump count change
|
||||
last_pump_count_change: Option<Instant>,
|
||||
/// Flow setpoint (m³/s)
|
||||
flow_setpoint_m3s: f64,
|
||||
/// Current total flow
|
||||
current_total_flow_m3s: f64,
|
||||
/// Load demand (0.0-1.0)
|
||||
load_demand: f64,
|
||||
}
|
||||
|
||||
impl PumpController {
|
||||
/// Creates a new pump controller
|
||||
pub fn new(config: PumpControllerConfig) -> Result<Self, PumpControllerError> {
|
||||
// Validation
|
||||
if config.min_active_pumps > config.max_active_pumps {
|
||||
return Err(PumpControllerError::InvalidConfiguration(
|
||||
"min_active_pumps cannot be greater than max_active_pumps".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if config.pumps.len() < config.max_active_pumps {
|
||||
return Err(PumpControllerError::InvalidConfiguration(
|
||||
"Number of configured pumps must be >= max_active_pumps".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Initialize pump states
|
||||
let mut pump_states = Vec::new();
|
||||
let mut rotation_queue = VecDeque::new();
|
||||
|
||||
for pump_config in &config.pumps {
|
||||
pump_states.push(PumpState {
|
||||
id: pump_config.id.clone(),
|
||||
operational_state: OperationalState::Off,
|
||||
runtime_hours: 0.0,
|
||||
start_count: 0,
|
||||
speed_fraction: 0.0,
|
||||
current_power_w: 0.0,
|
||||
current_flow_m3s: 0.0,
|
||||
last_start: None,
|
||||
last_stop: None,
|
||||
is_faulted: false,
|
||||
});
|
||||
rotation_queue.push_back(pump_config.id.clone());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
pump_states,
|
||||
rotation_queue,
|
||||
last_rotation: None,
|
||||
last_pump_count_change: None,
|
||||
flow_setpoint_m3s: 0.0,
|
||||
current_total_flow_m3s: 0.0,
|
||||
load_demand: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Updates load demand and calculates required pumps
|
||||
pub fn update_demand(
|
||||
&mut self,
|
||||
load_demand: f64,
|
||||
flow_setpoint_m3s: f64,
|
||||
) -> Result<(), PumpControllerError> {
|
||||
self.load_demand = load_demand.clamp(0.0, 1.0);
|
||||
self.flow_setpoint_m3s = flow_setpoint_m3s.max(0.0);
|
||||
|
||||
// Calculate required number of pumps
|
||||
let required_pumps = self.calculate_required_pumps()?;
|
||||
|
||||
// Check if change is needed
|
||||
let current_active = self.count_active_pumps();
|
||||
|
||||
if required_pumps != current_active {
|
||||
// Check minimum interval
|
||||
if let Some(last_change) = self.last_pump_count_change {
|
||||
let elapsed = last_change.elapsed().as_secs();
|
||||
if elapsed < self.config.min_switch_interval_secs {
|
||||
return Ok(()); // Wait more
|
||||
}
|
||||
}
|
||||
|
||||
// Apply change
|
||||
if required_pumps > current_active {
|
||||
self.start_pumps(required_pumps - current_active)?;
|
||||
} else {
|
||||
self.stop_pumps(current_active - required_pumps)?;
|
||||
}
|
||||
|
||||
self.last_pump_count_change = Some(Instant::now());
|
||||
}
|
||||
|
||||
// Optimize VFD speeds if enabled
|
||||
if self.config.energy_optimization {
|
||||
self.optimize_vfd_speeds()?;
|
||||
}
|
||||
|
||||
// Update flows
|
||||
self.update_flows()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculates required pumps based on demand
|
||||
fn calculate_required_pumps(&self) -> Result<usize, PumpControllerError> {
|
||||
// Simple calculation based on demand
|
||||
let flow_per_pump = self.calculate_nominal_flow_per_pump();
|
||||
let required = (self.flow_setpoint_m3s / flow_per_pump).ceil() as usize;
|
||||
|
||||
// Apply limits
|
||||
Ok(required.clamp(self.config.min_active_pumps, self.config.max_active_pumps))
|
||||
}
|
||||
|
||||
/// Starts N pumps
|
||||
fn start_pumps(&mut self, count: usize) -> Result<(), PumpControllerError> {
|
||||
let mut started = 0;
|
||||
|
||||
for _ in 0..count {
|
||||
if let Some(pump_id) = self.get_next_pump_to_start()? {
|
||||
self.start_pump(&pump_id)?;
|
||||
started += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if started < count {
|
||||
return Err(PumpControllerError::InsufficientPumps(format!(
|
||||
"Could only start {} of {} requested pumps",
|
||||
started, count
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops N pumps
|
||||
fn stop_pumps(&mut self, count: usize) -> Result<(), PumpControllerError> {
|
||||
let mut _stopped = 0;
|
||||
|
||||
for _ in 0..count {
|
||||
if let Some(pump_id) = self.get_next_pump_to_stop()? {
|
||||
self.stop_pump(&pump_id)?;
|
||||
_stopped += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finds the next pump to start (based on strategy)
|
||||
fn get_next_pump_to_start(&mut self) -> Result<Option<String>, PumpControllerError> {
|
||||
match self.config.sequencing_strategy {
|
||||
SequencingStrategy::FixedRotation => {
|
||||
// Take next in queue
|
||||
Ok(self.rotation_queue.pop_front())
|
||||
}
|
||||
SequencingStrategy::RuntimeBased => {
|
||||
// Find pump with least operating hours
|
||||
let mut candidates: Vec<_> = self
|
||||
.pump_states
|
||||
.iter()
|
||||
.filter(|p| p.operational_state == OperationalState::Off && !p.is_faulted)
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| a.runtime_hours.partial_cmp(&b.runtime_hours).unwrap());
|
||||
|
||||
Ok(candidates.first().map(|p| p.id.clone()))
|
||||
}
|
||||
SequencingStrategy::StartCountBased => {
|
||||
// Find pump with least starts
|
||||
let mut candidates: Vec<_> = self
|
||||
.pump_states
|
||||
.iter()
|
||||
.filter(|p| p.operational_state == OperationalState::Off && !p.is_faulted)
|
||||
.collect();
|
||||
|
||||
candidates.sort_by(|a, b| a.start_count.cmp(&b.start_count));
|
||||
|
||||
Ok(candidates.first().map(|p| p.id.clone()))
|
||||
}
|
||||
SequencingStrategy::EfficiencyBased => {
|
||||
// TODO: Implement based on performance curves
|
||||
Ok(self.rotation_queue.pop_front())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the next pump to stop
|
||||
fn get_next_pump_to_stop(&self) -> Result<Option<String>, PumpControllerError> {
|
||||
// Simple logic: stop the most recently started
|
||||
let active_pumps: Vec<_> = self
|
||||
.pump_states
|
||||
.iter()
|
||||
.filter(|p| p.operational_state == OperationalState::On)
|
||||
.collect();
|
||||
|
||||
if active_pumps.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Return the most recent (based on last_start)
|
||||
let mut sorted = active_pumps;
|
||||
sorted.sort_by(|a, b| {
|
||||
let a_time = a.last_start.unwrap_or(Instant::now());
|
||||
let b_time = b.last_start.unwrap_or(Instant::now());
|
||||
b_time.cmp(&a_time) // Most recent first
|
||||
});
|
||||
|
||||
Ok(sorted.first().map(|p| p.id.clone()))
|
||||
}
|
||||
|
||||
/// Starts a specific pump
|
||||
fn start_pump(&mut self, pump_id: &str) -> Result<(), PumpControllerError> {
|
||||
let pump = self
|
||||
.pump_states
|
||||
.iter_mut()
|
||||
.find(|p| p.id == pump_id)
|
||||
.ok_or_else(|| PumpControllerError::PumpNotFound(pump_id.to_string()))?;
|
||||
|
||||
if pump.is_faulted {
|
||||
return Err(PumpControllerError::PumpFaulted(pump_id.to_string()));
|
||||
}
|
||||
|
||||
pump.operational_state = OperationalState::On;
|
||||
pump.speed_fraction = 1.0; // Full speed by default
|
||||
pump.last_start = Some(Instant::now());
|
||||
pump.start_count += 1;
|
||||
|
||||
// Update rotation queue
|
||||
self.rotation_queue.push_back(pump_id.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops a specific pump
|
||||
fn stop_pump(&mut self, pump_id: &str) -> Result<(), PumpControllerError> {
|
||||
let pump = self
|
||||
.pump_states
|
||||
.iter_mut()
|
||||
.find(|p| p.id == pump_id)
|
||||
.ok_or_else(|| PumpControllerError::PumpNotFound(pump_id.to_string()))?;
|
||||
|
||||
pump.operational_state = OperationalState::Off;
|
||||
pump.speed_fraction = 0.0;
|
||||
pump.current_power_w = 0.0;
|
||||
pump.current_flow_m3s = 0.0;
|
||||
pump.last_stop = Some(Instant::now());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Optimizes VFD speeds to minimize power consumption
|
||||
fn optimize_vfd_speeds(&mut self) -> Result<(), PumpControllerError> {
|
||||
let active_pumps = self.count_active_pumps();
|
||||
|
||||
if active_pumps == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Calculate optimal speed for each pump
|
||||
let optimal_speed = self.calculate_optimal_speed()?;
|
||||
|
||||
for pump in &mut self.pump_states {
|
||||
if pump.operational_state == OperationalState::On {
|
||||
let pump_config = self.config.pumps.iter().find(|c| c.id == pump.id).unwrap();
|
||||
|
||||
if pump_config.supports_vfd {
|
||||
// Apply optimal speed with VFD limits
|
||||
if let Some((min_speed, max_speed)) = pump_config.vfd_range {
|
||||
pump.speed_fraction = optimal_speed.clamp(min_speed, max_speed);
|
||||
} else {
|
||||
pump.speed_fraction = optimal_speed;
|
||||
}
|
||||
|
||||
// Calculate new power (affinity laws)
|
||||
pump.current_power_w =
|
||||
pump_config.nominal_power_w * pump.speed_fraction.powi(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculates optimal VFD speed
|
||||
fn calculate_optimal_speed(&self) -> Result<f64, PumpControllerError> {
|
||||
// Affinity laws: Q ∝ N, P ∝ N³
|
||||
// To minimize energy, we want the lowest speed that satisfies flow requirement
|
||||
|
||||
let active_pumps = self.count_active_pumps() as f64;
|
||||
let flow_per_pump = self.flow_setpoint_m3s / active_pumps;
|
||||
|
||||
// Required speed (as fraction of nominal)
|
||||
let required_speed = flow_per_pump / self.calculate_nominal_flow_per_pump();
|
||||
|
||||
// Apply safety margin (5%)
|
||||
let safety_margin = 1.05;
|
||||
|
||||
Ok((required_speed * safety_margin).clamp(0.3, 1.0)) // Min 30%, max 100%
|
||||
}
|
||||
|
||||
/// Updates current flow rates
|
||||
fn update_flows(&mut self) -> Result<(), PumpControllerError> {
|
||||
let mut total_flow = 0.0;
|
||||
|
||||
for pump in &mut self.pump_states {
|
||||
if pump.operational_state == OperationalState::On {
|
||||
let pump_config = self.config.pumps.iter().find(|c| c.id == pump.id).unwrap();
|
||||
|
||||
// Affinity laws: Q ∝ N
|
||||
pump.current_flow_m3s = pump_config.nominal_flow_m3s * pump.speed_fraction;
|
||||
total_flow += pump.current_flow_m3s;
|
||||
} else {
|
||||
pump.current_flow_m3s = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
self.current_total_flow_m3s = total_flow;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculates nominal flow per pump (average)
|
||||
fn calculate_nominal_flow_per_pump(&self) -> f64 {
|
||||
let total_nominal_flow: f64 = self.config.pumps.iter().map(|p| p.nominal_flow_m3s).sum();
|
||||
|
||||
total_nominal_flow / self.config.pumps.len() as f64
|
||||
}
|
||||
|
||||
/// Counts active pumps
|
||||
pub fn count_active_pumps(&self) -> usize {
|
||||
self.pump_states
|
||||
.iter()
|
||||
.filter(|p| p.operational_state == OperationalState::On)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Returns pump states
|
||||
pub fn pump_states(&self) -> &[PumpState] {
|
||||
&self.pump_states
|
||||
}
|
||||
|
||||
/// Returns total current power consumption
|
||||
pub fn total_power_consumption(&self) -> f64 {
|
||||
self.pump_states.iter().map(|p| p.current_power_w).sum()
|
||||
}
|
||||
|
||||
/// Returns total current flow
|
||||
pub fn total_flow(&self) -> f64 {
|
||||
self.current_total_flow_m3s
|
||||
}
|
||||
|
||||
/// Checks if a pump is faulted
|
||||
pub fn is_pump_faulted(&self, pump_id: &str) -> bool {
|
||||
self.pump_states
|
||||
.iter()
|
||||
.find(|p| p.id == pump_id)
|
||||
.map(|p| p.is_faulted)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Sets a pump fault state
|
||||
pub fn set_pump_fault(
|
||||
&mut self,
|
||||
pump_id: &str,
|
||||
faulted: bool,
|
||||
) -> Result<(), PumpControllerError> {
|
||||
let pump = self
|
||||
.pump_states
|
||||
.iter_mut()
|
||||
.find(|p| p.id == pump_id)
|
||||
.ok_or_else(|| PumpControllerError::PumpNotFound(pump_id.to_string()))?;
|
||||
|
||||
pump.is_faulted = faulted;
|
||||
|
||||
if faulted && pump.operational_state == OperationalState::On {
|
||||
// Stop pump if running
|
||||
self.stop_pump(pump_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs scheduled pump rotation
|
||||
pub fn rotate_pumps(&mut self) -> Result<(), PumpControllerError> {
|
||||
// Check rotation interval
|
||||
if let Some(last_rotation) = self.last_rotation {
|
||||
let elapsed_hours = last_rotation.elapsed().as_secs() as f64 / 3600.0;
|
||||
if elapsed_hours < self.config.rotation_interval_hours {
|
||||
return Ok(()); // Not time to rotate yet
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation: take first active pump and put it at end of queue
|
||||
if let Some(first_active) = self
|
||||
.pump_states
|
||||
.iter()
|
||||
.find(|p| p.operational_state == OperationalState::On)
|
||||
.map(|p| p.id.clone())
|
||||
{
|
||||
// Remove from queue and add to end
|
||||
if let Some(pos) = self
|
||||
.rotation_queue
|
||||
.iter()
|
||||
.position(|id| *id == first_active)
|
||||
{
|
||||
self.rotation_queue.remove(pos);
|
||||
self.rotation_queue.push_back(first_active);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_rotation = Some(Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// PumpController errors
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PumpControllerError {
|
||||
#[error("Invalid configuration: {0}")]
|
||||
InvalidConfiguration(String),
|
||||
|
||||
#[error("Pump not found: {0}")]
|
||||
PumpNotFound(String),
|
||||
|
||||
#[error("Pump faulted: {0}")]
|
||||
PumpFaulted(String),
|
||||
|
||||
#[error("Insufficient pumps: {0}")]
|
||||
InsufficientPumps(String),
|
||||
|
||||
#[error("Calculation error")]
|
||||
CalculationError,
|
||||
}
|
||||
|
||||
impl Default for PumpControllerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pumps: Vec::new(),
|
||||
min_active_pumps: 1,
|
||||
max_active_pumps: 3,
|
||||
sequencing_strategy: SequencingStrategy::RuntimeBased,
|
||||
rotation_interval_hours: 168.0, // 1 week
|
||||
energy_optimization: true,
|
||||
min_switch_interval_secs: 300, // 5 minutes
|
||||
anti_short_cycle_time_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pump_controller_creation() {
|
||||
let config = PumpControllerConfig {
|
||||
pumps: vec![
|
||||
PumpConfig {
|
||||
id: "pump1".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: true,
|
||||
vfd_range: Some((0.3, 1.0)),
|
||||
},
|
||||
PumpConfig {
|
||||
id: "pump2".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: true,
|
||||
vfd_range: Some((0.3, 1.0)),
|
||||
},
|
||||
],
|
||||
min_active_pumps: 1,
|
||||
max_active_pumps: 2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let controller = PumpController::new(config);
|
||||
assert!(controller.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_configuration() {
|
||||
let config = PumpControllerConfig {
|
||||
pumps: vec![],
|
||||
min_active_pumps: 2,
|
||||
max_active_pumps: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let controller = PumpController::new(config);
|
||||
assert!(controller.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pump_sequencing() {
|
||||
let config = PumpControllerConfig {
|
||||
pumps: vec![
|
||||
PumpConfig {
|
||||
id: "pump1".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: false,
|
||||
vfd_range: None,
|
||||
},
|
||||
PumpConfig {
|
||||
id: "pump2".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: false,
|
||||
vfd_range: None,
|
||||
},
|
||||
PumpConfig {
|
||||
id: "pump3".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: false,
|
||||
vfd_range: None,
|
||||
},
|
||||
],
|
||||
min_active_pumps: 1,
|
||||
max_active_pumps: 3,
|
||||
sequencing_strategy: SequencingStrategy::FixedRotation,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut controller = PumpController::new(config).unwrap();
|
||||
|
||||
// Low demand: 1 pump
|
||||
controller.update_demand(0.3, 0.005).unwrap();
|
||||
assert_eq!(controller.count_active_pumps(), 1);
|
||||
assert_eq!(controller.pump_states()[0].id, "pump1");
|
||||
|
||||
// Medium demand: 2 pumps
|
||||
controller.update_demand(0.6, 0.015).unwrap();
|
||||
assert_eq!(controller.count_active_pumps(), 2);
|
||||
|
||||
// High demand: 3 pumps
|
||||
controller.update_demand(0.9, 0.025).unwrap();
|
||||
assert_eq!(controller.count_active_pumps(), 3);
|
||||
|
||||
// Back to low demand
|
||||
controller.update_demand(0.2, 0.005).unwrap();
|
||||
assert_eq!(controller.count_active_pumps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pump_fault_handling() {
|
||||
let config = PumpControllerConfig {
|
||||
pumps: vec![PumpConfig {
|
||||
id: "pump1".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: false,
|
||||
vfd_range: None,
|
||||
}],
|
||||
min_active_pumps: 1,
|
||||
max_active_pumps: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut controller = PumpController::new(config).unwrap();
|
||||
|
||||
// Start pump
|
||||
controller.update_demand(1.0, 0.01).unwrap();
|
||||
assert_eq!(controller.count_active_pumps(), 1);
|
||||
|
||||
// Set fault
|
||||
controller.set_pump_fault("pump1", true).unwrap();
|
||||
assert!(controller.is_pump_faulted("pump1"));
|
||||
assert_eq!(controller.count_active_pumps(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vfd_optimization() {
|
||||
let config = PumpControllerConfig {
|
||||
pumps: vec![PumpConfig {
|
||||
id: "pump1".to_string(),
|
||||
nominal_power_w: 1000.0,
|
||||
nominal_flow_m3s: 0.01,
|
||||
nominal_head_m: 20.0,
|
||||
nominal_rpm: 2900.0,
|
||||
supports_vfd: true,
|
||||
vfd_range: Some((0.3, 1.0)),
|
||||
}],
|
||||
min_active_pumps: 1,
|
||||
max_active_pumps: 1,
|
||||
energy_optimization: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut controller = PumpController::new(config).unwrap();
|
||||
|
||||
// 50% demand
|
||||
controller.update_demand(0.5, 0.005).unwrap();
|
||||
|
||||
let pump_state = &controller.pump_states()[0];
|
||||
assert!(pump_state.speed_fraction < 1.0);
|
||||
assert!(pump_state.current_power_w < 1000.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user