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>
955 lines
32 KiB
Rust
955 lines
32 KiB
Rust
//! 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 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<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 + ½ρ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<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);
|
||
}
|
||
}
|