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>
255 lines
9.1 KiB
Rust
255 lines
9.1 KiB
Rust
//! Jacobian row/column equilibration (Ruiz-style scaling).
|
||
//!
|
||
//! The Newton state vector mixes quantities of vastly different magnitudes:
|
||
//! mass flow (ṁ ≈ 1 kg/s), pressure (P ≈ 1e5–4e6 Pa), enthalpy (h ≈ 1e5–5e5
|
||
//! J/kg) and — once moist-air edges arrive — humidity ratio (W ≈ 0.005–0.025).
|
||
//! Without rescaling, the Jacobian condition number can reach 1e10+, so the LU
|
||
//! solve loses significant digits and Newton stalls or diverges.
|
||
//!
|
||
//! This module applies a **solution-preserving** diagonal scaling. Given the
|
||
//! row/column factors `(d_r, d_c)` from [`equilibrate`], the scaled matrix
|
||
//! `Ĵ = diag(d_r) · J · diag(d_c)` has row and column ∞-norms ≈ 1. The Newton
|
||
//! system is then solved as
|
||
//!
|
||
//! ```text
|
||
//! Ĵ · y = diag(d_r) · (−r)
|
||
//! Δx = diag(d_c) · y (see `unscale_dx`)
|
||
//! ```
|
||
//!
|
||
//! which is mathematically identical to the unscaled solve but numerically far
|
||
//! more robust on stiff, mixed-unit, >50-variable systems.
|
||
//!
|
||
//! Row scaling uses the inverse row ∞-norm; column scaling the inverse column
|
||
//! ∞-norm. Both are applied iteratively (Ruiz equilibration) until the norms are
|
||
//! within tolerance of 1. This data-driven scheme subsumes the nominal
|
||
//! per-variable column scaling described in the design doc (§3.5.2) without
|
||
//! needing hand-tuned reference magnitudes.
|
||
//!
|
||
//! **Ref:** IDAES scaling theory,
|
||
//! `idaes-pse.readthedocs.io/en/stable/explanations/scaling_toolbox/scaling_theory.html`
|
||
//! and the Ruiz (2001) iterative equilibration algorithm. Design context:
|
||
//! `_bmad-output/planning-artifacts/complex-machine-design.md#3.5`.
|
||
|
||
use nalgebra::DMatrix;
|
||
|
||
/// Maximum Ruiz sweeps before giving up on the unit-norm target.
|
||
const MAX_ITERS: usize = 20;
|
||
/// Convergence tolerance on the row/column ∞-norm deviation from 1.
|
||
const TOL: f64 = 1e-3;
|
||
|
||
/// Computes Ruiz row/column equilibration factors for a matrix.
|
||
///
|
||
/// Iteratively rescales rows and columns by the square root of their current
|
||
/// ∞-norm until the norms are within tolerance of 1 (or [`MAX_ITERS`] is
|
||
/// reached). Returns `(d_r, d_c)` such that `diag(d_r) · m · diag(d_c)` is
|
||
/// equilibrated (row/column ∞-norms ≈ 1).
|
||
///
|
||
/// Zero rows/columns are left untouched (factor `1.0`), so the rank — and
|
||
/// therefore singularity detection in a subsequent LU — is preserved exactly.
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust
|
||
/// use entropyk_solver::scaling::equilibrate;
|
||
/// use nalgebra::DMatrix;
|
||
///
|
||
/// // Badly scaled diagonal spanning 12 orders of magnitude.
|
||
/// let m = DMatrix::from_row_slice(2, 2, &[1e6, 0.0, 0.0, 1e-6]);
|
||
/// let (d_r, d_c) = equilibrate(&m);
|
||
/// assert_eq!(d_r.len(), 2);
|
||
/// assert_eq!(d_c.len(), 2);
|
||
/// ```
|
||
pub fn equilibrate(m: &DMatrix<f64>) -> (Vec<f64>, Vec<f64>) {
|
||
let n_rows = m.nrows();
|
||
let n_cols = m.ncols();
|
||
let mut d_r = vec![1.0_f64; n_rows];
|
||
let mut d_c = vec![1.0_f64; n_cols];
|
||
|
||
if n_rows == 0 || n_cols == 0 {
|
||
return (d_r, d_c);
|
||
}
|
||
|
||
for _ in 0..MAX_ITERS {
|
||
let mut max_deviation = 0.0_f64;
|
||
|
||
// Row scaling: divide each row factor by sqrt of its current ∞-norm.
|
||
for i in 0..n_rows {
|
||
let mut row_max = 0.0_f64;
|
||
for j in 0..n_cols {
|
||
let v = (d_r[i] * m[(i, j)] * d_c[j]).abs();
|
||
if v > row_max {
|
||
row_max = v;
|
||
}
|
||
}
|
||
if row_max > 0.0 {
|
||
d_r[i] /= row_max.sqrt();
|
||
max_deviation = max_deviation.max((1.0 - row_max).abs());
|
||
}
|
||
}
|
||
|
||
// Column scaling: divide each column factor by sqrt of its current ∞-norm.
|
||
for j in 0..n_cols {
|
||
let mut col_max = 0.0_f64;
|
||
for i in 0..n_rows {
|
||
let v = (d_r[i] * m[(i, j)] * d_c[j]).abs();
|
||
if v > col_max {
|
||
col_max = v;
|
||
}
|
||
}
|
||
if col_max > 0.0 {
|
||
d_c[j] /= col_max.sqrt();
|
||
max_deviation = max_deviation.max((1.0 - col_max).abs());
|
||
}
|
||
}
|
||
|
||
if max_deviation < TOL {
|
||
break;
|
||
}
|
||
}
|
||
|
||
(d_r, d_c)
|
||
}
|
||
|
||
/// Un-scales a solved step back to physical units.
|
||
///
|
||
/// After solving the equilibrated system `diag(d_r) · J · diag(d_c) · y =
|
||
/// diag(d_r) · (−r)`, the true Newton step is recovered by reapplying the column
|
||
/// scaling: `Δx[j] = d_c[j] · y[j]`.
|
||
///
|
||
/// If `y` and `d_c` differ in length, the shorter length governs the result
|
||
/// (defensive — callers always pass matching lengths).
|
||
///
|
||
/// # Example
|
||
///
|
||
/// ```rust
|
||
/// use entropyk_solver::scaling::unscale_dx;
|
||
///
|
||
/// let y = [2.0, -3.0];
|
||
/// let d_c = [0.5, 10.0];
|
||
/// let dx = unscale_dx(&y, &d_c);
|
||
/// assert_eq!(dx, vec![1.0, -30.0]);
|
||
/// ```
|
||
pub fn unscale_dx(y: &[f64], d_c: &[f64]) -> Vec<f64> {
|
||
y.iter().zip(d_c.iter()).map(|(&yj, &cj)| cj * yj).collect()
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Tests
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use approx::assert_relative_eq;
|
||
|
||
/// After equilibration, scaled row/column ∞-norms should be ≈ 1.
|
||
#[test]
|
||
fn test_equilibrate_unit_norms() {
|
||
let m = DMatrix::from_row_slice(2, 2, &[1e6, 1e3, 1e-3, 1e-6]);
|
||
let (d_r, d_c) = equilibrate(&m);
|
||
|
||
for i in 0..2 {
|
||
let row_max = (0..2)
|
||
.map(|j| (d_r[i] * m[(i, j)] * d_c[j]).abs())
|
||
.fold(0.0_f64, f64::max);
|
||
assert!(
|
||
(row_max - 1.0).abs() < 0.05,
|
||
"row {} ∞-norm not unit: {}",
|
||
i,
|
||
row_max
|
||
);
|
||
}
|
||
for j in 0..2 {
|
||
let col_max = (0..2)
|
||
.map(|i| (d_r[i] * m[(i, j)] * d_c[j]).abs())
|
||
.fold(0.0_f64, f64::max);
|
||
assert!(
|
||
(col_max - 1.0).abs() < 0.05,
|
||
"col {} ∞-norm not unit: {}",
|
||
j,
|
||
col_max
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `unscale_dx` must be the exact inverse of column scaling: given a step
|
||
/// expressed in scaled coordinates `y = D_c^{-1} · x`, reapplying `d_c`
|
||
/// recovers `x` exactly.
|
||
#[test]
|
||
fn test_unscale_dx_inverts_column_scaling() {
|
||
let x = [3.0_f64, -7.0, 0.25];
|
||
let d_c = [0.5_f64, 10.0, 2.0];
|
||
// y = D_c^{-1} · x
|
||
let y: Vec<f64> = x.iter().zip(d_c.iter()).map(|(&xj, &cj)| xj / cj).collect();
|
||
let recovered = unscale_dx(&y, &d_c);
|
||
for (got, want) in recovered.iter().zip(x.iter()) {
|
||
assert_relative_eq!(got, want, epsilon = 1e-12);
|
||
}
|
||
}
|
||
|
||
/// Zero rows and columns must keep factor 1.0 so the rank is preserved and
|
||
/// singularity detection in the downstream LU is unchanged.
|
||
#[test]
|
||
fn test_zero_row_and_column_keep_unit_factor() {
|
||
// Row 1 is all zero; column 1 is all zero.
|
||
let m = DMatrix::from_row_slice(2, 2, &[5.0, 0.0, 0.0, 0.0]);
|
||
let (d_r, d_c) = equilibrate(&m);
|
||
assert_relative_eq!(d_r[1], 1.0, epsilon = 1e-12);
|
||
assert_relative_eq!(d_c[1], 1.0, epsilon = 1e-12);
|
||
}
|
||
|
||
/// Equilibration must slash the condition number of a badly-scaled matrix.
|
||
#[test]
|
||
fn test_equilibrate_reduces_condition_number() {
|
||
// Row 0 lives at ~1e6, row 1 at ~1; dynamic range keeps the small
|
||
// singular value well above underflow so κ is measured reliably.
|
||
let m = DMatrix::from_row_slice(2, 2, &[1.0e6, 2.0e6, 3.0, 1.0]);
|
||
|
||
let cond_before = condition_number(&m).unwrap();
|
||
|
||
let (d_r, d_c) = equilibrate(&m);
|
||
let mut scaled = m.clone();
|
||
for i in 0..2 {
|
||
for j in 0..2 {
|
||
scaled[(i, j)] *= d_r[i] * d_c[j];
|
||
}
|
||
}
|
||
let cond_after = condition_number(&scaled).unwrap();
|
||
|
||
assert!(
|
||
cond_after < cond_before / 100.0 && cond_after < 10.0,
|
||
"Ruiz should slash κ: before={:.3e}, after={:.3e}",
|
||
cond_before,
|
||
cond_after
|
||
);
|
||
}
|
||
|
||
/// Empty matrices return empty/unit factors without panicking.
|
||
#[test]
|
||
fn test_empty_matrix() {
|
||
let m: DMatrix<f64> = DMatrix::zeros(0, 0);
|
||
let (d_r, d_c) = equilibrate(&m);
|
||
assert!(d_r.is_empty());
|
||
assert!(d_c.is_empty());
|
||
}
|
||
|
||
/// Local κ helper (SVD σ_max/σ_min) for the test above only.
|
||
fn condition_number(m: &DMatrix<f64>) -> Option<f64> {
|
||
if m.nrows() == 0 || m.ncols() == 0 {
|
||
return None;
|
||
}
|
||
let svd = m.clone().svd(false, false);
|
||
let sv = svd.singular_values;
|
||
if sv.len() == 0 {
|
||
return None;
|
||
}
|
||
let sigma_max = sv.max();
|
||
let sigma_min = sv
|
||
.iter()
|
||
.filter(|&&s| s > 0.0)
|
||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||
.copied();
|
||
sigma_min.map(|min| sigma_max / min)
|
||
}
|
||
}
|