Files
Entropyk/crates/solver/src/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

255 lines
9.1 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.
//! Jacobian row/column equilibration (Ruiz-style scaling).
//!
//! The Newton state vector mixes quantities of vastly different magnitudes:
//! mass flow (ṁ ≈ 1 kg/s), pressure (P ≈ 1e54e6 Pa), enthalpy (h ≈ 1e55e5
//! J/kg) and — once moist-air edges arrive — humidity ratio (W ≈ 0.0050.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)
}
}