Files
Entropyk/crates/components/src/fan.rs

702 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Self, ComponentError> {
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<f64>,
eff_coeffs: Vec<f64>,
) -> Result<Self, ComponentError> {
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, ComponentError> {
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, ComponentError> {
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<State> {
/// Performance curves
curves: FanCurves,
/// Inlet port
port_inlet: Port<State>,
/// Outlet port
port_outlet: Port<State>,
/// 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<State>,
}
impl Fan<Disconnected> {
/// 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<Disconnected>,
port_outlet: Port<Disconnected>,
air_density: f64,
) -> Result<Self, ComponentError> {
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<Connected> {
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&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 + ½ρ
///
/// # 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 = ½ρ
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<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
}
impl Component for Fan<Connected> {
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<Vec<entropyk_core::MassFlow>, 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<Vec<entropyk_core::Enthalpy>, 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<Connected> {
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<Connected> {
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);
}
}