Files
Entropyk/crates/components/src/centrifugal_compressor.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

351 lines
11 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.
//! 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);
}
}