Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
162 lines
5.9 KiB
Rust
162 lines
5.9 KiB
Rust
//! CM1.5 — acceptance tests for Jacobian row/column equilibration (NFR1).
|
||
//!
|
||
//! These tests prove the equilibration requirement on a *multi-circuit,
|
||
//! mixed-unit* Jacobian (the kind produced by a two-circuit `(ṁ, P, h)` system,
|
||
//! where ṁ ≈ 1 kg/s, P ≈ 1e6 Pa, h ≈ 3e5 J/kg):
|
||
//!
|
||
//! 1. The condition number drops by ≥ 1e4 versus the unscaled matrix.
|
||
//! 2. The equilibrated solve returns the same Newton step as an unscaled
|
||
//! reference solve, within tight relative tolerance (solution-preserving).
|
||
//!
|
||
//! A faithful synthetic stand-in is used so the test is deterministic and free
|
||
//! of any fluid-backend dependency: a well-conditioned base matrix `W` is framed
|
||
//! by physical magnitudes via `J = diag(mag) · W · diag(mag)`. This reproduces
|
||
//! exactly the ill-scaling that wrecks conditioning in the real assembled
|
||
//! Jacobian, while keeping the *intrinsic* problem (`W`) benign — so any κ blow-up
|
||
//! is purely a scaling artifact that equilibration must remove.
|
||
|
||
use entropyk_solver::{equilibrate, JacobianMatrix};
|
||
use nalgebra::{DMatrix, DVector};
|
||
|
||
/// Well-conditioned, diagonally-dominant base matrix for a 2-circuit layout.
|
||
///
|
||
/// Indices 0,1,2 = (ṁ, P, h) of circuit A; 3,4,5 = circuit B. The (0,5), (2,3),
|
||
/// (3,2), (5,0) entries model weak inter-circuit (thermal) coupling, so the
|
||
/// matrix is NOT block-diagonal — a realistic coupled system.
|
||
fn base_matrix() -> DMatrix<f64> {
|
||
DMatrix::from_row_slice(
|
||
6,
|
||
6,
|
||
&[
|
||
2.0, 0.4, 0.1, 0.0, 0.0, 0.05, //
|
||
0.3, 2.0, 0.5, 0.0, 0.0, 0.0, //
|
||
0.1, 0.3, 2.0, 0.05, 0.0, 0.0, //
|
||
0.0, 0.0, 0.05, 2.0, 0.4, 0.1, //
|
||
0.0, 0.0, 0.0, 0.3, 2.0, 0.5, //
|
||
0.05, 0.0, 0.0, 0.1, 0.3, 2.0, //
|
||
],
|
||
)
|
||
}
|
||
|
||
/// Builds `J = diag(mag) · W · diag(mag)` and returns it as a `JacobianMatrix`.
|
||
fn scaled_system(mag: &[f64]) -> (DMatrix<f64>, JacobianMatrix) {
|
||
let w = base_matrix();
|
||
let n = w.nrows();
|
||
let mut entries = Vec::with_capacity(n * n);
|
||
let mut dense = DMatrix::zeros(n, n);
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
let v = mag[i] * w[(i, j)] * mag[j];
|
||
dense[(i, j)] = v;
|
||
entries.push((i, j, v));
|
||
}
|
||
}
|
||
(dense.clone(), JacobianMatrix::from_builder(&entries, n, n))
|
||
}
|
||
|
||
/// κ via SVD (σ_max / σ_min), skipping exact-zero singular values.
|
||
fn condition_number(m: &DMatrix<f64>) -> f64 {
|
||
let svd = m.clone().svd(false, false);
|
||
let sv = svd.singular_values;
|
||
let sigma_max = sv.max();
|
||
let sigma_min = sv
|
||
.iter()
|
||
.filter(|&&s| s > 0.0)
|
||
.cloned()
|
||
.fold(f64::INFINITY, f64::min);
|
||
sigma_max / sigma_min
|
||
}
|
||
|
||
/// AC #3 (bullet 1+2): on a realistic mixed-unit (Pa + J/kg + kg/s) two-circuit
|
||
/// Jacobian, equilibration slashes the condition number by ≥ 1e4.
|
||
#[test]
|
||
fn test_equilibration_reduces_condition_number_realistic_magnitudes() {
|
||
// ṁ ≈ 1, P ≈ 1e6 Pa, h ≈ 3e5 J/kg, repeated for two circuits.
|
||
let mag = [1.0, 1.0e6, 3.0e5, 1.0, 1.0e6, 3.0e5];
|
||
let (dense, _jac) = scaled_system(&mag);
|
||
|
||
let cond_before = condition_number(&dense);
|
||
// Sanity: the raw problem really is badly conditioned.
|
||
assert!(
|
||
cond_before > 1.0e8,
|
||
"raw κ should be large for mixed units, got {:.3e}",
|
||
cond_before
|
||
);
|
||
|
||
let (d_r, d_c) = equilibrate(&dense);
|
||
let mut scaled = dense.clone();
|
||
for i in 0..6 {
|
||
for j in 0..6 {
|
||
scaled[(i, j)] *= d_r[i] * d_c[j];
|
||
}
|
||
}
|
||
let cond_after = condition_number(&scaled);
|
||
|
||
assert!(
|
||
cond_after <= cond_before / 1.0e4,
|
||
"equilibration must cut κ by ≥1e4: before={:.3e}, after={:.3e} (ratio {:.3e})",
|
||
cond_before,
|
||
cond_after,
|
||
cond_before / cond_after
|
||
);
|
||
}
|
||
|
||
/// AC #3 (bullet 3) + AC #4: the equilibrated `JacobianMatrix::solve` returns the
|
||
/// same Newton step as an unscaled reference LU solve, within 1e-9 relative — the
|
||
/// scaling is solution-preserving. Uses a mixed-unit system whose conditioning
|
||
/// (κ ≈ 1e6) is still comfortably resolvable in f64, so the 1e-9 comparison is
|
||
/// meaningful while κ reduction (≥1e4) still holds.
|
||
#[test]
|
||
fn test_equilibrated_solve_matches_unscaled_reference() {
|
||
// Mixed scales spanning 1e3 (kg/s vs reduced-pressure scale): κ_raw ≈ 1e6.
|
||
let mag = [1.0, 1.0e3, 3.0e2, 1.0, 1.0e3, 3.0e2];
|
||
let (dense, jac) = scaled_system(&mag);
|
||
|
||
// Known step we want to recover.
|
||
let x_true = DVector::from_row_slice(&[0.7, -1.3, 2.1, -0.4, 0.9, -1.1]);
|
||
// b = J · x_true; we want J · Δx = b, i.e. solve() with r = -b → Δx = x_true.
|
||
let b = &dense * &x_true;
|
||
let r: Vec<f64> = b.iter().map(|v| -v).collect();
|
||
|
||
// Equilibrated solve (the production path).
|
||
let delta = jac.solve(&r).expect("non-singular");
|
||
|
||
// Unscaled reference: direct LU on the raw matrix.
|
||
let dx_ref = dense.clone().lu().solve(&b).expect("reference LU solves");
|
||
|
||
for k in 0..6 {
|
||
let scale = x_true[k].abs().max(1.0);
|
||
assert!(
|
||
(delta[k] - x_true[k]).abs() / scale < 1e-9,
|
||
"equilibrated step differs from x_true at {}: got {}, want {}",
|
||
k,
|
||
delta[k],
|
||
x_true[k]
|
||
);
|
||
assert!(
|
||
(delta[k] - dx_ref[k]).abs() / scale < 1e-9,
|
||
"equilibrated step differs from unscaled reference at {}: {} vs {}",
|
||
k,
|
||
delta[k],
|
||
dx_ref[k]
|
||
);
|
||
}
|
||
|
||
// κ reduction also holds for this system (≥1e4).
|
||
let cond_before = condition_number(&dense);
|
||
let (d_r, d_c) = equilibrate(&dense);
|
||
let mut scaled = dense.clone();
|
||
for i in 0..6 {
|
||
for j in 0..6 {
|
||
scaled[(i, j)] *= d_r[i] * d_c[j];
|
||
}
|
||
}
|
||
let cond_after = condition_number(&scaled);
|
||
assert!(
|
||
cond_after <= cond_before / 1.0e4,
|
||
"κ reduction ≥1e4 expected: before={:.3e}, after={:.3e}",
|
||
cond_before,
|
||
cond_after
|
||
);
|
||
}
|