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>
414 lines
14 KiB
Rust
414 lines
14 KiB
Rust
//! 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",
|
|
®ion_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",
|
|
®ion_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",
|
|
®ion_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
|
|
}
|