Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled

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>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -0,0 +1,413 @@
//! Domain-grid Jacobian-health CI gate (Story 0.5 / Epic 0).
//!
//! Epic-0 P0 surfaces must be FD-healthy. Deferred debt (NFR9 exchanger FD,
//! python stubs, screw incomplete J, sat_domain P1, bphx P2) is **not** probed
//! here — see [`EPIC0_ALLOW_LIST`] (empty) and `docs/audits/jacobian-health-report.md`.
//!
//! Regenerate the committed report after grid changes:
//! `cargo test -p entropyk-components --test jacobian_health_sweep -- --nocapture`
//! then manually sync `docs/audits/jacobian-health-report.md`.
#![allow(clippy::const_is_empty)]
use std::sync::Arc;
use entropyk_components::heat_exchanger::two_phase_dp::{
friedel_multiplier, homogeneous_density, msh_gradient, FriedelInput,
};
use entropyk_components::heat_exchanger::Condenser;
use entropyk_components::jacobian_fd::{assert_jacobian_healthy, AllowListEntry, JacobianFdConfig};
use entropyk_components::valve_flow::{
valve_mass_flow, valve_mass_flow_dp_down, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel,
};
use entropyk_components::{Component, IsenthalpicExpansionValve};
use entropyk_core::CalibIndices;
use entropyk_fluids::TestBackend;
/// Epic-0 P0 allow-list: intentionally empty (must stay clean).
/// Deferred debt is documented in the health report, not suppressed here.
const EPIC0_ALLOW_LIST: &[AllowListEntry] = &[];
/// Narrow FD step for C¹ smooth_clamp neighborhoods.
const EDGE_CFG: JacobianFdConfig = JacobianFdConfig {
rel_epsilon: 1e-6,
h_floor: 1e-6,
rel_tol: 1e-4,
analytic_atol: 1e-12,
fd_informative: 1e-8,
};
const DEFAULT_CFG: JacobianFdConfig = JacobianFdConfig {
rel_epsilon: 1e-6,
h_floor: 1e-3,
rel_tol: 1e-4,
analytic_atol: 1e-12,
fd_informative: 1e-8,
};
// ── Region grids (Story 0.5) ────────────────────────────────────────────────
const EXV_OPENINGS: [f64; 3] = [0.0, 0.5, 1.0];
/// (p_in, p_out) — positive ΔP and mild ΔP≤0.
const EXV_DP_REGIMES: [(&str, f64, f64); 2] =
[("dp_positive", 1.2e6, 3.5e5), ("dp_le_0", 4.0e5, 4.005e5)];
const CONDENSER_FAN_PHI: [f64; 3] = [0.005, 0.75, 1.495];
const CONDENSER_FLOOD_LAMBDA: [f64; 3] = [0.005, 0.5, 0.975];
const QUALITY_INTERIOR: f64 = 0.5;
const QUALITY_NEAR_BAND: f64 = 0.005;
const QUALITY_EXTERIOR: f64 = -0.2;
const MSH_QUALITY_GRID: [f64; 7] = [0.5, 0.8, 0.90, 0.95, 0.99, 0.999, 1.0 - 1e-9];
// ── Valve flow helpers ──────────────────────────────────────────────────────
fn valve_input(model_hint: &str, dp_positive: bool, opening: f64) -> ValveFlowInput {
let (p_up, p_dn) = if dp_positive {
(1.5e6, 0.4e6)
} else {
(4.0e5, 4.005e5)
};
let _ = model_hint;
ValveFlowInput {
density_kg_m3: 1200.0,
p_upstream_pa: p_up,
p_downstream_pa: p_dn,
opening,
p_bulb_pa: 0.0,
}
}
#[test]
fn valve_flow_domain_grid_is_healthy() {
let models: [(&str, ValveFlowModel); 2] = [
(
"isenthalpic_orifice",
ValveFlowModel::IsenthalpicOrifice { beta_m2: 1.0e-6 },
),
(
"exv_cda",
ValveFlowModel::ExvCdA {
cd: 0.65,
area_max_m2: 5e-6,
},
),
];
for (model_id, model) in &models {
for dp_positive in [true, false] {
for opening in [0.0, 0.5, 1.0] {
let input = valve_input(model_id, dp_positive, opening);
let m = valve_mass_flow(model, &input).expect("valve mass flow");
assert!(
m.is_finite() && m >= 0.0,
"{model_id} opening={opening} dp+:{dp_positive}: ṁ={m}"
);
let d_up = valve_mass_flow_dp_up(model, &input).expect("dp_up");
let d_dn = valve_mass_flow_dp_down(model, &input).expect("dp_down");
assert!(
d_up.is_finite() && d_dn.is_finite(),
"{model_id}: non-finite derivatives"
);
// Opening ≈ 0 ⇒ ṁ≈0: pressure derivatives may be ~0 (not a ΔP killer).
// Interior / open valve: informative ∂ṁ/∂P_* required.
let opening_live = opening >= 0.05;
if opening_live {
if !dp_positive {
assert!(
d_up > 0.0,
"{model_id} ΔP≤0 opening={opening}: expected phantom dmdp>0, got {d_up}"
);
} else {
assert!(
d_up > 0.0,
"{model_id} ΔP>0 opening={opening}: expected dmdp>0, got {d_up}"
);
let eps = 1.0;
let mut up = input;
up.p_downstream_pa += eps;
let mut dn = input;
dn.p_downstream_pa -= eps;
let d_fd = (valve_mass_flow(model, &up).unwrap()
- valve_mass_flow(model, &dn).unwrap())
/ (2.0 * eps);
let scale = d_dn.abs().max(d_fd.abs()).max(1e-12);
assert!(
(d_dn - d_fd).abs() / scale < 1e-4,
"{model_id} opening={opening}: dp_down analytic {d_dn} vs FD {d_fd}"
);
}
}
}
}
}
}
// ── Two-phase quality helpers ───────────────────────────────────────────────
#[test]
fn two_phase_quality_domain_grid_is_healthy() {
let rho_l = 1000.0;
let rho_g = 50.0;
let base = FriedelInput {
quality: QUALITY_INTERIOR,
mass_flux: 200.0,
diameter: 0.01,
rho_liquid: rho_l,
rho_vapor: rho_g,
mu_liquid: 2e-4,
mu_vapor: 1e-5,
sigma: 0.01,
};
// Interior control.
let soft_int = homogeneous_density(QUALITY_INTERIOR, rho_l, rho_g);
let hard_int = 1.0 / (QUALITY_INTERIOR / rho_g + (1.0 - QUALITY_INTERIOR) / rho_l);
assert!(
(soft_int - hard_int).abs() / hard_int < 1e-6,
"interior quality must match hard formula; soft={soft_int} hard={hard_int}"
);
let m_int = friedel_multiplier(&base);
assert!(m_int.is_finite() && m_int >= 1.0);
// Near C¹ band: soft ≠ hard identity.
let soft_band = homogeneous_density(QUALITY_NEAR_BAND, rho_l, rho_g);
let hard_band = 1.0 / (QUALITY_NEAR_BAND / rho_g + (1.0 - QUALITY_NEAR_BAND) / rho_l);
assert!(
(soft_band - hard_band).abs() / hard_band > 1e-6,
"near-band C¹ ramp must differ from hard clamp"
);
let m_band = friedel_multiplier(&FriedelInput {
quality: QUALITY_NEAR_BAND,
..base
});
assert!(m_band.is_finite() && m_band > 0.0);
// Exterior saturates to bound (smooth_clamp hard exterior).
assert_eq!(
homogeneous_density(QUALITY_EXTERIOR, rho_l, rho_g),
homogeneous_density(0.0, rho_l, rho_g)
);
let m_ext = friedel_multiplier(&FriedelInput {
quality: QUALITY_EXTERIOR,
..base
});
let m_0 = friedel_multiplier(&FriedelInput {
quality: 0.0,
..base
});
assert!(
(m_ext - m_0).abs() < 1e-12 * (1.0 + m_0.abs()),
"exterior quality must saturate like x=0"
);
// Upper near-band + exterior > 1.
let soft_hi = homogeneous_density(0.995, rho_l, rho_g);
assert!(soft_hi.is_finite() && soft_hi > 0.0);
assert_eq!(
homogeneous_density(1.2, rho_l, rho_g),
homogeneous_density(1.0, rho_l, rho_g)
);
}
// ── MSH / dome edge ─────────────────────────────────────────────────────────
#[test]
fn msh_dome_edge_domain_grid_is_healthy() {
let base = FriedelInput {
quality: 0.5,
mass_flux: 200.0,
diameter: 0.01,
rho_liquid: 1000.0,
rho_vapor: 50.0,
mu_liquid: 2e-4,
mu_vapor: 1e-5,
sigma: 0.01,
};
let h = 1e-6;
for &x in &MSH_QUALITY_GRID {
let g = msh_gradient(&FriedelInput { quality: x, ..base });
assert!(g.is_finite() && g > 0.0, "MSH g({x})={g}");
let mut up = base;
up.quality = (x + h).min(1.0);
let mut dn = base;
dn.quality = (x - h).max(0.0);
let denom = up.quality - dn.quality;
if denom > 0.0 {
let d_fd = (msh_gradient(&up) - msh_gradient(&dn)) / denom;
assert!(
d_fd.is_finite(),
"MSH ∂g/∂x must be finite at x={x}, got {d_fd}"
);
// Near x→1 the Hermite blend forces slope → 0; elsewhere bounded.
assert!(d_fd.abs() < 1e6, "MSH ∂g/∂x blew up at x={x}: {d_fd}");
}
}
}
// ── EXV orifice actuator ────────────────────────────────────────────────────
fn orifice_exv() -> IsenthalpicExpansionValve {
let mut exv = IsenthalpicExpansionValve::new(275.15)
.with_refrigerant("R134a")
.with_orifice(3.0e-6)
.with_fluid_backend(Arc::new(TestBackend::new()));
exv.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
exv.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
exv
}
fn orifice_state(opening: f64, p_in: f64, p_out: f64) -> Vec<f64> {
vec![
0.2, // m_in
p_in, // p_in
2.0e5, // h_in
0.2, // m_out
p_out, // p_out
2.0e5, // h_out
opening,
]
}
#[test]
fn exv_orifice_domain_grid_is_jacobian_healthy() {
let exv = orifice_exv();
for opening in EXV_OPENINGS {
for (region, p_in, p_out) in EXV_DP_REGIMES {
let state = orifice_state(opening, p_in, p_out);
let region_id = format!("{region}_opening_{opening}");
// Opening edges need narrow h; interior + ΔP≤0 can use default floor
// but EDGE_CFG is safe everywhere for this actuator scale.
assert_jacobian_healthy(
&exv,
&state,
EDGE_CFG,
"exv_orifice",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
}
/// Thin solve smoke: orifice residual at ΔP≤0 has informative pressure coupling
/// so a single Newton pressure step can restore ΔP>0 (no hard zero-gradient stall).
#[test]
fn exv_nonpositive_dp_newton_step_restores_positive_dp() {
let exv = orifice_exv();
let mut state = orifice_state(0.5, 4.0e5, 4.005e5);
let mut r = vec![0.0; 3];
exv.compute_residuals(&state, &mut r).unwrap();
let mut jb = entropyk_components::JacobianBuilder::new();
exv.jacobian_entries(&state, &mut jb).unwrap();
let mut j_p_in = 0.0;
let mut j_p_out = 0.0;
for &(row, col, val) in jb.entries() {
if row == 2 && col == 1 {
j_p_in += val;
}
if row == 2 && col == 4 {
j_p_out += val;
}
}
assert!(
j_p_in.abs() > 0.0 && j_p_out.abs() > 0.0,
"pressure couplings must be live at ΔP≤0: ∂r/∂P_in={j_p_in}, ∂r/∂P_out={j_p_out}"
);
let step = 0.1 * r[2];
if j_p_in.abs() > 1e-30 {
state[1] -= step / j_p_in;
}
if j_p_out.abs() > 1e-30 {
state[4] -= step / j_p_out;
}
let dp_after = state[1] - state[4];
assert!(
dp_after > -500.0,
"Newton pressure step should not deepen reverse ΔP; got {dp_after}"
);
}
// ── Condenser flooded / fan actuators ───────────────────────────────────────
#[test]
fn condenser_flood_domain_grid_is_jacobian_healthy() {
let backend = Arc::new(TestBackend::new());
let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
let p_cond = 1_200_000.0_f64;
let mut cond = Condenser::new(10_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_stream(305.0, 3000.0)
.with_emergent_pressure(0.0)
.with_flooded_head_pressure(320.0);
cond.set_system_context(0, &edges);
cond.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
for lambda in CONDENSER_FLOOD_LAMBDA {
let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, lambda];
let region_id = format!("flood_lambda_{lambda}");
assert_jacobian_healthy(
&cond,
&state,
EDGE_CFG,
"condenser_flood",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
#[test]
fn condenser_fan_domain_grid_is_jacobian_healthy() {
let backend = Arc::new(TestBackend::new());
let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
let p_cond = 1_200_000.0_f64;
let mut cond = Condenser::new(10_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_stream(305.0, 3000.0)
.with_emergent_pressure(0.0)
.with_fan_head_pressure(320.0);
cond.set_system_context(0, &edges);
cond.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
for phi in CONDENSER_FAN_PHI {
let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, phi];
let region_id = format!("fan_phi_{phi}");
assert_jacobian_healthy(
&cond,
&state,
EDGE_CFG,
"condenser_fan",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
#[test]
fn epic0_allow_list_is_empty_for_p0_surfaces() {
// Guard: Story 0.5 forbids allow-listing valve/EXV/HX P0 regressions.
assert!(
EPIC0_ALLOW_LIST.is_empty(),
"Epic-0 P0 allow-list must stay empty; deferred debt is documented, not suppressed"
);
let _ = DEFAULT_CFG; // keep default config linked for report documentation
}