Files
Entropyk/crates/solver/tests/msh_tube_dp_robustness.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

410 lines
15 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.
//! System-level regression test for the MSH tube-ΔP + fixed-opening EXV
//! solver-robustness fix (Epic-0 follow-up).
//!
//! Builds the exact user system that exhibited the Newton stall (4-component
//! emergent-pressure R134a chiller, `dp_model=msh` on both heat exchangers,
//! `fix_opening=true, opening=0.9`) at the exact CLI staged seed, and guards:
//!
//! 1. the cold-start residual signature (evaporator tube-ΔP row dominant),
//! 2. the **momentum-row Jacobians** (condenser + evaporator tube ΔP) against
//! central finite differences — the NFR9 guard for the exact analytic
//! `tube_dp` composition wired into both heat exchangers,
//! 3. the **totality / C¹** of the tube-ΔP residual when Newton iterates leave
//! the saturation domain (no silent model switch, no error, smooth values).
//!
//! Run: cargo test -p entropyk-solver --features coolprop --test msh_tube_dp_robustness
#![cfg(feature = "coolprop")]
use std::sync::Arc;
use entropyk_components::heat_exchanger::two_phase_dp::{
TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use entropyk_components::{BrineSink, BrineSource, Condenser, Evaporator};
use entropyk_components::{ConnectedPort, FluidId as ComponentFluidId};
use entropyk_components::{IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder, Port};
use entropyk_core::{Concentration, Enthalpy, Pressure, Temperature};
use entropyk_fluids::{CoolPropBackend, FluidBackend, FluidId, FluidState, Property};
use entropyk_solver::scaling::{equilibrate, unscale_dx};
use entropyk_solver::system::System;
fn water_h(backend: &Arc<dyn FluidBackend>, t_c: f64) -> f64 {
backend
.property(
FluidId::new("Water"),
Property::Enthalpy,
FluidState::from_pt(Pressure::from_bar(2.0), Temperature::from_celsius(t_c)),
)
.expect("water h(P,T)")
}
fn water_port() -> ConnectedPort {
let fluid = ComponentFluidId::new("Water");
let a = Port::new(
fluid.clone(),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(100_000.0),
);
let b = Port::new(
fluid,
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(100_000.0),
);
a.connect(b).expect("port connect").0
}
/// Builds the exact hang system (msh + fixed-opening EXV at `opening`) and
/// returns it with the exact CLI staged seed and per-index variable names.
fn build_hang_system_with_opening(opening: f64) -> (System, Vec<f64>, Vec<String>) {
let backend: Arc<dyn FluidBackend> = Arc::new(CoolPropBackend::new());
let fluid = "R134a";
let geom = TubeChannelGeometry {
length_m: 6.0,
diameter_m: 0.0095,
n_parallel: 2.0,
};
// comp: emergent metered-flow (energy-only) — EXV fixed orifice meters ṁ.
let comp = Box::new(
IsentropicCompressor::new(0.70, 318.15, 278.15, 5.0)
.with_refrigerant(fluid)
.with_fluid_backend(backend.clone())
.with_emergent_metered_flow(),
);
// cond: UA=1500, msh tube ΔP, water 4-port, emergent with 5 K subcooling.
let mut cond = Condenser::new(1500.0)
.with_refrigerant(fluid)
.with_fluid_backend(backend.clone())
.with_emergent_pressure(5.0);
cond.set_secondary_fluid("Water");
cond.set_tube_pressure_drop(TwoPhaseDpCorrelation::MullerSteinhagenHeck1986, geom);
cond.set_secondary_pressure_drop_coeff(5000.0);
let cond = Box::new(cond);
// exv: emergent + fixed orifice kv=2e-6.
let exv = Box::new(
IsenthalpicExpansionValve::new(278.15)
.with_refrigerant(fluid)
.with_fluid_backend(backend.clone())
.with_emergent_pressure()
.with_orifice_fixed(2e-6, opening),
);
// evap: UA=2500, msh tube ΔP, water 4-port, emergent (5 K superheat).
let mut evap = Evaporator::new(2500.0)
.with_refrigerant(fluid)
.with_fluid_backend(backend.clone())
.with_emergent_pressure();
evap.set_secondary_fluid("Water");
evap.set_tube_pressure_drop(TwoPhaseDpCorrelation::MullerSteinhagenHeck1986, geom);
evap.set_secondary_pressure_drop_coeff(5000.0);
let evap = Box::new(evap);
// Water boundaries.
let cwin = Box::new(
BrineSource::new(
"Water",
Pressure::from_bar(2.0),
Temperature::from_celsius(30.0),
Concentration::from_percent(0.0),
backend.clone(),
water_port(),
)
.expect("BrineSource cond")
.with_imposed_mass_flow(0.3583)
.expect("imposed m"),
);
let cwout = Box::new(
BrineSink::new(
"Water",
Pressure::from_bar(2.0),
None,
None,
backend.clone(),
water_port(),
)
.expect("BrineSink cond"),
);
let ewin = Box::new(
BrineSource::new(
"Water",
Pressure::from_bar(2.0),
Temperature::from_celsius(12.0),
Concentration::from_percent(0.0),
backend.clone(),
water_port(),
)
.expect("BrineSource evap")
.with_imposed_mass_flow(0.4778)
.expect("imposed m"),
);
let ewout = Box::new(
BrineSink::new(
"Water",
Pressure::from_bar(2.0),
None,
None,
backend.clone(),
water_port(),
)
.expect("BrineSink evap"),
);
let mut system = System::new();
let n_comp = system.add_component(comp);
let n_cond = system.add_component(cond);
let n_exv = system.add_component(exv);
let n_evap = system.add_component(evap);
let n_cwin = system.add_component(cwin);
let n_cwout = system.add_component(cwout);
let n_ewin = system.add_component(ewin);
let n_ewout = system.add_component(ewout);
// Refrigerant loop (ports: inlet=0, outlet=1; HX secondary 2/3).
system.add_edge_with_ports(n_comp, 1, n_cond, 0).unwrap(); // E0
system.add_edge_with_ports(n_cond, 1, n_exv, 0).unwrap(); // E1
system.add_edge_with_ports(n_exv, 1, n_evap, 0).unwrap(); // E2
system.add_edge_with_ports(n_evap, 1, n_comp, 0).unwrap(); // E3
// Water loops.
system.add_edge_with_ports(n_cwin, 1, n_cond, 2).unwrap(); // W0
system.add_edge_with_ports(n_cond, 3, n_cwout, 0).unwrap(); // W1
system.add_edge_with_ports(n_ewin, 1, n_evap, 2).unwrap(); // W2
system.add_edge_with_ports(n_evap, 3, n_ewout, 0).unwrap(); // W3
system.finalize().unwrap();
let n_state = system.full_state_vector_len();
assert_eq!(n_state, 19, "hang system must have 19 unknowns");
// ── Exact CLI staged seed (validated against the production run: the
// cold-start residual breakdown matches row-by-row) ─────────────────────
let h_w30 = water_h(&backend, 30.0);
let h_w12 = water_h(&backend, 12.0);
let h_w20 = water_h(&backend, 20.0);
let seed_refrig = [
(1.159_924e6, 4.465_191e5), // E0 comp→cond
(1.159_924e6, 2.589_429e5), // E1 cond→exv
(3.496_586e5, 2.639_429e5), // E2 exv→evap
(3.496_586e5, 4.060_707e5), // E3 evap→comp
];
// Water loop ṁ slots are seeded at the generic 0.05 kg/s default (the
// sink boundary seed overwrites the source's imposed flow).
let seed_water = [
(0.05, h_w30), // W0 source→cond
(0.05, h_w20), // W1 cond→sink
(0.05, h_w12), // W2 source→evap
(0.05, h_w20), // W3 evap→sink
];
let mut state = vec![0.0; n_state];
let mut names: Vec<Option<String>> = vec![None; n_state];
let edge_names = ["E0", "E1", "E2", "E3", "W0", "W1", "W2", "W3"];
for (i, e) in system.edge_indices().enumerate() {
let (mi, pi, hi) = system.edge_state_indices_full(e);
if i < 4 {
state[mi] = 0.05;
state[pi] = seed_refrig[i].0;
state[hi] = seed_refrig[i].1;
} else {
let (mw, hw) = seed_water[i - 4];
state[mi] = mw;
state[pi] = 2.0e5;
state[hi] = hw;
}
let en = edge_names[i];
for (idx, tag) in [(mi, "m"), (pi, "P"), (hi, "h")] {
if names[idx].is_none() {
names[idx] = Some(format!("{tag}({en})"));
}
}
}
let names: Vec<String> = names
.into_iter()
.enumerate()
.map(|(i, n)| n.unwrap_or_else(|| format!("x[{i}]")))
.collect();
(system, state, names)
}
/// Row indices: node order comp(0) | cond(1..5) | exv(6..7) | evap(8..12) |
/// boundaries(13..18). Row 1 = condenser refrigerant momentum,
/// row 8 = evaporator refrigerant momentum.
const COND_MOMENTUM_ROW: usize = 1;
const EVAP_MOMENTUM_ROW: usize = 8;
const N_EQ: usize = 19;
/// The exact cold-start signature of the production stall (residual breakdown
/// matches the CLI run row-by-row): the evaporator tube-ΔP momentum row
/// dominates the cold residual.
#[test]
fn cold_start_residual_signature_matches_production() {
let (system, state, _names) = build_hang_system_with_opening(0.9);
let mut r = vec![0.0; N_EQ];
system.compute_residuals(&state, &mut r).unwrap();
let norm: f64 = r.iter().map(|v| v * v).sum::<f64>().sqrt();
assert!(
(norm - 44457.554).abs() < 0.01,
"cold-start residual norm must match the production signature, got {norm}"
);
// Evaporator momentum row: the full tube ΔP is unbalanced at the seed
// (uniform low-side pressure), ≈ 42 kPa.
assert!(
(r[EVAP_MOMENTUM_ROW] - 41_991.24).abs() < 1.0,
"evap momentum residual: {}",
r[EVAP_MOMENTUM_ROW]
);
// Condenser momentum row ≈ 7.7 kPa.
assert!(
(r[COND_MOMENTUM_ROW] - 7_673.32).abs() < 1.0,
"cond momentum residual: {}",
r[COND_MOMENTUM_ROW]
);
}
/// NFR9 guard: the momentum-row Jacobian of the tube ΔP (now fully analytic
/// through `heat_exchanger::tube_dp`) must agree with central finite
/// differences of the residual at the cold seed, on every column.
#[test]
fn tube_dp_momentum_jacobian_matches_fd_at_cold_seed() {
let (system, state, names) = build_hang_system_with_opening(0.9);
let n_state = state.len();
let mut jb = JacobianBuilder::new();
system.assemble_jacobian(&state, &mut jb).unwrap();
let mut analytic = vec![vec![0.0_f64; n_state]; N_EQ];
for &(row, col, v) in jb.entries() {
analytic[row][col] += v;
}
for row in [COND_MOMENTUM_ROW, EVAP_MOMENTUM_ROW] {
for col in 0..n_state {
let eps = (state[col].abs() * 1e-6).max(1e-7);
let (mut sp, mut sm) = (state.clone(), state.clone());
sp[col] += eps;
sm[col] -= eps;
let (mut rp, mut rm) = (vec![0.0; N_EQ], vec![0.0; N_EQ]);
system.compute_residuals(&sp, &mut rp).unwrap();
system.compute_residuals(&sm, &mut rm).unwrap();
let fd = (rp[row] - rm[row]) / (2.0 * eps);
let a = analytic[row][col];
if a == 0.0 && fd == 0.0 {
continue;
}
let tol = (1e-4 * fd.abs().max(a.abs())).max(1e-9);
assert!(
(a - fd).abs() <= tol,
"momentum J[{row}][{}]: analytic={a} vs fd={fd}",
names[col]
);
}
}
}
/// Totality + C¹ guard: pushing the refrigerant pressures far outside the
/// saturation domain (as Newton iterates do when a step overshoots) must keep
/// the residual defined — no error, no panic, no silent ΔP-model switch — and
/// the evaporator momentum row must vary smoothly across the domain bound.
#[test]
fn tube_dp_residual_is_total_and_smooth_outside_sat_domain() {
let (system, state, _names) = build_hang_system_with_opening(0.9);
// Find the E2 (exv→evap) pressure index.
let e2 = system.edge_indices().nth(2).unwrap();
let (_, p_e2, _) = system.edge_state_indices_full(e2);
let r8_at = |p: f64| -> f64 {
let mut s = state.clone();
s[p_e2] = p;
let mut r = vec![0.0; N_EQ];
system
.compute_residuals(&s, &mut r)
.expect("residual must stay defined outside the saturation domain");
r[EVAP_MOMENTUM_ROW]
};
// Deep outside the R134a saturation domain in both directions: the tube
// ΔP saturates to the bound's value (constant continuation), and the
// residual stays finite and equal across the far exterior.
let p_nominal = state[p_e2];
let r_nominal = r8_at(p_nominal);
assert!(r_nominal.is_finite());
for p_extreme in [1.0, 10.0, 1.0e9, 1.0e12] {
let r = r8_at(p_extreme);
assert!(r.is_finite(), "residual not finite at P={p_extreme}");
}
// Constant continuation far outside: two far-exterior points give the
// same clamped ΔP (isolate it from the row: P_out P_in + ΔP_sat).
let d1 = r8_at(1.0e9) + 1.0e9;
let d2 = r8_at(1.0e10) + 1.0e10;
assert!(
(d1 - d2).abs() < 1e-6 * d1.abs().max(1.0),
"clamped ΔP must be constant far outside the domain: {d1} vs {d2}"
);
// C¹ across the upper domain bound: FD slope just inside vs just outside
// the saturation-domain top must not jump (smooth clamp, Story 0.2).
let (p_min, p_max) = {
let backend: Arc<dyn FluidBackend> = Arc::new(CoolPropBackend::new());
entropyk_components::heat_exchanger::sat_domain::saturation_pressure_domain(
&backend, "R134a",
)
.expect("R134a domain")
};
let _ = p_min;
let h = p_max * 1e-5;
let slope_in = (r8_at(p_max - h) - r8_at(p_max - 2.0 * h)) / h;
let slope_out = (r8_at(p_max + 2.0 * h) - r8_at(p_max + h)) / h;
let denom = slope_in.abs().max(1.0); // the explicit P_in term dominates
assert!(
(slope_in - slope_out).abs() / denom < 0.2,
"C¹ slope across domain bound: in={slope_in} out={slope_out}"
);
}
/// Newton-step structure at the cold seed (documentation of the stall
/// mechanism): the first full Newton step must be finite and the scaled
/// Jacobian must be non-singular — the production stall is a *damping/seed
/// distance* problem, not a singular or misassembled Jacobian.
#[test]
fn cold_seed_jacobian_is_nonsingular_and_step_finite() {
let (system, state, _names) = build_hang_system_with_opening(0.9);
let n_state = state.len();
let mut r = vec![0.0; N_EQ];
system.compute_residuals(&state, &mut r).unwrap();
let mut jb = JacobianBuilder::new();
system.assemble_jacobian(&state, &mut jb).unwrap();
let mut jm = nalgebra::DMatrix::<f64>::zeros(N_EQ, n_state);
for &(row, col, v) in jb.entries() {
jm[(row, col)] += v;
}
let (d_r, d_c) = equilibrate(&jm);
let mut js = jm.clone();
for i in 0..N_EQ {
for j in 0..n_state {
js[(i, j)] *= d_r[i] * d_c[j];
}
}
let b: nalgebra::DVector<f64> =
nalgebra::DVector::from_iterator(N_EQ, (0..N_EQ).map(|i| -d_r[i] * r[i]));
let y = js
.clone()
.lu()
.solve(&b)
.expect("scaled Jacobian must solve");
let delta = unscale_dx(y.as_slice(), &d_c);
assert!(
delta.iter().all(|v| v.is_finite()),
"Newton step must be finite"
);
// The step is huge (stiff emergent-pressure mode) but the scaled Jacobian
// is invertible — σ_min > 0.
let sigma_min = js
.svd(false, false)
.singular_values
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
assert!(sigma_min > 0.0, "scaled Jacobian must be non-singular");
}