//! 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, /// VFD part-load efficiency curve (quadratic in speed ratio), default ≈0.97. vfd_eff_coeffs: [f64; 3], /// Motor part-load efficiency curve (quadratic in speed ratio), default ≈0.92. motor_eff_coeffs: [f64; 3], /// When true, `fan_power` returns wire-to-air electrical power. use_drive_chain: bool, /// Circuit identifier circuit_id: CircuitId, /// Operational state operational_state: OperationalState, /// When true, the fan participates in the (P,h) graph solver as a 2-port /// element imposing a design-point static pressure rise. edge_coupled: bool, /// Design volumetric flow (m³/s) at which the curve pressure rise is read. design_flow_m3_s: f64, /// Captured (m,P,h) global state indices of the inlet edge (incoming). inlet_m_idx: Option, inlet_p_idx: Option, inlet_h_idx: Option, /// Captured (m,P,h) global state indices of the outlet edge (outgoing). outlet_m_idx: Option, outlet_p_idx: Option, outlet_h_idx: Option, /// 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, vfd_eff_coeffs: [0.97, 0.0, 0.0], motor_eff_coeffs: [0.92, 0.0, 0.0], use_drive_chain: false, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), edge_coupled: false, design_flow_m3_s: 0.0, inlet_m_idx: None, inlet_p_idx: None, inlet_h_idx: None, outlet_m_idx: None, outlet_p_idx: None, outlet_h_idx: None, _state: PhantomData, }) } /// Enables Bernier–Bourret wire-to-air drive chain (η_VFD × η_motor × η_fan). pub fn with_drive_chain(mut self, enabled: bool) -> Self { self.use_drive_chain = enabled; self } /// Sets quadratic VFD efficiency coefficients η = a0 + a1·N* + a2·N*². pub fn with_vfd_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self { self.vfd_eff_coeffs = [a0, a1, a2]; self } /// Sets quadratic motor efficiency coefficients η = a0 + a1·N* + a2·N*². pub fn with_motor_efficiency(mut self, a0: f64, a1: f64, a2: f64) -> Self { self.motor_eff_coeffs = [a0, a1, a2]; self } /// 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(()) } /// Connects the fan to inlet and outlet ports, transitioning the type-state /// from `Disconnected` to `Connected` at compile time. pub fn connect( self, inlet: Port, outlet: Port, ) -> Result, 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()))?; let mut fan = Fan::::from_connected_parts( self.curves, p_in, p_out, self.air_density_kg_per_m3, )?; fan.set_speed_ratio(self.speed_ratio)?; fan.vfd_eff_coeffs = self.vfd_eff_coeffs; fan.motor_eff_coeffs = self.motor_eff_coeffs; fan.use_drive_chain = self.use_drive_chain; Ok(fan) } } impl Fan { /// Creates a new connected fan from pre-connected ports. pub(crate) fn from_connected_parts( curves: FanCurves, port_inlet: Port, port_outlet: Port, air_density: f64, ) -> Result { 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, vfd_eff_coeffs: [0.97, 0.0, 0.0], motor_eff_coeffs: [0.92, 0.0, 0.0], use_drive_chain: false, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), edge_coupled: false, design_flow_m3_s: 0.0, inlet_m_idx: None, inlet_p_idx: None, inlet_h_idx: None, outlet_m_idx: None, outlet_p_idx: None, outlet_h_idx: None, _state: PhantomData, }) } /// Enables the edge-coupled (P,h) solver model, imposing a design-point /// static pressure rise read from the fan curve at `design_flow_m3_s` /// (and the current speed ratio). Shaft power is added to the air stream as /// an enthalpy rise so the coupled model satisfies the First Law. pub fn with_edge_coupling(mut self, design_flow_m3_s: f64) -> Self { self.design_flow_m3_s = design_flow_m3_s.max(0.0); self.edge_coupled = true; if self.operational_state == OperationalState::Off { self.operational_state = OperationalState::On; } self } /// 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) } /// Shaft aerodynamic power `Q × ΔP / η_fan` [W]. pub fn shaft_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) } /// Drive-chain efficiency η_VFD(N*) × η_motor(N*) at the current speed ratio. pub fn drive_chain_efficiency(&self) -> f64 { let n = self.speed_ratio.clamp(0.0, 1.0); let eta_vfd = (self.vfd_eff_coeffs[0] + self.vfd_eff_coeffs[1] * n + self.vfd_eff_coeffs[2] * n * n) .clamp(0.05, 1.0); let eta_motor = (self.motor_eff_coeffs[0] + self.motor_eff_coeffs[1] * n + self.motor_eff_coeffs[2] * n * n) .clamp(0.05, 1.0); eta_vfd * eta_motor } /// Fan power consumption. /// /// Shaft power by default; electrical wire-to-air power when drive chain is enabled: /// `P_elec = P_shaft / (η_VFD · η_motor)`. pub fn fan_power(&self, flow_m3_per_s: f64) -> Power { let shaft = self.shaft_power(flow_m3_per_s); if !self.use_drive_chain { return shaft; } let eta_chain = self.drive_chain_efficiency(); if eta_chain <= 0.0 { return Power::from_watts(0.0); } Power::from_watts(shaft.to_watts() / eta_chain) } /// Enables or disables the wire-to-air drive chain. pub fn set_drive_chain(&mut self, enabled: bool) { self.use_drive_chain = enabled; } fn edge_coupled_flow_and_power( &self, state: &StateSlice, inlet_m_idx: usize, ) -> Result<(f64, f64, f64), ComponentError> { let mass_flow_kg_s = state.get(inlet_m_idx).copied().ok_or_else(|| { ComponentError::InvalidState(format!( "Fan edge-coupled inlet mass-flow index {inlet_m_idx} is outside the state vector" )) })?; let flow_m3_s = mass_flow_kg_s / self.air_density_kg_per_m3; let power_w = match self.operational_state { OperationalState::Off | OperationalState::Bypass => 0.0, OperationalState::On => self.fan_power(flow_m3_s).to_watts(), }; let enthalpy_rise_j_kg = if mass_flow_kg_s.abs() > 1e-12 { power_w / mass_flow_kg_s } else { 0.0 }; Ok((flow_m3_s, power_w, enthalpy_rise_j_kg)) } /// 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 set_system_context( &mut self, _state_offset: usize, external_edge_state_indices: &[(usize, usize, usize)], ) { // Layout: [0] = incoming edge, [1] = outgoing edge. // Triple: (m_idx, p_idx, h_idx) if !external_edge_state_indices.is_empty() { self.inlet_m_idx = Some(external_edge_state_indices[0].0); self.inlet_p_idx = Some(external_edge_state_indices[0].1); self.inlet_h_idx = Some(external_edge_state_indices[0].2); } if external_edge_state_indices.len() >= 2 { self.outlet_m_idx = Some(external_edge_state_indices[1].0); self.outlet_p_idx = Some(external_edge_state_indices[1].1); self.outlet_h_idx = Some(external_edge_state_indices[1].2); } } fn compute_residuals( &self, state: &StateSlice, residuals: &mut ResidualVector, ) -> Result<(), ComponentError> { // Edge-coupled (P,h) model: impose a design-point static pressure rise. if self.edge_coupled { if let (Some(in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = ( self.inlet_m_idx, self.inlet_p_idx, self.inlet_h_idx, self.outlet_p_idx, self.outlet_h_idx, ) { if residuals.len() < 2 { return Err(ComponentError::InvalidResidualDimensions { expected: 2, actual: residuals.len(), }); } let dp = match self.operational_state { OperationalState::Off => 0.0, _ => { let (flow_m3_s, _, _) = self.edge_coupled_flow_and_power(state, in_m)?; self.static_pressure_rise(flow_m3_s) } }; let (_, _, enthalpy_rise_j_kg) = self.edge_coupled_flow_and_power(state, in_m)?; // r0: imposed static pressure rise (fan adds pressure) residuals[0] = state[out_p] - (state[in_p] + dp); // r1: adiabatic fan casing, shaft power heats the air stream residuals[1] = state[out_h] - (state[in_h] + enthalpy_rise_j_kg); return Ok(()); } return Err(ComponentError::InvalidState( "Fan edge-coupled model requires inlet and outlet edge state indices".to_string(), )); } Err(ComponentError::InvalidState( "Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(), )) } fn jacobian_entries( &self, _state: &StateSlice, jacobian: &mut JacobianBuilder, ) -> Result<(), ComponentError> { // Edge-coupled (P,h) model. if self.edge_coupled { if let (Some(_in_m), Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = ( self.inlet_m_idx, self.inlet_p_idx, self.inlet_h_idx, self.outlet_p_idx, self.outlet_h_idx, ) { // r0 = P_out - (P_in + dp) jacobian.add_entry(0, out_p, 1.0); jacobian.add_entry(0, in_p, -1.0); // r1 = h_out - h_in jacobian.add_entry(1, out_h, 1.0); jacobian.add_entry(1, in_h, -1.0); return Ok(()); } return Err(ComponentError::InvalidState( "Fan edge-coupled model requires inlet and outlet edge state indices".to_string(), )); } Err(ComponentError::InvalidState( "Fan physical simulation requires edge-coupled inlet/outlet state indices".to_string(), )) } fn n_equations(&self) -> usize { 2 } fn get_ports(&self) -> &[ConnectedPort] { &[] } fn port_mass_flows( &self, state: &StateSlice, ) -> Result, ComponentError> { if self.edge_coupled { let (Some(in_m), Some(out_m)) = (self.inlet_m_idx, self.outlet_m_idx) else { return Err(ComponentError::InvalidState( "Fan edge-coupled model requires inlet and outlet mass-flow indices" .to_string(), )); }; let max_idx = in_m.max(out_m); if max_idx >= state.len() { return Err(ComponentError::InvalidStateDimensions { expected: max_idx + 1, actual: state.len(), }); } return Ok(vec![ entropyk_core::MassFlow::from_kg_per_s(state[in_m]), entropyk_core::MassFlow::from_kg_per_s(-state[out_m]), ]); } Err(ComponentError::InvalidState( "Fan mass-flow reporting requires edge-coupled inlet/outlet indices".to_string(), )) } fn port_enthalpies( &self, state: &StateSlice, ) -> Result, ComponentError> { if self.edge_coupled { let (Some(in_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) else { return Err(ComponentError::InvalidState( "Fan edge-coupled model requires inlet and outlet enthalpy indices".to_string(), )); }; let max_idx = in_h.max(out_h); if max_idx >= state.len() { return Err(ComponentError::InvalidStateDimensions { expected: max_idx + 1, actual: state.len(), }); } return Ok(vec![ entropyk_core::Enthalpy::from_joules_per_kg(state[in_h]), entropyk_core::Enthalpy::from_joules_per_kg(state[out_h]), ]); } Err(ComponentError::InvalidState( "Fan enthalpy reporting requires edge-coupled inlet/outlet indices".to_string(), )) } 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 self.edge_coupled { let Some(in_m) = self.inlet_m_idx else { return None; }; let Ok((_, power_w, _)) = self.edge_coupled_flow_and_power(state, in_m) else { return None; }; return Some(( entropyk_core::Power::from_watts(0.0), entropyk_core::Power::from_watts(-power_w), )); } let in_m = self.inlet_m_idx?; let mass_flow_kg_s = *state.get(in_m)?; 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), )) } } } fn signature(&self) -> String { format!("Fan(circuit={})", self.circuit_id.0) } fn to_params(&self) -> crate::ComponentParams { crate::ComponentParams::new("Fan") .with_param("circuitId", self.circuit_id.0) .with_param("airDensityKgPerM3", self.air_density_kg_per_m3) .with_param("speedRatio", self.speed_ratio) } } 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, vfd_eff_coeffs: [0.97, 0.0, 0.0], motor_eff_coeffs: [0.92, 0.0, 0.0], use_drive_chain: false, circuit_id: CircuitId::default(), operational_state: OperationalState::default(), edge_coupled: false, design_flow_m3_s: 0.0, inlet_m_idx: None, inlet_p_idx: None, inlet_h_idx: None, outlet_m_idx: None, outlet_p_idx: None, outlet_h_idx: None, _state: PhantomData, } } #[test] fn test_wire_to_air_drive_chain_increases_power() { let mut fan = create_test_fan_connected(); let shaft = fan.shaft_power(0.5).to_watts(); fan.set_drive_chain(true); let elec = fan.fan_power(0.5).to_watts(); assert!(elec > shaft); assert!((elec / shaft - 1.0 / (0.97 * 0.92)).abs() < 1e-6); } #[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); } }