//! 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 { 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 }