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:
@@ -140,11 +140,34 @@ impl PipeGeometry {
|
||||
|
||||
/// Friction factor calculation methods.
|
||||
pub mod friction_factor {
|
||||
use entropyk_core::smoothing::cubic_blend;
|
||||
|
||||
/// Calculates the Haaland friction factor for turbulent flow.
|
||||
/// Reynolds number below which the flow is treated as fully laminar.
|
||||
pub const RE_LAMINAR: f64 = 2300.0;
|
||||
|
||||
/// Reynolds number above which the flow is treated as fully turbulent.
|
||||
pub const RE_TURBULENT: f64 = 4000.0;
|
||||
|
||||
/// Pure turbulent Haaland friction factor (no transition handling).
|
||||
///
|
||||
/// The Haaland equation is an approximation of the Colebrook-White equation
|
||||
/// that can be solved explicitly without iteration.
|
||||
/// 1/√f = -1.8 × log10[(ε/D / 3.7)^1.11 + 6.9/Re]
|
||||
fn haaland_turbulent(relative_roughness: f64, reynolds: f64) -> f64 {
|
||||
let re_clamped = reynolds.max(1.0);
|
||||
let term1 = (relative_roughness / 3.7).powf(1.11);
|
||||
let term2 = 6.9 / re_clamped;
|
||||
let inv_sqrt_f = -1.8 * (term1 + term2).log10();
|
||||
1.0 / (inv_sqrt_f * inv_sqrt_f)
|
||||
}
|
||||
|
||||
/// Calculates the Darcy friction factor with a C¹ laminar→turbulent blend.
|
||||
///
|
||||
/// Below [`RE_LAMINAR`] the laminar law `f = 64/Re` is used; above
|
||||
/// [`RE_TURBULENT`] the explicit Haaland turbulent correlation is used. In
|
||||
/// the transition band `[RE_LAMINAR, RE_TURBULENT]` the two are blended with
|
||||
/// a C¹ [`cubic_blend`], so the friction factor — and therefore the analytic
|
||||
/// pressure-drop Jacobian — has **no slope discontinuity** at `Re = 2300`.
|
||||
/// The previous hard `if Re < 2300` switch introduced a kink that made the
|
||||
/// Newton Jacobian jump (the PR#563-style failure mode).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -159,22 +182,22 @@ pub mod friction_factor {
|
||||
return 0.02; // Default for invalid input
|
||||
}
|
||||
|
||||
// Laminar flow: f = 64/Re
|
||||
// Do not clamp Reynolds number here to preserve linear pressure drop near zero flow.
|
||||
if reynolds < 2300.0 {
|
||||
return 64.0 / reynolds;
|
||||
// Laminar law. Reynolds is not clamped here so the pressure drop stays
|
||||
// linear near zero flow (Story 3.5 zero-flow regularization).
|
||||
let f_laminar = 64.0 / reynolds;
|
||||
if reynolds <= RE_LAMINAR {
|
||||
return f_laminar;
|
||||
}
|
||||
|
||||
// Prevent division by zero or negative values in log
|
||||
let re_clamped = reynolds.max(1.0);
|
||||
let f_turbulent = haaland_turbulent(relative_roughness, reynolds);
|
||||
if reynolds >= RE_TURBULENT {
|
||||
return f_turbulent;
|
||||
}
|
||||
|
||||
// Haaland equation (turbulent)
|
||||
// 1/√f = -1.8 × log10[(ε/D/3.7)^1.11 + 6.9/Re]
|
||||
let term1 = (relative_roughness / 3.7).powf(1.11);
|
||||
let term2 = 6.9 / re_clamped;
|
||||
let inv_sqrt_f = -1.8 * (term1 + term2).log10();
|
||||
|
||||
1.0 / (inv_sqrt_f * inv_sqrt_f)
|
||||
// Transition band: C¹ blend. At both edges the blend weight and its
|
||||
// derivative are zero, so the slope matches the pure laminar / turbulent
|
||||
// branches there — the join is C¹.
|
||||
cubic_blend(f_laminar, f_turbulent, reynolds, RE_LAMINAR, RE_TURBULENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +251,21 @@ pub struct Pipe<State> {
|
||||
circuit_id: CircuitId,
|
||||
/// Operational state
|
||||
operational_state: OperationalState,
|
||||
/// Design-point pressure drop (Pa) for the edge-coupled (P,h) solver model.
|
||||
/// The (P,h) refrigeration solver has no mass-flow unknown, so the pressure
|
||||
/// drop is imposed as a design-point value rather than computed from Darcy.
|
||||
design_dp_pa: f64,
|
||||
/// When true, the component uses the edge-coupled (P,h) solver model
|
||||
/// (`n_equations() == 2`) instead of the legacy mass-flow model.
|
||||
edge_coupled: bool,
|
||||
/// Inlet edge pressure index in the global state vector (edge-coupled mode).
|
||||
inlet_p_idx: Option<usize>,
|
||||
/// Inlet edge enthalpy index in the global state vector (edge-coupled mode).
|
||||
inlet_h_idx: Option<usize>,
|
||||
/// Outlet edge pressure index in the global state vector (edge-coupled mode).
|
||||
outlet_p_idx: Option<usize>,
|
||||
/// Outlet edge enthalpy index in the global state vector (edge-coupled mode).
|
||||
outlet_h_idx: Option<usize>,
|
||||
/// Phantom data for type state
|
||||
_state: PhantomData<State>,
|
||||
}
|
||||
@@ -276,6 +314,12 @@ impl Pipe<Disconnected> {
|
||||
calib: Calib::default(),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
design_dp_pa: 0.0,
|
||||
edge_coupled: false,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
@@ -431,12 +475,29 @@ impl Pipe<Disconnected> {
|
||||
calib: self.calib,
|
||||
circuit_id: self.circuit_id,
|
||||
operational_state: self.operational_state,
|
||||
design_dp_pa: self.design_dp_pa,
|
||||
edge_coupled: self.edge_coupled,
|
||||
inlet_p_idx: self.inlet_p_idx,
|
||||
inlet_h_idx: self.inlet_h_idx,
|
||||
outlet_p_idx: self.outlet_p_idx,
|
||||
outlet_h_idx: self.outlet_h_idx,
|
||||
_state: PhantomData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Pipe<Connected> {
|
||||
/// Enables the edge-coupled (P,h) solver model with a design-point pressure
|
||||
/// drop, so the pipe participates in the refrigeration/hydronic graph solver.
|
||||
///
|
||||
/// In this mode the pipe owns 2 equations on its outlet edge:
|
||||
/// - `r0 = P_out - (P_in - design_dp_pa)` (imposed pressure drop)
|
||||
/// - `r1 = h_out - h_in` (adiabatic pass-through)
|
||||
pub fn with_design_pressure_drop_pa(mut self, design_dp_pa: f64) -> Self {
|
||||
self.design_dp_pa = design_dp_pa.max(0.0);
|
||||
self.edge_coupled = true;
|
||||
self
|
||||
}
|
||||
/// Returns the inlet port.
|
||||
pub fn port_inlet(&self) -> &Port<Connected> {
|
||||
&self.port_inlet
|
||||
@@ -504,7 +565,7 @@ impl Pipe<Connected> {
|
||||
|
||||
// Darcy-Weisbach nominal: ΔP_nominal = f × (L/D) × (ρ × v² / 2); ΔP_eff = f_dp × ΔP_nominal
|
||||
let dp_nominal = f * ld * self.fluid_density_kg_per_m3 * velocity * velocity / 2.0;
|
||||
let dp = dp_nominal * self.calib.f_dp;
|
||||
let dp = dp_nominal * self.calib.z_dp;
|
||||
|
||||
if flow_m3_per_s < 0.0 {
|
||||
-dp
|
||||
@@ -555,11 +616,57 @@ impl Pipe<Connected> {
|
||||
}
|
||||
|
||||
impl Component for Pipe<Connected> {
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
_state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize, usize)],
|
||||
) {
|
||||
// Layout: [0] = incoming edge (upstream→pipe), [1] = outgoing edge (pipe→downstream)
|
||||
// Triple: (m_idx, p_idx, h_idx)
|
||||
if !external_edge_state_indices.is_empty() {
|
||||
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
|
||||
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
|
||||
}
|
||||
if external_edge_state_indices.len() >= 2 {
|
||||
self.outlet_p_idx = Some(external_edge_state_indices[1].1);
|
||||
self.outlet_h_idx = Some(external_edge_state_indices[1].2);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Edge-coupled (P,h) model: constrain the outlet edge.
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
if residuals.len() < 2 {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: 2,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
let dp = match self.operational_state {
|
||||
OperationalState::Bypass => 0.0,
|
||||
_ => self.calib.z_dp * self.design_dp_pa,
|
||||
};
|
||||
// r0: imposed pressure drop across the pipe
|
||||
residuals[0] = state[out_p] - (state[in_p] - dp);
|
||||
// r1: adiabatic pass-through (enthalpy conserved)
|
||||
residuals[1] = state[out_h] - state[in_h];
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ComponentError::InvalidState(
|
||||
"Pipe edge-coupled model requires live inlet/outlet edge state indices".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if residuals.len() != self.n_equations() {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: self.n_equations(),
|
||||
@@ -618,6 +725,24 @@ impl Component for Pipe<Connected> {
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Edge-coupled (P,h) model.
|
||||
if self.edge_coupled {
|
||||
if let (Some(in_p), Some(in_h), Some(out_p), Some(out_h)) = (
|
||||
self.inlet_p_idx,
|
||||
self.inlet_h_idx,
|
||||
self.outlet_p_idx,
|
||||
self.outlet_h_idx,
|
||||
) {
|
||||
// r0 = P_out - (P_in - dp) → ∂r0/∂P_out = 1, ∂r0/∂P_in = -1
|
||||
jacobian.add_entry(0, out_p, 1.0);
|
||||
jacobian.add_entry(0, in_p, -1.0);
|
||||
// r1 = h_out - h_in → ∂r1/∂h_out = 1, ∂r1/∂h_in = -1
|
||||
jacobian.add_entry(1, out_h, 1.0);
|
||||
jacobian.add_entry(1, in_h, -1.0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.operational_state {
|
||||
OperationalState::Off => {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
@@ -652,7 +777,11 @@ impl Component for Pipe<Connected> {
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
1
|
||||
if self.edge_coupled {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
@@ -713,7 +842,10 @@ impl Component for Pipe<Connected> {
|
||||
.with_param("roughnessM", self.geometry.roughness_m)
|
||||
.with_param("fluidDensityKgPerM3", self.fluid_density_kg_per_m3)
|
||||
.with_param("fluidViscosityPaS", self.fluid_viscosity_pa_s)
|
||||
.with_param("calib", serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null))
|
||||
.with_param(
|
||||
"calib",
|
||||
serde_json::to_value(&self.calib).unwrap_or(serde_json::Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_calib_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
@@ -795,6 +927,12 @@ mod tests {
|
||||
calib: Calib::default(),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
design_dp_pa: 0.0,
|
||||
edge_coupled: false,
|
||||
inlet_p_idx: None,
|
||||
inlet_h_idx: None,
|
||||
outlet_p_idx: None,
|
||||
outlet_h_idx: None,
|
||||
_state: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -849,6 +987,44 @@ mod tests {
|
||||
assert!(f_turb > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_friction_factor_transition_is_c1() {
|
||||
// The laminar→turbulent blend must be continuous in value AND slope
|
||||
// across the whole transition band [2300, 4000] — no PR#563-style kink.
|
||||
let rel = 0.001;
|
||||
let f = |re: f64| friction_factor::haaland(rel, re);
|
||||
let dfd = |re: f64| (f(re + 0.5) - f(re - 0.5)) / 1.0;
|
||||
|
||||
// Value continuity across the band, including the former jump at 2300.
|
||||
let mut prev_v = f(2299.0);
|
||||
let mut prev_s = dfd(2299.0);
|
||||
for re_i in (2300..=4000).step_by(20) {
|
||||
let re = re_i as f64;
|
||||
let v = f(re);
|
||||
let s = dfd(re);
|
||||
assert!(v > 0.0 && v.is_finite());
|
||||
assert!(
|
||||
(v - prev_v).abs() < 1e-3,
|
||||
"friction value jump near Re={}: {} vs {}",
|
||||
re,
|
||||
v,
|
||||
prev_v
|
||||
);
|
||||
assert!(
|
||||
(s - prev_s).abs() < 1e-4,
|
||||
"friction slope jump near Re={}: {} vs {}",
|
||||
re,
|
||||
s,
|
||||
prev_s
|
||||
);
|
||||
prev_v = v;
|
||||
prev_s = s;
|
||||
}
|
||||
|
||||
// Endpoints match the pure branches.
|
||||
assert_relative_eq!(f(2300.0), 64.0 / 2300.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_friction_factor_zero_flow_regularization() {
|
||||
// Re = 0 or very small must not cause division by zero (Story 3.5)
|
||||
@@ -946,7 +1122,7 @@ mod tests {
|
||||
let flow = 0.005;
|
||||
let dp_default = pipe.pressure_drop(flow);
|
||||
pipe.set_calib(Calib {
|
||||
f_dp: 1.1,
|
||||
z_dp: 1.1,
|
||||
..Calib::default()
|
||||
});
|
||||
let dp_calib = pipe.pressure_drop(flow);
|
||||
|
||||
Reference in New Issue
Block a user