//! 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, } impl CentrifugalMap { /// Creates a map from unsorted points (must cover a rectangle in φ–Mach). pub fn new(points: Vec) -> Result { 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 { 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, port_outlet: Port, operational_state: OperationalState, circuit_id: CircuitId, _state: PhantomData, } impl CentrifugalCompressor { /// Creates a disconnected centrifugal compressor. pub fn new( map: CentrifugalMap, diameter_m: f64, speed_rpm: f64, port_inlet: Port, port_outlet: Port, ) -> Result { 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, outlet: Port, ) -> Result, 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 { /// 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 { 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); } }