//! 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, /// Last stop time pub last_stop: Option, /// 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, /// 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, /// Rotation queue rotation_queue: VecDeque, /// Last rotation time last_rotation: Option, /// Last pump count change last_pump_count_change: Option, /// 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 { // 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 { // 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, 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, 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 { // 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); } }