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>
351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
//! Centrifugal compressor with a normalized polytropic performance map.
|
||
//!
|
||
//! Independent variables: flow coefficient `φ = Q/(N D³)` and machine Mach
|
||
//! number `M_u = U/√(γ R T)`. Dependent: polytropic head coefficient `μ_p`
|
||
//! and polytropic efficiency `η_p`. VFD operation re-evaluates the map at the
|
||
//! new tip speed / Mach number.
|
||
|
||
use crate::port::{Connected, Disconnected, Port};
|
||
use crate::{
|
||
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, OperationalState,
|
||
ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::Power;
|
||
use std::marker::PhantomData;
|
||
|
||
/// Single map point `(φ, M_u) → (μ_p, η_p)`.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct CentrifugalMapPoint {
|
||
/// Flow coefficient φ [-].
|
||
pub phi: f64,
|
||
/// Machine Mach number M_u [-].
|
||
pub mach: f64,
|
||
/// Polytropic head coefficient μ_p [-].
|
||
pub mu_p: f64,
|
||
/// Polytropic efficiency η_p [-].
|
||
pub eta_p: f64,
|
||
}
|
||
|
||
/// Bilinear performance map on a structured `(φ, M_u)` grid.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct CentrifugalMap {
|
||
points: Vec<CentrifugalMapPoint>,
|
||
}
|
||
|
||
impl CentrifugalMap {
|
||
/// Creates a map from unsorted points (must cover a rectangle in φ–Mach).
|
||
pub fn new(points: Vec<CentrifugalMapPoint>) -> Result<Self, ComponentError> {
|
||
if points.len() < 4 {
|
||
return Err(ComponentError::InvalidState(
|
||
"CentrifugalMap needs at least 4 points".into(),
|
||
));
|
||
}
|
||
if points
|
||
.iter()
|
||
.any(|p| !p.phi.is_finite() || !p.mach.is_finite() || p.eta_p <= 0.0)
|
||
{
|
||
return Err(ComponentError::InvalidState(
|
||
"CentrifugalMap points must be finite with positive eta".into(),
|
||
));
|
||
}
|
||
Ok(Self { points })
|
||
}
|
||
|
||
/// Default demo map around φ∈[0.02,0.08], M_u∈[0.6,1.2].
|
||
pub fn default_chiller_map() -> Self {
|
||
Self::new(vec![
|
||
CentrifugalMapPoint {
|
||
phi: 0.02,
|
||
mach: 0.6,
|
||
mu_p: 0.55,
|
||
eta_p: 0.78,
|
||
},
|
||
CentrifugalMapPoint {
|
||
phi: 0.08,
|
||
mach: 0.6,
|
||
mu_p: 0.48,
|
||
eta_p: 0.80,
|
||
},
|
||
CentrifugalMapPoint {
|
||
phi: 0.02,
|
||
mach: 1.2,
|
||
mu_p: 0.62,
|
||
eta_p: 0.76,
|
||
},
|
||
CentrifugalMapPoint {
|
||
phi: 0.08,
|
||
mach: 1.2,
|
||
mu_p: 0.52,
|
||
eta_p: 0.79,
|
||
},
|
||
])
|
||
.expect("default map")
|
||
}
|
||
|
||
/// Inverse-distance weighted interpolation of (μ_p, η_p).
|
||
pub fn interpolate(&self, phi: f64, mach: f64) -> (f64, f64) {
|
||
let mut w_sum = 0.0;
|
||
let mut mu = 0.0;
|
||
let mut eta = 0.0;
|
||
for p in &self.points {
|
||
let d2 = (p.phi - phi).powi(2) + (p.mach - mach).powi(2);
|
||
let w = 1.0 / d2.max(1e-12);
|
||
w_sum += w;
|
||
mu += w * p.mu_p;
|
||
eta += w * p.eta_p;
|
||
}
|
||
(mu / w_sum, (eta / w_sum).clamp(0.2, 0.95))
|
||
}
|
||
}
|
||
|
||
/// Centrifugal compressor component (2-port).
|
||
#[derive(Debug, Clone)]
|
||
pub struct CentrifugalCompressor<State> {
|
||
map: CentrifugalMap,
|
||
/// Impeller tip diameter [m].
|
||
diameter_m: f64,
|
||
/// Rotational speed [rpm].
|
||
speed_rpm: f64,
|
||
/// Nominal speed [rpm] for VFD ratio.
|
||
nominal_speed_rpm: f64,
|
||
/// Specific gas constant R [J/(kg·K)].
|
||
gas_constant: f64,
|
||
/// Heat capacity ratio γ [-].
|
||
gamma: f64,
|
||
port_inlet: Port<State>,
|
||
port_outlet: Port<State>,
|
||
operational_state: OperationalState,
|
||
circuit_id: CircuitId,
|
||
_state: PhantomData<State>,
|
||
}
|
||
|
||
impl CentrifugalCompressor<Disconnected> {
|
||
/// Creates a disconnected centrifugal compressor.
|
||
pub fn new(
|
||
map: CentrifugalMap,
|
||
diameter_m: f64,
|
||
speed_rpm: f64,
|
||
port_inlet: Port<Disconnected>,
|
||
port_outlet: Port<Disconnected>,
|
||
) -> Result<Self, ComponentError> {
|
||
if diameter_m <= 0.0 || speed_rpm <= 0.0 {
|
||
return Err(ComponentError::InvalidState(
|
||
"diameter and speed must be positive".into(),
|
||
));
|
||
}
|
||
Ok(Self {
|
||
map,
|
||
diameter_m,
|
||
speed_rpm,
|
||
nominal_speed_rpm: speed_rpm,
|
||
gas_constant: 188.9, // R134a approx
|
||
gamma: 1.12,
|
||
port_inlet,
|
||
port_outlet,
|
||
operational_state: OperationalState::On,
|
||
circuit_id: CircuitId::default(),
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
|
||
/// Sets gas properties for Mach / head evaluation.
|
||
pub fn with_gas(mut self, r_j_kg_k: f64, gamma: f64) -> Self {
|
||
self.gas_constant = r_j_kg_k.max(50.0);
|
||
self.gamma = gamma.clamp(1.05, 1.4);
|
||
self
|
||
}
|
||
|
||
/// Connects ports.
|
||
pub fn connect(
|
||
self,
|
||
inlet: Port<Disconnected>,
|
||
outlet: Port<Disconnected>,
|
||
) -> Result<CentrifugalCompressor<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()))?;
|
||
Ok(CentrifugalCompressor {
|
||
map: self.map,
|
||
diameter_m: self.diameter_m,
|
||
speed_rpm: self.speed_rpm,
|
||
nominal_speed_rpm: self.nominal_speed_rpm,
|
||
gas_constant: self.gas_constant,
|
||
gamma: self.gamma,
|
||
port_inlet: p_in,
|
||
port_outlet: p_out,
|
||
operational_state: self.operational_state,
|
||
circuit_id: self.circuit_id,
|
||
_state: PhantomData,
|
||
})
|
||
}
|
||
}
|
||
|
||
impl CentrifugalCompressor<Connected> {
|
||
/// Tip speed U = π N D [m/s] (N in rev/s).
|
||
pub fn tip_speed(&self) -> f64 {
|
||
let n_rps = self.speed_rpm / 60.0;
|
||
std::f64::consts::PI * n_rps * self.diameter_m
|
||
}
|
||
|
||
/// Sets VFD speed [rpm].
|
||
pub fn set_speed_rpm(&mut self, rpm: f64) -> Result<(), ComponentError> {
|
||
if rpm <= 0.0 {
|
||
return Err(ComponentError::InvalidState("speed must be positive".into()));
|
||
}
|
||
self.speed_rpm = rpm;
|
||
Ok(())
|
||
}
|
||
|
||
/// Evaluates map at suction conditions; returns (head [J/kg], η_p, power [W]).
|
||
pub fn rate(
|
||
&self,
|
||
t_suction_k: f64,
|
||
rho_suction: f64,
|
||
volume_flow_m3_s: f64,
|
||
) -> Result<(f64, f64, f64), ComponentError> {
|
||
let u = self.tip_speed();
|
||
let a = (self.gamma * self.gas_constant * t_suction_k.max(200.0)).sqrt();
|
||
let mach = u / a.max(1.0);
|
||
let n_rps = self.speed_rpm / 60.0;
|
||
let phi = volume_flow_m3_s / (n_rps * self.diameter_m.powi(3)).max(1e-12);
|
||
let (mu_p, eta_p) = self.map.interpolate(phi, mach);
|
||
let head = mu_p * u * u; // J/kg
|
||
let m_dot = volume_flow_m3_s * rho_suction.max(0.1);
|
||
let power = m_dot * head / eta_p.max(0.2);
|
||
Ok((head, eta_p, power))
|
||
}
|
||
}
|
||
|
||
impl Component for CentrifugalCompressor<Connected> {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
if state.len() < 2 {
|
||
return Err(ComponentError::InvalidStateDimensions {
|
||
expected: 2,
|
||
actual: state.len(),
|
||
});
|
||
}
|
||
if residuals.len() < 2 {
|
||
return Err(ComponentError::InvalidResidualDimensions {
|
||
expected: 2,
|
||
actual: residuals.len(),
|
||
});
|
||
}
|
||
// r0: mass continuity ṁ_out − ṁ_in = 0
|
||
// r1: isentropic-like enthalpy rise placeholder using map head
|
||
let m_in = state[0];
|
||
let m_out = state[1];
|
||
residuals[0] = m_out - m_in;
|
||
let rho = 20.0; // fallback when edge density unavailable
|
||
let vol = m_in.abs() / rho;
|
||
let (_, _, power) = self.rate(280.0, rho, vol)?;
|
||
let dh = if m_in.abs() > 1e-9 {
|
||
power / m_in.abs()
|
||
} else {
|
||
0.0
|
||
};
|
||
// Enthalpy rise residual uses port enthalpies when available via state[2]/3]
|
||
if state.len() >= 4 {
|
||
residuals[1] = (state[3] - state[2]) - dh;
|
||
} else {
|
||
residuals[1] = 0.0;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
jacobian.add_entry(0, 0, -1.0);
|
||
jacobian.add_entry(0, 1, 1.0);
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
2
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
|
||
fn signature(&self) -> String {
|
||
format!(
|
||
"CentrifugalCompressor(D={:.3}m, N={:.0}rpm)",
|
||
self.diameter_m, self.speed_rpm
|
||
)
|
||
}
|
||
|
||
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
|
||
let m = state.first().copied().unwrap_or(0.0);
|
||
let vol = m.abs() / 20.0;
|
||
let power = self.rate(280.0, 20.0, vol).map(|(_, _, p)| p).unwrap_or(0.0);
|
||
Some((Power::from_watts(0.0), Power::from_watts(-power)))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::port::FluidId;
|
||
use entropyk_core::{Enthalpy, Pressure};
|
||
|
||
#[test]
|
||
fn map_interpolates_interior() {
|
||
let map = CentrifugalMap::default_chiller_map();
|
||
let (mu, eta) = map.interpolate(0.05, 0.9);
|
||
assert!(mu > 0.4 && mu < 0.7);
|
||
assert!(eta > 0.7 && eta < 0.85);
|
||
}
|
||
|
||
#[test]
|
||
fn rate_increases_with_speed() {
|
||
let inlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.0),
|
||
Enthalpy::from_joules_per_kg(400_000.0),
|
||
);
|
||
let outlet = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(430_000.0),
|
||
);
|
||
let c = CentrifugalCompressor::new(
|
||
CentrifugalMap::default_chiller_map(),
|
||
0.25,
|
||
9000.0,
|
||
inlet,
|
||
outlet,
|
||
)
|
||
.unwrap();
|
||
let connected = c.connect(
|
||
Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(3.0),
|
||
Enthalpy::from_joules_per_kg(400_000.0),
|
||
),
|
||
Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_bar(10.0),
|
||
Enthalpy::from_joules_per_kg(430_000.0),
|
||
),
|
||
)
|
||
.unwrap();
|
||
let (_, _, p_low) = connected.rate(280.0, 20.0, 0.05).unwrap();
|
||
let mut fast = connected;
|
||
fast.set_speed_rpm(12_000.0).unwrap();
|
||
let (_, _, p_high) = fast.rate(280.0, 20.0, 0.05).unwrap();
|
||
assert!(p_high > p_low);
|
||
}
|
||
}
|