//! Pump Component Implementation //! //! This module provides a pump component for hydraulic systems using //! polynomial performance curves and affinity laws for variable speed operation. //! //! ## Performance Curves //! //! **Head Curve:** H = a₀ + a₁Q + a₂Q² + a₃Q³ //! //! **Efficiency Curve:** η = b₀ + b₁Q + b₂Q² //! //! **Hydraulic Power:** P_hydraulic = ρ × g × Q × H / η //! //! ## Affinity Laws (Variable Speed) //! //! When operating at reduced speed (VFD): //! - Q₂/Q₁ = N₂/N₁ //! - H₂/H₁ = (N₂/N₁)² //! - P₂/P₁ = (N₂/N₁)³ use crate::polynomials::{AffinityLaws, PerformanceCurves, Polynomial1D}; use crate::port::{Connected, Disconnected, FluidId, Port}; use crate::state_machine::StateManageable; use crate::{ CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState, ResidualVector, SystemState, }; use entropyk_core::{MassFlow, Power}; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; /// Pump performance curve coefficients. /// /// Defines the polynomial coefficients for the pump's head-flow curve /// and efficiency curve. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PumpCurves { /// Performance curves (head, efficiency, optional power) curves: PerformanceCurves, } impl PumpCurves { /// Creates pump curves from performance curves. pub fn new(curves: PerformanceCurves) -> Result { curves.validate()?; Ok(Self { curves }) } /// Creates pump curves from polynomial coefficients. /// /// # Arguments /// /// * `head_coeffs` - Head curve coefficients [a0, a1, a2, ...] for H = a0 + a1*Q + a2*Q² /// * `eff_coeffs` - Efficiency coefficients [b0, b1, b2, ...] for η = b0 + b1*Q + b2*Q² /// /// # Units /// /// * Q (flow) in m³/s /// * H (head) in meters /// * η (efficiency) as decimal (0.0 to 1.0) pub fn from_coefficients( head_coeffs: Vec, eff_coeffs: Vec, ) -> Result { let head_curve = Polynomial1D::new(head_coeffs); let eff_curve = Polynomial1D::new(eff_coeffs); let curves = PerformanceCurves::simple(head_curve, eff_curve); Self::new(curves) } /// Creates a quadratic pump curve. /// /// H = a0 + a1*Q + a2*Q² /// η = b0 + b1*Q + b2*Q² pub fn quadratic( h0: f64, h1: f64, h2: f64, e0: f64, e1: f64, e2: f64, ) -> Result { Self::from_coefficients(vec![h0, h1, h2], vec![e0, e1, e2]) } /// Creates a cubic pump curve (3rd-order polynomial for head). /// /// H = a0 + a1*Q + a2*Q² + a3*Q³ /// η = b0 + b1*Q + b2*Q² pub fn cubic( h0: f64, h1: f64, h2: f64, h3: f64, e0: f64, e1: f64, e2: f64, ) -> Result { Self::from_coefficients(vec![h0, h1, h2, h3], vec![e0, e1, e2]) } /// Returns the head at the given flow rate (at full speed). /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Head in meters pub fn head_at_flow(&self, flow_m3_per_s: f64) -> f64 { self.curves.head_curve.evaluate(flow_m3_per_s) } /// Returns the efficiency at the given flow rate (at full speed). /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Efficiency as decimal (0.0 to 1.0) pub fn efficiency_at_flow(&self, flow_m3_per_s: f64) -> f64 { let eta = self.curves.efficiency_curve.evaluate(flow_m3_per_s); // Clamp efficiency to valid range eta.clamp(0.0, 1.0) } /// Returns reference to the performance curves. pub fn curves(&self) -> &PerformanceCurves { &self.curves } } impl Default for PumpCurves { fn default() -> Self { Self::quadratic(30.0, 0.0, 0.0, 0.7, 0.0, 0.0).unwrap() } } /// A pump component with polynomial performance curves. /// /// The pump uses the Type-State pattern to ensure ports are connected /// before use in simulations. /// /// # Example /// /// ```ignore /// use entropyk_components::pump::{Pump, PumpCurves}; /// use entropyk_components::port::{FluidId, Port}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// // Create pump curves: H = 30 - 10*Q - 50*Q² (in m and m³/s) /// let curves = PumpCurves::quadratic(30.0, -10.0, -50.0, 0.5, 0.3, -0.5).unwrap(); /// /// let inlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(1.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// let outlet = Port::new( /// FluidId::new("Water"), /// Pressure::from_bar(1.0), /// Enthalpy::from_joules_per_kg(100000.0), /// ); /// /// let pump = Pump::new(curves, inlet, outlet, 1000.0).unwrap(); /// ``` #[derive(Debug, Clone)] pub struct Pump { /// Performance curves curves: PumpCurves, /// Inlet port port_inlet: Port, /// Outlet port port_outlet: Port, /// Fluid density in kg/m³ fluid_density_kg_per_m3: f64, /// Speed ratio (0.0 to 1.0), default 1.0 (full speed) speed_ratio: f64, /// Circuit identifier circuit_id: CircuitId, /// Operational state operational_state: OperationalState, /// Phantom data for type state _state: PhantomData, } impl Pump { /// Creates a new disconnected pump. /// /// # Arguments /// /// * `curves` - Pump performance curves /// * `port_inlet` - Inlet port (disconnected) /// * `port_outlet` - Outlet port (disconnected) /// * `fluid_density` - Fluid density in kg/m³ /// /// # Errors /// /// Returns an error if: /// - Ports have different fluid types /// - Fluid density is not positive pub fn new( curves: PumpCurves, port_inlet: Port, port_outlet: Port, fluid_density: f64, ) -> Result { if port_inlet.fluid_id() != port_outlet.fluid_id() { return Err(ComponentError::InvalidState( "Inlet and outlet ports must have the same fluid type".to_string(), )); } if fluid_density <= 0.0 { return Err(ComponentError::InvalidState( "Fluid density must be positive".to_string(), )); } Ok(Self { curves, port_inlet, port_outlet, fluid_density_kg_per_m3: fluid_density, speed_ratio: 1.0, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), _state: PhantomData, }) } /// Returns the fluid identifier. pub fn fluid_id(&self) -> &FluidId { self.port_inlet.fluid_id() } /// Returns the inlet port. pub fn port_inlet(&self) -> &Port { &self.port_inlet } /// Returns the outlet port. pub fn port_outlet(&self) -> &Port { &self.port_outlet } /// Returns the fluid density. pub fn fluid_density(&self) -> f64 { self.fluid_density_kg_per_m3 } /// Returns the performance curves. pub fn curves(&self) -> &PumpCurves { &self.curves } /// Returns the speed ratio. pub fn speed_ratio(&self) -> f64 { self.speed_ratio } /// Sets the speed ratio (0.0 to 1.0). pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> { if !(0.0..=1.0).contains(&ratio) { return Err(ComponentError::InvalidState( "Speed ratio must be between 0.0 and 1.0".to_string(), )); } self.speed_ratio = ratio; Ok(()) } } impl Pump { /// Returns the inlet port. pub fn port_inlet(&self) -> &Port { &self.port_inlet } /// Returns the outlet port. pub fn port_outlet(&self) -> &Port { &self.port_outlet } /// Calculates the pressure rise across the pump. /// /// Uses the head curve and converts to pressure: /// ΔP = ρ × g × H /// /// Applies affinity laws for variable speed operation. /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Pressure rise in Pascals pub fn pressure_rise(&self, flow_m3_per_s: f64) -> f64 { // Handle zero speed - pump produces no pressure if self.speed_ratio <= 0.0 { return 0.0; } // Handle negative flow gracefully by using a linear extrapolation from Q=0 // to prevent polynomial extrapolation issues with quadratic/cubic terms if flow_m3_per_s < 0.0 { let h0 = self.curves.head_at_flow(0.0); let h_eps = self.curves.head_at_flow(1e-6); let dh_dq = (h_eps - h0) / 1e-6; let head_m = h0 + dh_dq * flow_m3_per_s; let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio); const G: f64 = 9.80665; // m/s² return self.fluid_density_kg_per_m3 * G * actual_head; } // Handle exactly zero flow if flow_m3_per_s == 0.0 { // At zero flow, use the shut-off head scaled by speed let head_m = self.curves.head_at_flow(0.0); let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio); const G: f64 = 9.80665; // m/s² return self.fluid_density_kg_per_m3 * G * actual_head; } // Apply affinity law to get equivalent flow at full speed let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio); // Get head at equivalent flow let head_m = self.curves.head_at_flow(equivalent_flow); // Apply affinity law to scale head back to actual speed let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio); // Convert head to pressure: P = ρ × g × H const G: f64 = 9.80665; // m/s² self.fluid_density_kg_per_m3 * G * actual_head } /// Calculates the efficiency at the given flow rate. /// /// Applies affinity laws to find the equivalent operating point. pub fn efficiency(&self, flow_m3_per_s: f64) -> f64 { // Handle zero speed - pump is not running if self.speed_ratio <= 0.0 { return 0.0; } // Handle zero flow if flow_m3_per_s <= 0.0 { return self.curves.efficiency_at_flow(0.0); } let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio); self.curves.efficiency_at_flow(equivalent_flow) } /// Calculates the hydraulic power consumption. /// /// P_hydraulic = Q × ΔP / η /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate in m³/s /// /// # Returns /// /// Power in Watts pub fn hydraulic_power(&self, flow_m3_per_s: f64) -> Power { if flow_m3_per_s <= 0.0 || self.speed_ratio <= 0.0 { return Power::from_watts(0.0); } let delta_p = self.pressure_rise(flow_m3_per_s); let eta = self.efficiency(flow_m3_per_s); if eta <= 0.0 { return Power::from_watts(0.0); } // P = Q × ΔP / η let power_w = flow_m3_per_s * delta_p / eta; Power::from_watts(power_w) } /// Calculates mass flow rate from volumetric flow. pub fn mass_flow_from_volumetric(&self, flow_m3_per_s: f64) -> MassFlow { MassFlow::from_kg_per_s(flow_m3_per_s * self.fluid_density_kg_per_m3) } /// Calculates volumetric flow rate from mass flow. pub fn volumetric_from_mass_flow(&self, mass_flow: MassFlow) -> f64 { mass_flow.to_kg_per_s() / self.fluid_density_kg_per_m3 } /// Returns the fluid density. pub fn fluid_density(&self) -> f64 { self.fluid_density_kg_per_m3 } /// Returns the performance curves. pub fn curves(&self) -> &PumpCurves { &self.curves } /// Returns the speed ratio. pub fn speed_ratio(&self) -> f64 { self.speed_ratio } /// Sets the speed ratio (0.0 to 1.0). pub fn set_speed_ratio(&mut self, ratio: f64) -> Result<(), ComponentError> { if !(0.0..=1.0).contains(&ratio) { return Err(ComponentError::InvalidState( "Speed ratio must be between 0.0 and 1.0".to_string(), )); } self.speed_ratio = ratio; Ok(()) } /// Returns both ports as a slice for solver topology. pub fn get_ports_slice(&self) -> [&Port; 2] { [&self.port_inlet, &self.port_outlet] } } impl Component for Pump { fn compute_residuals( &self, state: &SystemState, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { if residuals.len() != self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } // Handle operational states match self.operational_state { OperationalState::Off => { residuals[0] = state[0]; // Mass flow = 0 residuals[1] = 0.0; // No energy transfer return Ok(()); } OperationalState::Bypass => { // Behaves as a pipe: no pressure rise, no energy change let p_in = self.port_inlet.pressure().to_pascals(); let p_out = self.port_outlet.pressure().to_pascals(); let h_in = self.port_inlet.enthalpy().to_joules_per_kg(); let h_out = self.port_outlet.enthalpy().to_joules_per_kg(); residuals[0] = p_in - p_out; residuals[1] = h_in - h_out; return Ok(()); } OperationalState::On => {} } if state.len() < 2 { return Err(ComponentError::InvalidStateDimensions { expected: 2, actual: state.len(), }); } // State: [mass_flow_kg_s, power_w] let mass_flow_kg_s = state[0]; let _power_w = state[1]; // Convert to volumetric flow let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3; // Calculate pressure rise from curves let delta_p_calc = self.pressure_rise(flow_m3_s); // Get port pressures let p_in = self.port_inlet.pressure().to_pascals(); let p_out = self.port_outlet.pressure().to_pascals(); let delta_p_actual = p_out - p_in; // Residual 0: Pressure balance residuals[0] = delta_p_calc - delta_p_actual; // Residual 1: Power balance let power_calc = self.hydraulic_power(flow_m3_s).to_watts(); residuals[1] = power_calc - _power_w; Ok(()) } fn jacobian_entries( &self, state: &SystemState, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { if state.len() < 2 { return Err(ComponentError::InvalidStateDimensions { expected: 2, actual: state.len(), }); } let mass_flow_kg_s = state[0]; let flow_m3_s = mass_flow_kg_s / self.fluid_density_kg_per_m3; // Numerical derivative of pressure with respect to mass flow let h = 0.001; let p_plus = self.pressure_rise(flow_m3_s + h / self.fluid_density_kg_per_m3); let p_minus = self.pressure_rise(flow_m3_s - h / self.fluid_density_kg_per_m3); let dp_dm = (p_plus - p_minus) / (2.0 * h); // ∂r₀/∂ṁ = dΔP/dṁ jacobian.add_entry(0, 0, dp_dm); // ∂r₀/∂P = -1 (constant) jacobian.add_entry(0, 1, 0.0); // Numerical derivative of power with respect to mass flow let pow_plus = self .hydraulic_power(flow_m3_s + h / self.fluid_density_kg_per_m3) .to_watts(); let pow_minus = self .hydraulic_power(flow_m3_s - h / self.fluid_density_kg_per_m3) .to_watts(); let dpow_dm = (pow_plus - pow_minus) / (2.0 * h); // ∂r₁/∂ṁ jacobian.add_entry(1, 0, dpow_dm); // ∂r₁/∂P = -1 jacobian.add_entry(1, 1, -1.0); Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } } impl StateManageable for Pump { fn state(&self) -> OperationalState { self.operational_state } fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> { if self.operational_state.can_transition_to(state) { let from = self.operational_state; self.operational_state = state; self.on_state_change(from, state); Ok(()) } else { Err(ComponentError::InvalidStateTransition { from: self.operational_state, to: state, reason: "Transition not allowed".to_string(), }) } } fn can_transition_to(&self, target: OperationalState) -> bool { self.operational_state.can_transition_to(target) } fn circuit_id(&self) -> &CircuitId { &self.circuit_id } fn set_circuit_id(&mut self, circuit_id: CircuitId) { self.circuit_id = circuit_id; } } #[cfg(test)] mod tests { use super::*; use crate::port::FluidId; use approx::assert_relative_eq; use entropyk_core::{Enthalpy, Pressure}; fn create_test_curves() -> PumpCurves { // Typical small pump: // H = 30 - 10*Q - 50*Q² (m, Q in m³/s) // η = 0.6 + 1.0*Q - 2.0*Q² PumpCurves::quadratic(30.0, -10.0, -50.0, 0.6, 1.0, -2.0).unwrap() } fn create_test_pump_disconnected() -> Pump { let curves = create_test_curves(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); Pump::new(curves, inlet, outlet, 1000.0).unwrap() } fn create_test_pump_connected() -> Pump { let curves = create_test_curves(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap(); Pump { curves, port_inlet: inlet_conn, port_outlet: outlet_conn, fluid_density_kg_per_m3: 1000.0, speed_ratio: 1.0, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), _state: PhantomData, } } #[test] fn test_pump_curves_creation() { let curves = create_test_curves(); assert_eq!(curves.head_at_flow(0.0), 30.0); assert_relative_eq!(curves.efficiency_at_flow(0.0), 0.6); } #[test] fn test_pump_curves_head() { let curves = create_test_curves(); // H = 30 - 10*0.5 - 50*0.25 = 30 - 5 - 12.5 = 12.5 m let head = curves.head_at_flow(0.5); assert_relative_eq!(head, 12.5, epsilon = 1e-10); } #[test] fn test_pump_curves_efficiency_clamped() { let curves = create_test_curves(); // At very high flow, efficiency might go negative // Should be clamped to 0 let eff = curves.efficiency_at_flow(10.0); assert!(eff >= 0.0); } #[test] fn test_pump_creation() { let pump = create_test_pump_disconnected(); assert_eq!(pump.fluid_density(), 1000.0); assert_eq!(pump.speed_ratio(), 1.0); } #[test] fn test_pump_invalid_density() { let curves = create_test_curves(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let result = Pump::new(curves, inlet, outlet, -1.0); assert!(result.is_err()); } #[test] fn test_pump_different_fluids() { let curves = create_test_curves(); let inlet = Port::new( FluidId::new("Water"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let outlet = Port::new( FluidId::new("Glycol"), Pressure::from_bar(1.0), Enthalpy::from_joules_per_kg(100000.0), ); let result = Pump::new(curves, inlet, outlet, 1000.0); assert!(result.is_err()); } #[test] fn test_pump_set_speed_ratio() { let mut pump = create_test_pump_connected(); assert!(pump.set_speed_ratio(0.8).is_ok()); assert_eq!(pump.speed_ratio(), 0.8); } #[test] fn test_pump_set_speed_ratio_invalid() { let mut pump = create_test_pump_connected(); assert!(pump.set_speed_ratio(1.5).is_err()); assert!(pump.set_speed_ratio(-0.1).is_err()); } #[test] fn test_pump_pressure_rise_full_speed() { let pump = create_test_pump_connected(); // At Q=0: H=30m, P = 1000 * 9.8 * 30 ≈ 294200 Pa let delta_p = pump.pressure_rise(0.0); let expected = 1000.0 * 9.80665 * 30.0; assert_relative_eq!(delta_p, expected, epsilon = 100.0); } #[test] fn test_pump_pressure_rise_reduced_speed() { let mut pump = create_test_pump_connected(); pump.set_speed_ratio(0.5).unwrap(); // At 50% speed, shut-off head is 25% of full speed // H = 0.25 * 30 = 7.5 m let delta_p = pump.pressure_rise(0.0); let expected = 1000.0 * 9.80665 * 7.5; assert_relative_eq!(delta_p, expected, epsilon = 100.0); } #[test] fn test_pump_hydraulic_power() { let pump = create_test_pump_connected(); // At Q=0.1 m³/s: H ≈ 30 - 1 - 0.5 = 28.5 m // η ≈ 0.6 + 0.1 - 0.02 = 0.68 // P = 1000 * 9.8 * 0.1 * 28.5 / 0.68 ≈ 4110 W let power = pump.hydraulic_power(0.1); assert!(power.to_watts() > 0.0); assert!(power.to_watts() < 50000.0); } #[test] fn test_pump_affinity_laws_power() { let pump_full = create_test_pump_connected(); let mut pump_half = create_test_pump_connected(); pump_half.set_speed_ratio(0.5).unwrap(); // Power at half speed should be ~12.5% of full speed (cube law) // At the same equivalent flow point let power_full = pump_full.hydraulic_power(0.1); let power_half = pump_half.hydraulic_power(0.05); // Half the flow // P_half / P_full ≈ 0.5³ = 0.125 let ratio = power_half.to_watts() / power_full.to_watts(); assert_relative_eq!(ratio, 0.125, epsilon = 0.05); } #[test] fn test_pump_component_n_equations() { let pump = create_test_pump_connected(); assert_eq!(pump.n_equations(), 2); } #[test] fn test_pump_component_compute_residuals() { let pump = create_test_pump_connected(); let state = vec![50.0, 2000.0]; // mass flow, power let mut residuals = vec![0.0; 2]; let result = pump.compute_residuals(&state, &mut residuals); assert!(result.is_ok()); } #[test] fn test_pump_state_manageable() { let pump = create_test_pump_connected(); assert_eq!(pump.state(), OperationalState::On); assert!(pump.can_transition_to(OperationalState::Off)); } }