//! 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 { 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, 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 { 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 = 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 ); }