//! Fan Component Implementation //! //! This module provides a fan component for air handling systems using //! polynomial performance curves and affinity laws for variable speed operation. //! //! ## Performance Curves //! //! **Static Pressure Curve:** P_s = a₀ + a₁Q + a₂Q² + a₃Q³ //! //! **Efficiency Curve:** η = b₀ + b₁Q + b₂Q² //! //! **Fan Power:** P_fan = Q × P_s / η //! //! ## Affinity Laws (Variable Speed) //! //! When operating at reduced speed (VFD): //! - Q₂/Q₁ = N₂/N₁ //! - P₂/P₁ = (N₂/N₁)² //! - Pwr₂/Pwr₁ = (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, StateSlice, }; use entropyk_core::{MassFlow, Power}; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; /// Fan performance curve coefficients. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FanCurves { /// Performance curves (static pressure, efficiency) curves: PerformanceCurves, } impl FanCurves { /// Creates fan curves from performance curves. pub fn new(curves: PerformanceCurves) -> Result { curves.validate()?; Ok(Self { curves }) } /// Creates fan curves from polynomial coefficients. /// /// # Arguments /// /// * `pressure_coeffs` - Static pressure curve [a0, a1, a2, ...] in Pa /// * `eff_coeffs` - Efficiency coefficients [b0, b1, b2, ...] as decimal /// /// # Units /// /// * Q (flow) in m³/s /// * P_s (static pressure) in Pascals /// * η (efficiency) as decimal (0.0 to 1.0) pub fn from_coefficients( pressure_coeffs: Vec, eff_coeffs: Vec, ) -> Result { let pressure_curve = Polynomial1D::new(pressure_coeffs); let eff_curve = Polynomial1D::new(eff_coeffs); let curves = PerformanceCurves::simple(pressure_curve, eff_curve); Self::new(curves) } /// Creates a quadratic fan curve. pub fn quadratic( p0: f64, p1: f64, p2: f64, e0: f64, e1: f64, e2: f64, ) -> Result { Self::from_coefficients(vec![p0, p1, p2], vec![e0, e1, e2]) } /// Creates a cubic fan curve (common for fans). pub fn cubic( p0: f64, p1: f64, p2: f64, p3: f64, e0: f64, e1: f64, e2: f64, ) -> Result { Self::from_coefficients(vec![p0, p1, p2, p3], vec![e0, e1, e2]) } /// Returns static pressure at given flow rate (full speed). pub fn static_pressure_at_flow(&self, flow_m3_per_s: f64) -> f64 { self.curves.head_curve.evaluate(flow_m3_per_s) } /// Returns efficiency at given flow rate (full speed). pub fn efficiency_at_flow(&self, flow_m3_per_s: f64) -> f64 { let eta = self.curves.efficiency_curve.evaluate(flow_m3_per_s); eta.clamp(0.0, 1.0) } /// Returns reference to performance curves. pub fn curves(&self) -> &PerformanceCurves { &self.curves } } impl Default for FanCurves { fn default() -> Self { Self::quadratic(500.0, 0.0, 0.0, 0.7, 0.0, 0.0).unwrap() } } /// Standard air properties at sea level (for reference). pub mod standard_air { /// Standard air density at 20°C, 101325 Pa (kg/m³) pub const DENSITY: f64 = 1.204; /// Standard air specific heat at constant pressure (J/(kg·K)) pub const CP: f64 = 1005.0; } /// A fan component with polynomial performance curves. /// /// Fans differ from pumps in that: /// - They work with compressible fluids (air) /// - Static pressure is typically much lower /// - Common to use cubic curves for pressure /// /// # Example /// /// ```ignore /// use entropyk_components::fan::{Fan, FanCurves}; /// use entropyk_components::port::{FluidId, Port}; /// use entropyk_core::{Pressure, Enthalpy}; /// /// // Create fan curves: P_s = 500 - 50*Q - 10*Q² (Pa, m³/s) /// let curves = FanCurves::quadratic(500.0, -50.0, -10.0, 0.5, 0.2, -0.1).unwrap(); /// /// let inlet = Port::new( /// FluidId::new("Air"), /// Pressure::from_bar(1.01325), /// Enthalpy::from_joules_per_kg(300000.0), /// ); /// let outlet = Port::new( /// FluidId::new("Air"), /// Pressure::from_bar(1.01325), /// Enthalpy::from_joules_per_kg(300000.0), /// ); /// /// let fan = Fan::new(curves, inlet, outlet, 1.2).unwrap(); /// ``` #[derive(Debug, Clone)] pub struct Fan { /// Performance curves curves: FanCurves, /// Inlet port port_inlet: Port, /// Outlet port port_outlet: Port, /// Air density in kg/m³ air_density_kg_per_m3: f64, /// Speed ratio (0.0 to 1.0) speed_ratio: f64, /// Circuit identifier circuit_id: CircuitId, /// Operational state operational_state: OperationalState, /// Phantom data for type state _state: PhantomData, } impl Fan { /// Creates a new disconnected fan. /// /// # Arguments /// /// * `curves` - Fan performance curves /// * `port_inlet` - Inlet port (disconnected) /// * `port_outlet` - Outlet port (disconnected) /// * `air_density` - Air density in kg/m³ (use 1.2 for standard conditions) pub fn new( curves: FanCurves, port_inlet: Port, port_outlet: Port, air_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 air_density <= 0.0 { return Err(ComponentError::InvalidState( "Air density must be positive".to_string(), )); } Ok(Self { curves, port_inlet, port_outlet, air_density_kg_per_m3: air_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 air density. pub fn air_density(&self) -> f64 { self.air_density_kg_per_m3 } /// 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 Fan { /// 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 static pressure rise across the fan. /// /// Applies affinity laws for variable speed operation. pub fn static_pressure_rise(&self, flow_m3_per_s: f64) -> f64 { // Handle zero speed - fan 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 p0 = self.curves.static_pressure_at_flow(0.0); let p_eps = self.curves.static_pressure_at_flow(1e-6); let dp_dq = (p_eps - p0) / 1e-6; let pressure = p0 + dp_dq * flow_m3_per_s; return AffinityLaws::scale_head(pressure, self.speed_ratio); } // Handle exactly zero flow if flow_m3_per_s == 0.0 { let pressure = self.curves.static_pressure_at_flow(0.0); return AffinityLaws::scale_head(pressure, self.speed_ratio); } let equivalent_flow = AffinityLaws::unscale_flow(flow_m3_per_s, self.speed_ratio); let pressure = self.curves.static_pressure_at_flow(equivalent_flow); AffinityLaws::scale_head(pressure, self.speed_ratio) } /// Calculates total pressure (static + velocity pressure). /// /// Total pressure = Static pressure + ½ρv² /// /// # Arguments /// /// * `flow_m3_per_s` - Volumetric flow rate /// * `duct_area_m2` - Duct cross-sectional area pub fn total_pressure_rise(&self, flow_m3_per_s: f64, duct_area_m2: f64) -> f64 { let static_p = self.static_pressure_rise(flow_m3_per_s); if duct_area_m2 <= 0.0 { return static_p; } // Velocity pressure: P_v = ½ρv² let velocity = flow_m3_per_s / duct_area_m2; let velocity_pressure = 0.5 * self.air_density_kg_per_m3 * velocity * velocity; static_p + velocity_pressure } /// Calculates efficiency at the given flow rate. pub fn efficiency(&self, flow_m3_per_s: f64) -> f64 { // Handle zero speed - fan 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 fan power consumption. /// /// P_fan = Q × P_s / η pub fn fan_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 pressure = self.static_pressure_rise(flow_m3_per_s); let eta = self.efficiency(flow_m3_per_s); if eta <= 0.0 { return Power::from_watts(0.0); } let power_w = flow_m3_per_s * pressure / eta; Power::from_watts(power_w) } /// Calculates mass flow 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.air_density_kg_per_m3) } /// Calculates volumetric flow from mass flow. pub fn volumetric_from_mass_flow(&self, mass_flow: MassFlow) -> f64 { mass_flow.to_kg_per_s() / self.air_density_kg_per_m3 } /// Returns the air density. pub fn air_density(&self) -> f64 { self.air_density_kg_per_m3 } /// 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 Fan { fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { if residuals.len() != self.n_equations() { return Err(ComponentError::InvalidResidualDimensions { expected: self.n_equations(), actual: residuals.len(), }); } match self.operational_state { OperationalState::Off => { residuals[0] = state[0]; residuals[1] = 0.0; return Ok(()); } OperationalState::Bypass => { 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(), }); } let mass_flow_kg_s = state[0]; let _power_w = state[1]; let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3; let delta_p_calc = self.static_pressure_rise(flow_m3_s); 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; residuals[0] = delta_p_calc - delta_p_actual; let power_calc = self.fan_power(flow_m3_s).to_watts(); residuals[1] = power_calc - _power_w; Ok(()) } fn jacobian_entries( &self, state: &StateSlice, 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.air_density_kg_per_m3; let h = 0.001; let p_plus = self.static_pressure_rise(flow_m3_s + h / self.air_density_kg_per_m3); let p_minus = self.static_pressure_rise(flow_m3_s - h / self.air_density_kg_per_m3); let dp_dm = (p_plus - p_minus) / (2.0 * h); jacobian.add_entry(0, 0, dp_dm); jacobian.add_entry(0, 1, 0.0); let pow_plus = self .fan_power(flow_m3_s + h / self.air_density_kg_per_m3) .to_watts(); let pow_minus = self .fan_power(flow_m3_s - h / self.air_density_kg_per_m3) .to_watts(); let dpow_dm = (pow_plus - pow_minus) / (2.0 * h); jacobian.add_entry(1, 0, dpow_dm); jacobian.add_entry(1, 1, -1.0); Ok(()) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows( &self, state: &StateSlice, ) -> Result, ComponentError> { if state.len() < 1 { return Err(ComponentError::InvalidStateDimensions { expected: 1, actual: state.len(), }); } // Fan has inlet and outlet with same mass flow (air is incompressible for HVAC applications) let m = entropyk_core::MassFlow::from_kg_per_s(state[0]); // Inlet (positive = entering), Outlet (negative = leaving) Ok(vec![ m, entropyk_core::MassFlow::from_kg_per_s(-m.to_kg_per_s()), ]) } fn port_enthalpies( &self, _state: &StateSlice, ) -> Result, ComponentError> { // Fan uses internally simulated enthalpies Ok(vec![ self.port_inlet.enthalpy(), self.port_outlet.enthalpy(), ]) } fn energy_transfers( &self, state: &StateSlice, ) -> Option<(entropyk_core::Power, entropyk_core::Power)> { match self.operational_state { OperationalState::Off | OperationalState::Bypass => Some(( entropyk_core::Power::from_watts(0.0), entropyk_core::Power::from_watts(0.0), )), OperationalState::On => { if state.is_empty() { return None; } let mass_flow_kg_s = state[0]; let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3; let power_calc = self.fan_power(flow_m3_s).to_watts(); Some(( entropyk_core::Power::from_watts(0.0), entropyk_core::Power::from_watts(-power_calc), )) } } } } impl StateManageable for Fan { 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() -> FanCurves { // Typical centrifugal fan: // P_s = 500 - 100*Q - 200*Q² (Pa, Q in m³/s) // η = 0.5 + 0.3*Q - 0.5*Q² FanCurves::quadratic(500.0, -100.0, -200.0, 0.5, 0.3, -0.5).unwrap() } fn create_test_fan_connected() -> Fan { let curves = create_test_curves(); let inlet = Port::new( FluidId::new("Air"), Pressure::from_bar(1.01325), Enthalpy::from_joules_per_kg(300000.0), ); let outlet = Port::new( FluidId::new("Air"), Pressure::from_bar(1.01325), Enthalpy::from_joules_per_kg(300000.0), ); let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap(); Fan { curves, port_inlet: inlet_conn, port_outlet: outlet_conn, air_density_kg_per_m3: 1.2, speed_ratio: 1.0, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), _state: PhantomData, } } #[test] fn test_fan_curves_creation() { let curves = create_test_curves(); assert_eq!(curves.static_pressure_at_flow(0.0), 500.0); assert_relative_eq!(curves.efficiency_at_flow(0.0), 0.5); } #[test] fn test_fan_static_pressure() { let curves = create_test_curves(); // P_s = 500 - 100*1 - 200*1 = 200 Pa let pressure = curves.static_pressure_at_flow(1.0); assert_relative_eq!(pressure, 200.0, epsilon = 1e-10); } #[test] fn test_fan_creation() { let fan = create_test_fan_connected(); assert_relative_eq!(fan.air_density(), 1.2, epsilon = 1e-10); assert_eq!(fan.speed_ratio(), 1.0); } #[test] fn test_fan_pressure_rise_full_speed() { let fan = create_test_fan_connected(); let pressure = fan.static_pressure_rise(0.0); assert_relative_eq!(pressure, 500.0, epsilon = 1e-10); } #[test] fn test_fan_pressure_rise_half_speed() { let mut fan = create_test_fan_connected(); fan.set_speed_ratio(0.5).unwrap(); // At 50% speed, shut-off pressure is 25% of full speed let pressure = fan.static_pressure_rise(0.0); assert_relative_eq!(pressure, 125.0, epsilon = 1e-10); } #[test] fn test_fan_fan_power() { let fan = create_test_fan_connected(); // At Q=1 m³/s: P_s ≈ 200 Pa, η ≈ 0.3 // P = 1 * 200 / 0.3 ≈ 667 W let power = fan.fan_power(1.0); assert!(power.to_watts() > 0.0); assert!(power.to_watts() < 2000.0); } #[test] fn test_fan_affinity_laws_power() { let fan_full = create_test_fan_connected(); let mut fan_half = create_test_fan_connected(); fan_half.set_speed_ratio(0.5).unwrap(); let power_full = fan_full.fan_power(1.0); let power_half = fan_half.fan_power(0.5); // Ratio should be approximately 0.125 (cube law) let ratio = power_half.to_watts() / power_full.to_watts(); assert_relative_eq!(ratio, 0.125, epsilon = 0.1); } #[test] fn test_fan_total_pressure() { let fan = create_test_fan_connected(); // With a duct area of 0.5 m² let total_p = fan.total_pressure_rise(1.0, 0.5); let static_p = fan.static_pressure_rise(1.0); // Total > Static due to velocity pressure assert!(total_p > static_p); } #[test] fn test_fan_component_n_equations() { let fan = create_test_fan_connected(); assert_eq!(fan.n_equations(), 2); } #[test] fn test_fan_state_manageable() { let fan = create_test_fan_connected(); assert_eq!(fan.state(), OperationalState::On); assert!(fan.can_transition_to(OperationalState::Off)); } #[test] fn test_standard_air_constants() { assert_relative_eq!(standard_air::DENSITY, 1.204, epsilon = 0.01); assert_relative_eq!(standard_air::CP, 1005.0); } }