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

@@ -30,10 +30,13 @@
//! ```
use super::bphx_correlation::{
BphxCorrelation, CorrelationParams, CorrelationResult, CorrelationSelector, FlowRegime,
ValidityStatus,
BphxCorrelation, CorrelationEvaluation, CorrelationParams, CorrelationResult,
CorrelationSelector, ValidityStatus,
};
use super::bphx_geometry::{BphxGeometry, BphxType};
use super::correlation_registry::{
CorrelationSelectionError, ExchangerGeometryType, FlowRegime, SelectionOutcome,
};
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
@@ -41,7 +44,7 @@ use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::sync::Arc;
/// BphxExchanger - Brazed Plate Heat Exchanger component
@@ -57,6 +60,7 @@ pub struct BphxExchanger {
fluid_backend: Option<Arc<dyn entropyk_fluids::FluidBackend>>,
last_htc: Cell<f64>,
last_htc_result: Cell<Option<CorrelationResult>>,
last_selection: RefCell<Option<SelectionOutcome>>,
last_validity_warning: Cell<bool>,
}
@@ -109,6 +113,7 @@ impl BphxExchanger {
fluid_backend: None,
last_htc: Cell::new(0.0),
last_htc_result: Cell::new(None),
last_selection: RefCell::new(None),
last_validity_warning: Cell::new(false),
}
}
@@ -125,6 +130,7 @@ impl BphxExchanger {
fluid_backend: None,
last_htc: Cell::new(0.0),
last_htc_result: Cell::new(None),
last_selection: RefCell::new(None),
last_validity_warning: Cell::new(false),
}
}
@@ -147,22 +153,46 @@ impl BphxExchanger {
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
self.set_refrigerant_id(fluid);
self.inner.set_hot_fluid(self.refrigerant_id.clone());
self
}
/// Sets the refrigerant identifier used by correlation applicability selection.
pub fn set_refrigerant_id(&mut self, fluid: impl Into<String>) {
self.refrigerant_id = fluid.into();
}
/// Returns the configured refrigerant identifier, when present.
pub fn refrigerant_id(&self) -> Option<&str> {
(!self.refrigerant_id.is_empty()).then_some(self.refrigerant_id.as_str())
}
/// Sets the secondary fluid identifier (water, brine, etc.).
pub fn with_secondary_fluid(mut self, fluid: impl Into<String>) -> Self {
self.secondary_fluid_id = fluid.into();
self.inner.set_cold_fluid(self.secondary_fluid_id.clone());
self
}
/// Attaches a fluid backend for property queries.
pub fn with_fluid_backend(mut self, backend: Arc<dyn entropyk_fluids::FluidBackend>) -> Self {
self.inner
.set_fluid_backend_from_builder(Arc::clone(&backend));
self.fluid_backend = Some(backend);
self
}
/// Declares the hot-side live-port fluid for the delegated four-port exchanger.
pub fn set_hot_fluid(&mut self, fluid: impl Into<String>) {
self.inner.set_hot_fluid(fluid);
}
/// Declares the cold-side live-port fluid for the delegated four-port exchanger.
pub fn set_cold_fluid(&mut self, fluid: impl Into<String>) {
self.inner.set_cold_fluid(fluid);
}
/// Returns the component name.
pub fn name(&self) -> &str {
self.inner.name()
@@ -173,9 +203,13 @@ impl BphxExchanger {
&self.geometry
}
/// Returns the name of the configured heat transfer correlation.
/// Returns the configured formula name, or `Automatic` before/while auto-selecting.
pub fn correlation_name(&self) -> &'static str {
self.correlation_selector.correlation.name()
if self.correlation_selector.automatic {
"Automatic"
} else {
self.correlation_selector.correlation.name()
}
}
/// Returns the effective UA value (W/K).
@@ -203,6 +237,11 @@ impl BphxExchanger {
self.last_htc_result.take()
}
/// Returns the latest ranked selection report.
pub fn last_selection_report(&self) -> Option<SelectionOutcome> {
self.last_selection.borrow().clone()
}
/// Returns whether a validity warning was issued.
pub fn had_validity_warning(&self) -> bool {
self.last_validity_warning.get()
@@ -245,7 +284,7 @@ impl BphxExchanger {
pr_l: f64,
t_sat: f64,
t_wall: f64,
) -> CorrelationResult {
) -> Result<CorrelationEvaluation, CorrelationSelectionError> {
let regime = match self.geometry.exchanger_type {
BphxType::Evaporator => FlowRegime::Evaporation,
BphxType::Condenser => FlowRegime::Condensation,
@@ -266,18 +305,37 @@ impl BphxExchanger {
t_wall,
regime,
chevron_angle: self.geometry.chevron_angle,
p_reduced: 0.2,
};
let result = self.correlation_selector.compute_htc(&params);
let evaluation = match self.correlation_selector.select_and_compute(
&params,
ExchangerGeometryType::BrazedPlate,
self.refrigerant_id(),
) {
Ok(evaluation) => evaluation,
Err(error) => {
self.clear_last_htc_evaluation();
return Err(error);
}
};
let result = &evaluation.result;
self.last_htc.set(result.h);
self.last_htc_result.set(Some(result.clone()));
self.last_selection
.replace(Some(evaluation.selection.clone()));
self.last_validity_warning
.set(result.validity != ValidityStatus::Valid);
if result.validity == ValidityStatus::Extrapolation {
self.last_validity_warning.set(true);
}
Ok(evaluation)
}
result
fn clear_last_htc_evaluation(&self) {
self.last_htc.set(0.0);
self.last_htc_result.set(None);
self.last_selection.replace(None);
self.last_validity_warning.set(false);
}
/// Computes the pressure drop using a simplified correlation.
@@ -285,7 +343,7 @@ impl BphxExchanger {
/// ΔP = f_dp × (2 × f × L × G²) / (ρ × d_h)
///
/// where f is the friction factor, L is the plate length, G is mass flux.
/// The result is scaled by `calib().f_dp`.
/// The result is scaled by `calib().z_dp`.
///
/// # Arguments
///
@@ -300,24 +358,47 @@ impl BphxExchanger {
return 0.0;
}
let re = mass_flux * self.geometry.dh / 0.0002;
let f = if re < 2300.0 {
64.0 / re.max(1.0)
let re = mass_flux.abs() * self.geometry.dh / 0.0002;
// C¹ laminar→turbulent blend over [2300, 4000] (same rationale as the
// pipe friction factor and the Gnielinski correlation): a hard switch at
// Re = 2300 put a kink in the pressure-drop residual that the analytic
// Newton Jacobian could not see, hurting convergence near the transition.
// Reynolds uses |mass_flux| so transient reverse flow during iteration
// yields a physical friction factor instead of the flat `64` the old
// signed/`max(1.0)` form produced. The lower clamp only guards the exact
// div-by-zero at stagnation; at 1e-9 it is far below any reachable Re, so
// it introduces no visible kink (the previous `max(1.0)` kinked at Re = 1).
const RE_LAMINAR: f64 = 2300.0;
const RE_TURBULENT: f64 = 4000.0;
let f_laminar = 64.0 / re.max(1e-9);
let f = if re <= RE_LAMINAR {
f_laminar
} else {
0.079 * re.powf(-0.25)
let f_turbulent = 0.079 * re.powf(-0.25);
if re >= RE_TURBULENT {
f_turbulent
} else {
entropyk_core::smoothing::cubic_blend(
f_laminar,
f_turbulent,
re,
RE_LAMINAR,
RE_TURBULENT,
)
}
};
let dp_base =
2.0 * f * self.geometry.plate_length * mass_flux.powi(2) / (rho * self.geometry.dh);
dp_base * self.calib().f_dp
dp_base * self.calib().z_dp
}
/// Updates UA based on computed HTC.
///
/// UA_eff = h × A × f_ua
pub fn update_ua_from_htc(&mut self, h: f64) {
let ua = h * self.geometry.area * self.calib().f_ua;
let ua = h * self.geometry.area * self.calib().z_ua;
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
}
}
@@ -347,6 +428,18 @@ impl Component for BphxExchanger {
self.inner.get_ports()
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
self.inner.set_port_context(port_edges);
}
fn port_names(&self) -> Vec<String> {
self.inner.port_names()
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
self.inner.flow_paths()
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
self.inner.set_calib_indices(indices);
}
@@ -363,10 +456,14 @@ impl Component for BphxExchanger {
self.inner.energy_transfers(state)
}
fn set_fluid_backend_from_builder(&mut self, backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>) {
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(backend);
self.fluid_backend = Some(Arc::clone(&backend));
}
self.inner.set_fluid_backend_from_builder(backend);
}
fn signature(&self) -> String {
@@ -375,7 +472,7 @@ impl Component for BphxExchanger {
self.geometry.n_plates,
self.geometry.dh * 1000.0,
self.geometry.area,
self.correlation_selector.correlation.name()
self.correlation_name()
)
}
@@ -456,7 +553,24 @@ mod tests {
let mut residuals = vec![0.0; 3];
let result = hx.compute_residuals(&state, &mut residuals);
assert!(result.is_ok());
assert!(matches!(result, Err(ComponentError::InvalidState(_))));
}
#[test]
fn test_bphx_exchanger_exposes_four_port_metadata() {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
assert_eq!(
hx.port_names(),
vec![
"hot_inlet".to_string(),
"hot_outlet".to_string(),
"cold_inlet".to_string(),
"cold_outlet".to_string(),
]
);
assert_eq!(hx.flow_paths(), vec![(0, 1), (2, 3)]);
}
#[test]
@@ -486,13 +600,24 @@ mod tests {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let result = hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
let result = hx
.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap();
assert!(result.h > 0.0);
assert!(result.re > 0.0);
assert!(result.nu > 0.0);
assert_eq!(
result.selection.selected,
super::super::correlation_registry::CorrelationId::Longo2004
);
assert_eq!(result.selection.assessments.len(), 8);
assert_eq!(
hx.last_selection_report().unwrap().selected,
result.selection.selected
);
}
#[test]
@@ -502,9 +627,11 @@ mod tests {
assert_eq!(hx.last_htc(), 0.0);
let result = hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
let result = hx
.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap();
assert!((hx.last_htc() - result.h).abs() < 1e-10);
}
@@ -517,7 +644,7 @@ mod tests {
let sig = hx.signature();
assert!(sig.contains("BphxExchanger"));
assert!(sig.contains("20 plates"));
assert!(sig.contains("Longo (2004)"));
assert!(sig.contains("Automatic"));
}
#[test]
@@ -535,7 +662,7 @@ mod tests {
let geo = test_geometry();
let hx = BphxExchanger::new(geo);
let calib = hx.calib();
assert_eq!(calib.f_ua, 1.0);
assert_eq!(calib.z_ua, 1.0);
}
#[test]
@@ -543,9 +670,9 @@ mod tests {
let geo = test_geometry();
let mut hx = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_ua = 0.9;
calib.z_ua = 0.9;
hx.set_calib(calib);
assert_eq!(hx.calib().f_ua, 0.9);
assert_eq!(hx.calib().z_ua, 0.9);
}
#[test]
@@ -570,18 +697,59 @@ mod tests {
#[test]
fn test_bphx_exchanger_validity_warning() {
let geo = test_geometry();
let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
let hx = BphxExchanger::new(geo).with_correlation(BphxCorrelation::Longo2004);
assert!(!hx.had_validity_warning());
hx.compute_htc(
100.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
);
)
.unwrap();
assert!(hx.had_validity_warning());
}
#[test]
fn test_bphx_exchanger_invalid_input_returns_error() {
let hx = BphxExchanger::new(test_geometry());
let error = hx
.compute_htc(
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap_err();
assert!(matches!(
error,
CorrelationSelectionError::InvalidInput(
super::super::correlation_registry::DomainInputError::InvalidQuality { .. }
)
));
assert_eq!(hx.last_htc(), 0.0);
assert!(hx.last_selection_report().is_none());
}
#[test]
fn failed_htc_evaluation_clears_previous_success_state() {
let hx = BphxExchanger::new(test_geometry()).with_refrigerant("R134a");
hx.compute_htc(
30.0, 0.5, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap();
assert!(hx.last_htc() > 0.0);
assert!(hx.last_selection_report().is_some());
hx.compute_htc(
30.0, 1.2, 1100.0, 30.0, 0.0002, 0.000012, 0.1, 3.5, 280.0, 285.0,
)
.unwrap_err();
assert_eq!(hx.last_htc(), 0.0);
assert!(hx.last_htc_result().is_none());
assert!(hx.last_selection_report().is_none());
assert!(!hx.had_validity_warning());
}
#[test]
fn test_bphx_exchanger_pressure_drop() {
let geo = test_geometry();
@@ -592,10 +760,88 @@ mod tests {
let mut hx_with_calib = BphxExchanger::new(geo);
let mut calib = Calib::default();
calib.f_dp = 0.5;
calib.z_dp = 0.5;
hx_with_calib.set_calib(calib);
let dp_calib = hx_with_calib.compute_pressure_drop(30.0, 1100.0);
assert!((dp_calib - dp * 0.5).abs() < 1e-6);
}
#[test]
fn test_bphx_pressure_drop_transition_is_c1() {
// re = mass_flux · dh / 0.0002 = mass_flux · 15 for dh = 0.003.
// The laminar→turbulent friction blend over [2300, 4000] must keep ΔP
// and its slope continuous at the band edges so the analytic Jacobian
// sees no kink.
let hx = BphxExchanger::new(test_geometry());
let rho = 1100.0;
let re_to_g = |re: f64| re / 15.0;
let d_g = 0.01; // mass-flux step for the finite-difference slope
for &re_edge in &[2300.0_f64, 4000.0_f64] {
let g = re_to_g(re_edge);
let dp_below = hx.compute_pressure_drop(g - d_g, rho);
let dp_at = hx.compute_pressure_drop(g, rho);
let dp_above = hx.compute_pressure_drop(g + d_g, rho);
let rel_jump = (dp_above - dp_below).abs() / dp_at.abs();
assert!(
rel_jump < 0.01,
"ΔP jump {:.4} at Re = {}",
rel_jump,
re_edge
);
let slope_below = (dp_at - dp_below) / d_g;
let slope_above = (dp_above - dp_at) / d_g;
let denom = slope_below.abs().max(1e-9);
assert!(
(slope_above - slope_below).abs() / denom < 0.5,
"ΔP slope discontinuity at Re = {} ({:.4} vs {:.4})",
re_edge,
slope_below,
slope_above
);
}
}
#[test]
fn test_bphx_pressure_drop_reverse_flow_and_low_re() {
// ΔP must be a positive magnitude and symmetric under flow reversal
// (friction now uses |Re|), and must not exhibit the old slope kink at
// Re = 1 (the `re.max(1.0)` floor was lowered to a pure div-by-zero
// guard). It must also stay finite and vanish at exactly zero flow.
let hx = BphxExchanger::new(test_geometry());
let rho = 1100.0;
for &g in &[0.5_f64, 5.0, 50.0] {
let dp_fwd = hx.compute_pressure_drop(g, rho);
let dp_rev = hx.compute_pressure_drop(-g, rho);
assert!(dp_fwd >= 0.0, "ΔP must be non-negative, got {dp_fwd}");
assert!(
(dp_fwd - dp_rev).abs() <= 1e-9 * dp_fwd.max(1.0),
"ΔP not symmetric under flow reversal: {dp_fwd} vs {dp_rev}"
);
}
// Zero flow → finite, ~zero ΔP (no NaN from the div-by-zero guard).
let dp_zero = hx.compute_pressure_drop(0.0, rho);
assert!(
dp_zero.is_finite() && dp_zero.abs() < 1e-6,
"ΔP(0) = {dp_zero}"
);
// No kink near the old Re = 1 transition (dh=0.003 ⇒ re = g·15).
let g1 = 1.0 / 15.0; // Re = 1
let d_g = g1 * 0.1;
let s_below =
(hx.compute_pressure_drop(g1, rho) - hx.compute_pressure_drop(g1 - d_g, rho)) / d_g;
let s_above =
(hx.compute_pressure_drop(g1 + d_g, rho) - hx.compute_pressure_drop(g1, rho)) / d_g;
let denom = s_below.abs().max(1e-12);
assert!(
(s_above - s_below).abs() / denom < 0.5,
"ΔP slope kink near Re = 1 ({s_below:.3e} vs {s_above:.3e})"
);
}
}