Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
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:
413
crates/components/tests/jacobian_health_sweep.rs
Normal file
413
crates/components/tests/jacobian_health_sweep.rs
Normal 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",
|
||||
®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
|
||||
}
|
||||
107
crates/components/tests/test_exv_off.rs
Normal file
107
crates/components/tests/test_exv_off.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Quick test: EXV OperationalState Off path.
|
||||
//! Validates that the EXV with `Off` produces ṁ = 0 in its residual and keeps
|
||||
//! the Jacobian non-singular. Run with:
|
||||
//! cargo test --release -p entropyk-components --test test_exv_off -- --nocapture
|
||||
use entropyk_components::isenthalpic_expansion_valve::IsenthalpicExpansionValve;
|
||||
use entropyk_components::state_machine::{OperationalState, StateManageable};
|
||||
use entropyk_components::{Component, JacobianBuilder};
|
||||
|
||||
fn make_exv() -> IsenthalpicExpansionValve {
|
||||
let mut exv = IsenthalpicExpansionValve::new(278.15)
|
||||
.with_refrigerant("R134a")
|
||||
.with_emergent_pressure()
|
||||
.with_orifice_fixed(2.0e-6, 1.0);
|
||||
// Layout: [m_inlet=0, m_outlet=1, p_inlet=2, h_inlet=3, p_outlet=4, h_outlet=5]
|
||||
exv.set_system_context(0, &[(0, 2, 3), (1, 4, 5)]);
|
||||
exv
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exv_off_produces_zero_flow_residual() {
|
||||
let mut exv = make_exv();
|
||||
|
||||
// Turn the valve OFF.
|
||||
exv.set_operational_state_unchecked(OperationalState::Off);
|
||||
assert!(exv.state().is_off());
|
||||
|
||||
// State where m_outlet = 0.05 (non-zero flow) — the Off residual must drive it to 0.
|
||||
let state: Vec<f64> = vec![0.05, 0.05, 1.5e6, 250_000.0, 0.4e6, 250_000.0];
|
||||
let mut r = vec![0.0_f64; exv.n_equations()];
|
||||
exv.compute_residuals(&state, &mut r).expect("residuals");
|
||||
|
||||
// The orifice equation is the last one (after isenthalpic + mass conservation).
|
||||
// r_orifice should equal m_outlet - 0 = 0.05 (non-zero → Newton will push it to 0).
|
||||
let orifice_residual = *r.last().unwrap();
|
||||
assert!(
|
||||
(orifice_residual - 0.05).abs() < 1e-12,
|
||||
"Off residual should be ṁ = 0.05 (forcing flow to 0), got {}",
|
||||
orifice_residual
|
||||
);
|
||||
|
||||
// Jacobian: ∂r_orifice/∂m_outlet = 1 (keeps Newton coupled on the mass flow).
|
||||
let mut jb = JacobianBuilder::new();
|
||||
exv.jacobian_entries(&state, &mut jb).expect("jacobian");
|
||||
let entries = jb.entries();
|
||||
let m_out_idx = 1;
|
||||
let orifice_row = exv.n_equations() - 1;
|
||||
let dm_out: f64 = entries
|
||||
.iter()
|
||||
.filter(|(row, col, _)| *row == orifice_row && *col == m_out_idx)
|
||||
.map(|(_, _, v)| *v)
|
||||
.sum();
|
||||
assert!(
|
||||
(dm_out - 1.0).abs() < 1e-12,
|
||||
"Off Jacobian ∂r/∂m_outlet should be 1.0, got {}",
|
||||
dm_out
|
||||
);
|
||||
|
||||
println!(
|
||||
"EXV Off: residual={:.4e} (target 0.05), ∂r/∂m_out={:.3} (target 1.0) — OK",
|
||||
orifice_residual, dm_out
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exv_on_still_uses_orifice_equation() {
|
||||
let mut exv = make_exv();
|
||||
// Default state is On.
|
||||
assert!(exv.state().is_on());
|
||||
|
||||
// Use a backend so ρ_in can be evaluated.
|
||||
let backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend> =
|
||||
std::sync::Arc::new(entropyk_fluids::TestBackend::new());
|
||||
exv.set_fluid_backend_from_builder(backend);
|
||||
|
||||
// State with a physical ΔP across the valve: P_in = 13 bar, P_out = 3.5 bar.
|
||||
let state: Vec<f64> = vec![0.05, 0.05, 1.3e6, 250_000.0, 0.35e6, 250_000.0];
|
||||
let mut r = vec![0.0_f64; exv.n_equations()];
|
||||
exv.compute_residuals(&state, &mut r).expect("residuals");
|
||||
|
||||
let orifice_residual = *r.last().unwrap();
|
||||
// Orifice equation is active: residual is non-trivial (not just ṁ).
|
||||
assert!(
|
||||
orifice_residual.abs() > 1e-6,
|
||||
"On residual should be non-trivial (orifice equation active), got {}",
|
||||
orifice_residual
|
||||
);
|
||||
println!(
|
||||
"EXV On: residual={:.4e} (orifice equation active) — OK",
|
||||
orifice_residual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exv_state_transitions_are_validated() {
|
||||
let mut exv = IsenthalpicExpansionValve::new(278.15);
|
||||
assert!(exv.state().is_on());
|
||||
|
||||
// On → Off is a legal transition.
|
||||
assert!(exv.can_transition_to(OperationalState::Off));
|
||||
exv.set_state(OperationalState::Off).expect("On → Off");
|
||||
assert!(exv.state().is_off());
|
||||
|
||||
// Off → On is legal.
|
||||
assert!(exv.can_transition_to(OperationalState::On));
|
||||
exv.set_state(OperationalState::On).expect("Off → On");
|
||||
assert!(exv.state().is_on());
|
||||
}
|
||||
66
crates/components/tests/test_msh_smoothness.rs
Normal file
66
crates/components/tests/test_msh_smoothness.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! Quick empirical check of MSH smoothness near x=1.
|
||||
//!
|
||||
//! Run with: cargo test --release -p entropyk-components --lib -- test_msh_smoothness_empirical real_check --nocapture
|
||||
use entropyk_components::heat_exchanger::two_phase_dp::{
|
||||
friedel_gradient, msh_gradient, FriedelInput,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn real_check() {
|
||||
// R134a-like at 5 °C, mass flux typical of DX evaporator at opening=1.
|
||||
let base = FriedelInput {
|
||||
mass_flux: 300.0,
|
||||
diameter: 0.0095,
|
||||
quality: 0.5,
|
||||
rho_liquid: 1290.0,
|
||||
rho_vapor: 17.4,
|
||||
mu_liquid: 250e-6,
|
||||
mu_vapor: 11e-6,
|
||||
sigma: 0.011,
|
||||
};
|
||||
|
||||
let qualities = [
|
||||
0.10, 0.50, 0.80, 0.90, 0.95, 0.97, 0.99, 0.995, 0.999, 0.9999, 1.0,
|
||||
];
|
||||
|
||||
println!(
|
||||
"\n{:<10} {:<14} {:<14} {:<14}",
|
||||
"x", "MSH", "dMSH/dx", "Friedel"
|
||||
);
|
||||
let mut prev_msh: Option<f64> = None;
|
||||
let mut prev_x: Option<f64> = None;
|
||||
for &x in &qualities {
|
||||
let inp = FriedelInput { quality: x, ..base };
|
||||
let msh = msh_gradient(&inp);
|
||||
let fri = friedel_gradient(&inp);
|
||||
let dmsdh = match (prev_msh, prev_x) {
|
||||
(Some(pm), Some(px)) => (msh - pm) / (x - px),
|
||||
_ => f64::NAN,
|
||||
};
|
||||
println!("{:<10.5} {:<14.3} {:<14.3} {:<14.3}", x, msh, dmsdh, fri);
|
||||
prev_msh = Some(msh);
|
||||
prev_x = Some(x);
|
||||
}
|
||||
|
||||
// Analytic derivative check: d/dx[linear·(1-x)^(1/3)] near x=1
|
||||
println!("\nAnalytic derivative of linear·(1-x)^(1/3) term:");
|
||||
let a: f64 =
|
||||
2.0 * 0.079 * (300.0_f64 * 0.0095 / 250e-6).powf(-0.25) * 300.0 * 300.0 / (0.0095 * 1290.0);
|
||||
let b: f64 =
|
||||
2.0 * 0.079 * (300.0_f64 * 0.0095 / 11e-6).powf(-0.25) * 300.0 * 300.0 / (0.0095 * 17.4);
|
||||
println!(" A = liquid-only gradient = {:.3} Pa/m", a);
|
||||
println!(" B = vapor-only gradient = {:.3} Pa/m", b);
|
||||
println!(" Ratio B/A = {:.1}", b / a);
|
||||
for &x in &[0.9_f64, 0.95, 0.99, 0.999, 0.9999] {
|
||||
let linear = a + 2.0 * (b - a) * x;
|
||||
let term = linear * (1.0 - x).powf(1.0 / 3.0);
|
||||
// d/dx[linear·(1-x)^(1/3)] = 2(b-a)·(1-x)^(1/3) - (1/3)·linear·(1-x)^(-2/3)
|
||||
let d_term = 2.0 * (b - a) * (1.0 - x).powf(1.0 / 3.0)
|
||||
- (1.0 / 3.0) * linear * (1.0 - x).powf(-2.0 / 3.0);
|
||||
let d_total = d_term + 3.0 * b * x * x;
|
||||
println!(
|
||||
" x={:.5}: term={:>10.2} d(term)/dx={:>14.2} d(MSH)/dx={:>14.2}",
|
||||
x, term, d_term, d_total
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user