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>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,350 @@
//! 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);
}
}