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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -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);
}
}