Files
Entropyk/crates/solver/tests/jacobian_scaling.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
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>
2026-07-17 22:46:46 +02:00

162 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
);
}