Files
Entropyk/crates/components/src/pump_controller.rs
Sepehr ab5dc7e568 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>
2026-04-25 15:01:09 +02:00

717 lines
22 KiB
Rust

//! 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);
}
}