Files
Entropyk/crates/components/src/fan.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

955 lines
32 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,
/// 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<usize>,
inlet_p_idx: Option<usize>,
inlet_h_idx: Option<usize>,
/// Captured (m,P,h) global state indices of the outlet edge (outgoing).
outlet_m_idx: Option<usize>,
outlet_p_idx: Option<usize>,
outlet_h_idx: Option<usize>,
/// 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,
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 BernierBourret 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<Disconnected>,
outlet: Port<Disconnected>,
) -> Result<Fan<Connected>, 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::<Connected>::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<Connected> {
/// Creates a new connected fan from pre-connected ports.
pub(crate) fn from_connected_parts(
curves: FanCurves,
port_inlet: Port<Connected>,
port_outlet: Port<Connected>,
air_density: f64,
) -> Result<Self, ComponentError> {
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<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)
}
/// 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<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
}
impl Component for Fan<Connected> {
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<Vec<entropyk_core::MassFlow>, 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<Vec<entropyk_core::Enthalpy>, 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<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,
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);
}
}