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>
This commit is contained in:
@@ -103,8 +103,17 @@ impl JacobianMatrix {
|
||||
|
||||
/// Solves the linear system J·Δx = -r and returns Δx.
|
||||
///
|
||||
/// Uses LU decomposition with partial pivoting. Returns `None` if the
|
||||
/// matrix is singular (no unique solution exists).
|
||||
/// Uses **Ruiz equilibration** (iterative row/column scaling) followed by LU
|
||||
/// decomposition with partial pivoting. Equilibration rescales the rows and
|
||||
/// columns so their ∞-norms approach 1, which dramatically lowers the
|
||||
/// condition number of badly-scaled Jacobians — exactly the situation that
|
||||
/// arises when a thermodynamic system mixes pressures (~1e6), enthalpies
|
||||
/// (~1e5) and dimensionless controls (~1) in the same matrix. The scaling is
|
||||
/// solution-preserving (it is undone on the returned step), so the result is
|
||||
/// mathematically identical to an unscaled solve but numerically far more
|
||||
/// robust on stiff, >50-variable systems.
|
||||
///
|
||||
/// Returns `None` if the matrix is singular (no unique solution exists).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -138,16 +147,45 @@ impl JacobianMatrix {
|
||||
return None;
|
||||
}
|
||||
|
||||
// For square systems, use LU decomposition
|
||||
// For square systems, use Ruiz-equilibrated LU decomposition.
|
||||
if self.0.nrows() == self.0.ncols() {
|
||||
let lu = self.0.clone().lu();
|
||||
let n = self.0.nrows();
|
||||
|
||||
// Solve J·Δx = -r
|
||||
let r_vec = DVector::from_row_slice(residuals);
|
||||
let neg_r = -r_vec;
|
||||
// Diagonal scalings D_r, D_c such that the scaled matrix
|
||||
// Ĵ = diag(d_r) · J · diag(d_c) has near-unit row/column ∞-norms.
|
||||
let (d_r, d_c) = crate::scaling::equilibrate(&self.0);
|
||||
|
||||
match lu.solve(&neg_r) {
|
||||
Some(delta) => Some(delta.iter().copied().collect()),
|
||||
// Build the scaled matrix in place on a single clone (same number of
|
||||
// allocations as the previous unscaled path).
|
||||
let mut scaled = self.0.clone();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
scaled[(i, j)] *= d_r[i] * d_c[j];
|
||||
}
|
||||
}
|
||||
|
||||
let lu = scaled.lu();
|
||||
|
||||
// Scaled right-hand side: D_r · (-r).
|
||||
let neg_r_scaled = DVector::from_iterator(n, (0..n).map(|i| -residuals[i] * d_r[i]));
|
||||
|
||||
match lu.solve(&neg_r_scaled) {
|
||||
// Undo the column scaling to recover the true step: Δx = D_c · y.
|
||||
Some(y) => {
|
||||
let delta = crate::scaling::unscale_dx(y.as_slice(), &d_c);
|
||||
// A NaN/Inf entry anywhere in the Jacobian (or RHS) silently
|
||||
// slips through LU as a non-finite step. Reject it here so the
|
||||
// caller treats the iteration as a clean failure instead of
|
||||
// propagating NaN into the state for another iteration.
|
||||
if delta.iter().all(|v| v.is_finite()) {
|
||||
Some(delta)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"LU solve produced a non-finite step - Jacobian may contain NaN/Inf"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("LU solve failed - Jacobian may be singular");
|
||||
None
|
||||
@@ -168,7 +206,17 @@ impl JacobianMatrix {
|
||||
// Use SVD for robust least-squares solution
|
||||
let svd = self.0.clone().svd(true, true);
|
||||
match svd.solve(&neg_r, 1e-10) {
|
||||
Ok(delta) => Some(delta.iter().copied().collect()),
|
||||
Ok(delta) => {
|
||||
let v: Vec<f64> = delta.iter().copied().collect();
|
||||
if v.iter().all(|x| x.is_finite()) {
|
||||
Some(v)
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"SVD solve produced a non-finite step - Jacobian may contain NaN/Inf"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("SVD solve failed - Jacobian may be rank-deficient: {}", e);
|
||||
None
|
||||
@@ -177,6 +225,29 @@ impl JacobianMatrix {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Ruiz row/column equilibration factors `(d_r, d_c)`.
|
||||
///
|
||||
/// The scaled matrix `diag(d_r) · J · diag(d_c)` has row and column
|
||||
/// ∞-norms close to 1. This is the same scaling applied internally by
|
||||
/// [`solve`](Self::solve); it is exposed for diagnostics (e.g. inspecting how
|
||||
/// ill-scaled a Jacobian is, or estimating the conditioning improvement).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_solver::jacobian::JacobianMatrix;
|
||||
///
|
||||
/// // Badly scaled diagonal: entries span 12 orders of magnitude.
|
||||
/// let entries = vec![(0, 0, 1e6), (1, 1, 1e-6)];
|
||||
/// let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
||||
/// let (d_r, d_c) = j.ruiz_scaling_factors();
|
||||
/// assert_eq!(d_r.len(), 2);
|
||||
/// assert_eq!(d_c.len(), 2);
|
||||
/// ```
|
||||
pub fn ruiz_scaling_factors(&self) -> (Vec<f64>, Vec<f64>) {
|
||||
crate::scaling::equilibrate(&self.0)
|
||||
}
|
||||
|
||||
/// Estimates the condition number of the Jacobian matrix.
|
||||
///
|
||||
/// The condition number κ = σ_max / σ_min indicates how ill-conditioned
|
||||
@@ -225,7 +296,11 @@ impl JacobianMatrix {
|
||||
}
|
||||
|
||||
let sigma_max = singular_values.max();
|
||||
let sigma_min = singular_values.iter().filter(|&&s| s > 0.0).min_by(|a, b| a.partial_cmp(b).unwrap()).copied();
|
||||
let sigma_min = singular_values
|
||||
.iter()
|
||||
.filter(|&&s| s > 0.0)
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap())
|
||||
.copied();
|
||||
|
||||
match sigma_min {
|
||||
Some(min) => Some(sigma_max / min),
|
||||
@@ -471,6 +546,15 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// A NaN entry in the Jacobian must yield `None` (clean failure) rather than
|
||||
/// a `Some(..)` step containing NaN that would poison the next iteration.
|
||||
#[test]
|
||||
fn test_solve_with_nan_entry_returns_none() {
|
||||
let entries = vec![(0, 0, f64::NAN), (1, 1, 1.0)];
|
||||
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
||||
assert!(j.solve(&[1.0, 1.0]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_builder_simple() {
|
||||
let entries = vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 3.0), (1, 1, 4.0)];
|
||||
@@ -694,4 +778,82 @@ mod tests {
|
||||
assert_relative_eq!(j_num.get(1, 0).unwrap(), j10, epsilon = 1e-5);
|
||||
assert_relative_eq!(j_num.get(1, 1).unwrap(), j11, epsilon = 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ruiz_equilibration_unit_norms() {
|
||||
// After equilibration, scaled row/column ∞-norms should be ≈ 1.
|
||||
let entries = vec![(0, 0, 1e6), (0, 1, 1e3), (1, 0, 1e-3), (1, 1, 1e-6)];
|
||||
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
||||
let (d_r, d_c) = j.ruiz_scaling_factors();
|
||||
|
||||
let m = j.as_matrix();
|
||||
for i in 0..2 {
|
||||
let row_max = (0..2)
|
||||
.map(|jj| (d_r[i] * m[(i, jj)] * d_c[jj]).abs())
|
||||
.fold(0.0_f64, f64::max);
|
||||
assert!(
|
||||
(row_max - 1.0).abs() < 0.05,
|
||||
"row {} ∞-norm not unit: {}",
|
||||
i,
|
||||
row_max
|
||||
);
|
||||
}
|
||||
for jj in 0..2 {
|
||||
let col_max = (0..2)
|
||||
.map(|i| (d_r[i] * m[(i, jj)] * d_c[jj]).abs())
|
||||
.fold(0.0_f64, f64::max);
|
||||
assert!(
|
||||
(col_max - 1.0).abs() < 0.05,
|
||||
"col {} ∞-norm not unit: {}",
|
||||
jj,
|
||||
col_max
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ruiz_reduces_condition_number() {
|
||||
// Badly-scaled but SVD-resolvable matrix (dynamic range ~1e6 keeps the
|
||||
// small singular value above underflow, so the condition number is
|
||||
// measured reliably). Row 0 lives at ~1e6, row 1 at ~1.
|
||||
let entries = vec![(0, 0, 1.0e6), (0, 1, 2.0e6), (1, 0, 3.0), (1, 1, 1.0)];
|
||||
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
||||
|
||||
let cond_before = j.estimate_condition_number().unwrap();
|
||||
|
||||
// Apply the Ruiz scaling and recompute the condition number.
|
||||
let (d_r, d_c) = j.ruiz_scaling_factors();
|
||||
let m = j.as_matrix();
|
||||
let mut scaled_entries = Vec::new();
|
||||
for i in 0..2 {
|
||||
for jj in 0..2 {
|
||||
scaled_entries.push((i, jj, d_r[i] * m[(i, jj)] * d_c[jj]));
|
||||
}
|
||||
}
|
||||
let scaled = JacobianMatrix::from_builder(&scaled_entries, 2, 2);
|
||||
let cond_after = scaled.estimate_condition_number().unwrap();
|
||||
|
||||
assert!(
|
||||
cond_after < cond_before / 100.0 && cond_after < 10.0,
|
||||
"Ruiz should slash the condition number: before={:.3e}, after={:.3e}",
|
||||
cond_before,
|
||||
cond_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solve_illconditioned_matches_known_solution() {
|
||||
// Badly-scaled system with a known solution. J·x = b where
|
||||
// J = [[1e6, 2e6], [3e-6, 4e-6]], x = [1, -1] => b = [-1e6, -1e-6].
|
||||
// solve() returns Δx for J·Δx = -r, so set r = -b to get Δx = x.
|
||||
let entries = vec![(0, 0, 1e6), (0, 1, 2e6), (1, 0, 3e-6), (1, 1, 4e-6)];
|
||||
let j = JacobianMatrix::from_builder(&entries, 2, 2);
|
||||
|
||||
let b = [-1e6, -1e-6];
|
||||
let r = [-b[0], -b[1]];
|
||||
let delta = j.solve(&r).expect("non-singular");
|
||||
|
||||
assert_relative_eq!(delta[0], 1.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(delta[1], -1.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user