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

860 lines
31 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 matrix assembly and solving for Newton-Raphson.
//!
//! This module provides the `JacobianMatrix` type, which wraps `nalgebra::DMatrix<f64>`
//! and provides methods for:
//!
//! - Building from sparse entries (from `JacobianBuilder`)
//! - Solving linear systems J·Δx = -r via LU decomposition
//! - Computing numerical Jacobians via finite differences
//!
//! # Example
//!
//! ```rust
//! use entropyk_solver::jacobian::JacobianMatrix;
//!
//! // Build from sparse entries
//! let entries = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
//! let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
//!
//! // Solve J·Δx = -r
//! let residuals = vec![1.0, 2.0];
//! let delta = jacobian.solve(&residuals).expect("non-singular");
//! ```
use nalgebra::{DMatrix, DVector};
/// Wrapper around `nalgebra::DMatrix<f64>` for Jacobian operations.
///
/// The Jacobian matrix J represents the partial derivatives of the residual vector
/// with respect to the state vector:
///
/// $$J_{ij} = \frac{\partial r_i}{\partial x_j}$$
///
/// For Newton-Raphson, we solve the linear system:
///
/// $$J \cdot \Delta x = -r$$
#[derive(Debug, Clone)]
pub struct JacobianMatrix(DMatrix<f64>);
impl JacobianMatrix {
/// Builds a Jacobian matrix from sparse entries.
///
/// Each entry is a tuple `(row, col, value)`. The matrix is zero-initialized
/// and then filled with the provided entries.
///
/// # Arguments
///
/// * `entries` - Slice of `(row, col, value)` tuples
/// * `n_rows` - Number of rows (equations)
/// * `n_cols` - Number of columns (state variables)
///
/// # Example
///
/// ```rust
/// use entropyk_solver::jacobian::JacobianMatrix;
///
/// let entries = vec![(0, 0, 1.0), (1, 1, 2.0)];
/// let j = JacobianMatrix::from_builder(&entries, 2, 2);
/// ```
pub fn from_builder(entries: &[(usize, usize, f64)], n_rows: usize, n_cols: usize) -> Self {
let mut matrix = DMatrix::zeros(n_rows, n_cols);
for &(row, col, value) in entries {
if row < n_rows && col < n_cols {
matrix[(row, col)] += value;
}
}
JacobianMatrix(matrix)
}
/// Updates an existing Jacobian matrix from sparse entries in-place.
///
/// The matrix is first zeroed out, then filled with the new entries.
/// This avoids re-allocating memory during iterations, satisfying the
/// zero-allocation architecture constraint.
///
/// # Arguments
///
/// * `entries` - Slice of `(row, col, value)` tuples
pub fn update_from_builder(&mut self, entries: &[(usize, usize, f64)]) {
self.0.fill(0.0);
let n_rows = self.0.nrows();
let n_cols = self.0.ncols();
for &(row, col, value) in entries {
if row < n_rows && col < n_cols {
self.0[(row, col)] += value;
}
}
}
/// Creates a zero Jacobian matrix with the given dimensions.
pub fn zeros(n_rows: usize, n_cols: usize) -> Self {
JacobianMatrix(DMatrix::zeros(n_rows, n_cols))
}
/// Returns the number of rows (equations).
pub fn nrows(&self) -> usize {
self.0.nrows()
}
/// Returns the number of columns (state variables).
pub fn ncols(&self) -> usize {
self.0.ncols()
}
/// Solves the linear system J·Δx = -r and returns Δx.
///
/// 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
///
/// * `residuals` - The residual vector r (length must equal `nrows()`)
///
/// # Returns
///
/// * `Some(Δx)` - The Newton step (length = `ncols()`)
/// * `None` - If the Jacobian is singular
///
/// # Example
///
/// ```rust
/// use entropyk_solver::jacobian::JacobianMatrix;
///
/// let entries = vec![(0, 0, 2.0), (1, 1, 1.0)];
/// let j = JacobianMatrix::from_builder(&entries, 2, 2);
///
/// let r = vec![4.0, 3.0];
/// let delta = j.solve(&r).expect("non-singular");
/// assert!((delta[0] - (-2.0)).abs() < 1e-10);
/// assert!((delta[1] - (-3.0)).abs() < 1e-10);
/// ```
pub fn solve(&self, residuals: &[f64]) -> Option<Vec<f64>> {
if residuals.len() != self.0.nrows() {
tracing::warn!(
"residual length {} != Jacobian rows {}",
residuals.len(),
self.0.nrows()
);
return None;
}
// For square systems, use Ruiz-equilibrated LU decomposition.
if self.0.nrows() == self.0.ncols() {
let n = self.0.nrows();
// 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);
// 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
}
}
} else {
// For non-square systems, use least-squares (SVD)
// This is a fallback for overdetermined/underdetermined systems
tracing::debug!(
"Non-square Jacobian ({}x{}) - using least-squares",
self.0.nrows(),
self.0.ncols()
);
let r_vec = DVector::from_row_slice(residuals);
let neg_r = -r_vec;
// Use SVD for robust least-squares solution
let svd = self.0.clone().svd(true, true);
match svd.solve(&neg_r, 1e-10) {
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
}
}
}
}
/// 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
/// the matrix is. Values > 1e10 indicate an ill-conditioned system that
/// may cause numerical instability in the solver.
///
/// Uses SVD decomposition to compute singular values. This is an O(n³)
/// operation and should only be used for diagnostics.
///
/// # Returns
///
/// * `Some(κ)` - The condition number (ratio of largest to smallest singular value)
/// * `None` - If the matrix is rank-deficient (σ_min = 0)
///
/// # Example
///
/// ```rust
/// use entropyk_solver::jacobian::JacobianMatrix;
///
/// // Well-conditioned matrix
/// let entries = vec![(0, 0, 2.0), (1, 1, 1.0)];
/// let j = JacobianMatrix::from_builder(&entries, 2, 2);
/// let cond = j.estimate_condition_number().unwrap();
/// assert!(cond < 10.0, "Expected low condition number, got {}", cond);
///
/// // Ill-conditioned matrix (nearly singular)
/// let bad_entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0000001)];
/// let bad_j = JacobianMatrix::from_builder(&bad_entries, 2, 2);
/// let bad_cond = bad_j.estimate_condition_number().unwrap();
/// assert!(bad_cond > 1e7, "Expected high condition number, got {}", bad_cond);
/// ```
pub fn estimate_condition_number(&self) -> Option<f64> {
// Handle empty matrices
if self.0.nrows() == 0 || self.0.ncols() == 0 {
return None;
}
// Use SVD to get singular values
let svd = self.0.clone().svd(true, true);
// Get singular values
let singular_values = svd.singular_values;
if singular_values.len() == 0 {
return None;
}
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();
match sigma_min {
Some(min) => Some(sigma_max / min),
None => None, // Matrix is rank-deficient
}
}
/// Computes a numerical Jacobian via finite differences.
///
/// For each state variable x_j, perturbs by epsilon and computes:
///
/// $$J_{ij} \approx \frac{r_i(x + \epsilon e_j) - r_i(x)}{\epsilon}$$
///
/// # Arguments
///
/// * `compute_residuals` - Function that computes residuals from state
/// * `state` - Current state vector
/// * `residuals` - Current residual vector (avoid recomputing)
/// * `epsilon` - Perturbation size (typically 1e-8)
///
/// # Returns
///
/// A `JacobianMatrix` with the numerical derivatives.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::jacobian::JacobianMatrix;
///
/// let state: Vec<f64> = vec![1.0, 2.0];
/// let residuals: Vec<f64> = vec![state[0] * state[0], state[1] * 2.0];
/// let compute_residuals = |s: &[f64], r: &mut [f64]| {
/// r[0] = s[0] * s[0];
/// r[1] = s[1] * 2.0;
/// Ok(())
/// };
///
/// let j = JacobianMatrix::numerical(
/// compute_residuals,
/// &state,
/// &residuals,
/// 1e-8
/// ).unwrap();
/// ```
pub fn numerical<F>(
compute_residuals: F,
state: &[f64],
residuals: &[f64],
relative_epsilon: f64,
) -> Result<Self, String>
where
F: Fn(&[f64], &mut [f64]) -> Result<(), String>,
{
let n = state.len();
let m = residuals.len();
let mut matrix = DMatrix::zeros(m, n);
for j in 0..n {
// Use relative epsilon scaled to the state variable magnitude.
// For variables like P~350,000 Pa or h~400,000 J/kg, an absolute
// epsilon of 1e-8 is below library numerical precision and gives zero
// finite differences. A relative epsilon of ~1e-5 gives physically
// meaningful perturbations across all thermodynamic property scales.
let eps_j = state[j].abs().max(1.0) * relative_epsilon;
let mut state_perturbed = state.to_vec();
state_perturbed[j] += eps_j;
let mut residuals_perturbed = vec![0.0; m];
compute_residuals(&state_perturbed, &mut residuals_perturbed)?;
for i in 0..m {
matrix[(i, j)] = (residuals_perturbed[i] - residuals[i]) / eps_j;
}
}
Ok(JacobianMatrix(matrix))
}
/// Returns a reference to the underlying matrix.
pub fn as_matrix(&self) -> &DMatrix<f64> {
&self.0
}
/// Returns a mutable reference to the underlying matrix.
pub fn as_matrix_mut(&mut self) -> &mut DMatrix<f64> {
&mut self.0
}
/// Gets an element at (row, col).
pub fn get(&self, row: usize, col: usize) -> Option<f64> {
if row < self.0.nrows() && col < self.0.ncols() {
Some(self.0[(row, col)])
} else {
None
}
}
/// Sets an element at (row, col).
pub fn set(&mut self, row: usize, col: usize, value: f64) {
if row < self.0.nrows() && col < self.0.ncols() {
self.0[(row, col)] = value;
}
}
/// Returns the Frobenius norm of the matrix.
pub fn norm(&self) -> f64 {
self.0.norm()
}
/// Returns the condition number (ratio of largest to smallest singular value).
///
/// Returns `None` if the matrix is rank-deficient.
pub fn condition_number(&self) -> Option<f64> {
let svd = self.0.clone().svd(false, false);
let singular_values = svd.singular_values;
let max_sv = singular_values.max();
let min_sv = singular_values.min();
if min_sv > 1e-14 {
Some(max_sv / min_sv)
} else {
None
}
}
/// Returns the block structure of the Jacobian matrix for a multi-circuit system.
///
/// For a system with N circuits, each circuit's equations and state variables
/// form a contiguous block in the Jacobian (assuming the state vector layout
/// `[P_edge0, h_edge0, P_edge1, h_edge1, ...]` is ordered by circuit).
///
/// Returns one tuple per circuit: `(row_start, row_end, col_start, col_end)`,
/// where rows correspond to equations and columns to state variables.
///
/// # Notes
///
/// - For uncoupled circuits, the blocks do not overlap and off-block entries
/// are zero (verified by [`is_block_diagonal`](Self::is_block_diagonal)).
/// - Row/col ranges are inclusive-start, exclusive-end: `row_start..row_end`.
///
/// # AC: #6
pub fn block_structure(
&self,
system: &crate::system::System,
) -> Vec<(usize, usize, usize, usize)> {
let n_circuits = system.circuit_count();
let mut blocks = Vec::with_capacity(n_circuits);
for circuit_idx in 0..n_circuits {
let circuit_id = circuit_idx as u8;
// Collect state-variable indices for this circuit.
// State layout: [P_edge0, h_edge0, P_edge1, h_edge1, ...], so for edge i:
// col p_idx = 2*i, col h_idx = 2*i+1.
// The equation rows mirror the same layout, so row = col for square systems.
let indices: Vec<usize> = system
.circuit_edges(crate::CircuitId(circuit_id.into()))
.flat_map(|edge| {
let (p_idx, h_idx) = system.edge_state_indices(edge);
[p_idx, h_idx]
})
.collect();
if indices.is_empty() {
continue;
}
let col_start = *indices.iter().min().unwrap();
let col_end = *indices.iter().max().unwrap() + 1; // exclusive
// Equations mirror state layout for square systems
let row_start = col_start;
let row_end = col_end;
blocks.push((row_start, row_end, col_start, col_end));
}
blocks
}
/// Returns `true` if the Jacobian has block-diagonal structure for a multi-circuit system.
///
/// Checks that all entries **outside** the circuit blocks (as returned by
/// [`block_structure`](Self::block_structure)) have absolute value ≤ `tolerance`.
///
/// For uncoupled multi-circuit systems, the Jacobian is block-diagonal because
/// equations in one circuit do not depend on state variables in another circuit.
///
/// # Arguments
///
/// * `system` — The system whose circuit decomposition defines the expected blocks.
/// * `tolerance` — Maximum allowed absolute value for off-block entries.
///
/// # AC: #6
pub fn is_block_diagonal(&self, system: &crate::system::System, tolerance: f64) -> bool {
let blocks = self.block_structure(system);
let nrows = self.0.nrows();
let ncols = self.0.ncols();
// Map each row to its corresponding block column range (if any)
// This optimizes the check from O(N^2 * C) to O(N^2)
let mut row_block_cols = vec![None; nrows];
for &(rs, re, cs, ce) in &blocks {
for block in &mut row_block_cols[rs..re] {
*block = Some((cs, ce));
}
}
for (row, block) in row_block_cols.iter().enumerate().take(nrows) {
for col in 0..ncols {
let in_block = match *block {
Some((cs, ce)) => col >= cs && col < ce,
None => false,
};
if !in_block {
let val = self.0[(row, col)].abs();
if val > tolerance {
tracing::debug!(
row = row,
col = col,
value = val,
tolerance = tolerance,
"Off-block nonzero entry found — not block-diagonal"
);
return false;
}
}
}
}
true
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
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)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
assert_eq!(j.nrows(), 2);
assert_eq!(j.ncols(), 2);
assert_relative_eq!(j.get(0, 0).unwrap(), 1.0);
assert_relative_eq!(j.get(0, 1).unwrap(), 2.0);
assert_relative_eq!(j.get(1, 0).unwrap(), 3.0);
assert_relative_eq!(j.get(1, 1).unwrap(), 4.0);
}
#[test]
fn test_from_builder_accumulates() {
// Multiple entries for the same position should accumulate
let entries = vec![(0, 0, 1.0), (0, 0, 2.0), (0, 0, 3.0)];
let j = JacobianMatrix::from_builder(&entries, 1, 1);
assert_relative_eq!(j.get(0, 0).unwrap(), 6.0);
}
#[test]
fn test_from_builder_out_of_bounds_ignored() {
let entries = vec![(0, 0, 1.0), (5, 5, 100.0)]; // (5, 5) is out of bounds
let j = JacobianMatrix::from_builder(&entries, 2, 2);
assert_relative_eq!(j.get(0, 0).unwrap(), 1.0);
assert_eq!(j.get(5, 5), None); // Out of bounds
}
#[test]
fn test_solve_identity() {
let entries = vec![(0, 0, 1.0), (1, 1, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![3.0, 4.0];
let delta = j.solve(&r).expect("identity is non-singular");
assert_relative_eq!(delta[0], -3.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -4.0, epsilon = 1e-10);
}
#[test]
fn test_solve_diagonal() {
let entries = vec![(0, 0, 2.0), (1, 1, 4.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![6.0, 8.0];
let delta = j.solve(&r).expect("diagonal is non-singular");
assert_relative_eq!(delta[0], -3.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -2.0, epsilon = 1e-10);
}
#[test]
fn test_solve_full_matrix() {
// J = [[2, 1], [1, 3]]
// J·Δx = -r where r = [1, 2]
// Solution: Δx = [-0.2, -0.6]
let entries = vec![(0, 0, 2.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![1.0, 2.0];
let delta = j.solve(&r).expect("non-singular");
// Verify: J·Δx = -r
assert_relative_eq!(2.0 * delta[0] + 1.0 * delta[1], -1.0, epsilon = 1e-10);
assert_relative_eq!(1.0 * delta[0] + 3.0 * delta[1], -2.0, epsilon = 1e-10);
}
#[test]
fn test_solve_singular_returns_none() {
// Singular matrix: [[1, 1], [1, 1]]
let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![1.0, 2.0];
let result = j.solve(&r);
assert!(result.is_none(), "Singular matrix should return None");
}
#[test]
fn test_solve_zero_matrix_returns_none() {
let entries: Vec<(usize, usize, f64)> = vec![];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![1.0, 2.0];
let result = j.solve(&r);
assert!(result.is_none(), "Zero matrix should return None");
}
#[test]
fn test_numerical_jacobian_linear() {
// r[0] = 2*x0 + 3*x1
// r[1] = x0 - x1
// J = [[2, 3], [1, -1]]
let state = vec![1.0, 2.0];
let residuals = vec![2.0 * state[0] + 3.0 * state[1], state[0] - state[1]];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = 2.0 * s[0] + 3.0 * s[1];
r[1] = s[0] - s[1];
Ok(())
};
let j = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
assert_relative_eq!(j.get(0, 0).unwrap(), 2.0, epsilon = 1e-6);
assert_relative_eq!(j.get(0, 1).unwrap(), 3.0, epsilon = 1e-6);
assert_relative_eq!(j.get(1, 0).unwrap(), 1.0, epsilon = 1e-6);
assert_relative_eq!(j.get(1, 1).unwrap(), -1.0, epsilon = 1e-6);
}
#[test]
fn test_numerical_jacobian_quadratic() {
// r[0] = x0^2
// r[1] = x1^3
// J = [[2*x0, 0], [0, 3*x1^2]]
let state: Vec<f64> = vec![2.0, 3.0];
let residuals: Vec<f64> = vec![state[0].powi(2), state[1].powi(3)];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = s[0].powi(2);
r[1] = s[1].powi(3);
Ok(())
};
let j = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
assert_relative_eq!(j.get(0, 0).unwrap(), 4.0, epsilon = 1e-5); // 2*2
assert_relative_eq!(j.get(0, 1).unwrap(), 0.0, epsilon = 1e-5);
assert_relative_eq!(j.get(1, 0).unwrap(), 0.0, epsilon = 1e-5);
assert_relative_eq!(j.get(1, 1).unwrap(), 27.0, epsilon = 1e-4); // 3*3^2
}
#[test]
fn test_condition_number() {
// Well-conditioned identity
let entries = vec![(0, 0, 1.0), (1, 1, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = j.condition_number().unwrap();
assert_relative_eq!(cond, 1.0, epsilon = 1e-10);
// Ill-conditioned (nearly singular)
let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0 + 1e-10)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = j.condition_number();
assert!(cond.unwrap() > 1e9, "Should be ill-conditioned");
}
#[test]
fn test_norm() {
let entries = vec![(0, 0, 3.0), (0, 1, 4.0)];
let j = JacobianMatrix::from_builder(&entries, 1, 2);
// Frobenius norm = sqrt(3^2 + 4^2) = 5
assert_relative_eq!(j.norm(), 5.0, epsilon = 1e-10);
}
#[test]
fn test_zeros() {
let j = JacobianMatrix::zeros(3, 4);
assert_eq!(j.nrows(), 3);
assert_eq!(j.ncols(), 4);
assert_relative_eq!(j.norm(), 0.0);
}
#[test]
fn test_set_and_get() {
let mut j = JacobianMatrix::zeros(2, 2);
j.set(0, 0, 5.0);
j.set(1, 1, 7.0);
assert_relative_eq!(j.get(0, 0).unwrap(), 5.0);
assert_relative_eq!(j.get(1, 1).unwrap(), 7.0);
assert_relative_eq!(j.get(0, 1).unwrap(), 0.0);
}
#[test]
fn test_solve_wrong_residual_length() {
let entries = vec![(0, 0, 1.0), (1, 1, 1.0)];
let j = JacobianMatrix::from_builder(&entries, 2, 2);
let r = vec![1.0]; // Wrong length
let result = j.solve(&r);
assert!(result.is_none(), "Wrong residual length should return None");
}
#[test]
fn test_numerical_vs_analytical_agree() {
// For a simple function, numerical and analytical Jacobians should match
// r[0] = x0^2 + x0*x1
// r[1] = sin(x0) + cos(x1)
// J = [[2*x0 + x1, x0], [cos(x0), -sin(x1)]]
let state: Vec<f64> = vec![0.5, 1.0];
let residuals: Vec<f64> = vec![
state[0].powi(2) + state[0] * state[1],
state[0].sin() + state[1].cos(),
];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = s[0].powi(2) + s[0] * s[1];
r[1] = s[0].sin() + s[1].cos();
Ok(())
};
let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
// Analytical values
let j00 = 2.0 * state[0] + state[1]; // 2*0.5 + 1.0 = 2.0
let j01 = state[0]; // 0.5
let j10 = state[0].cos(); // cos(0.5)
let j11 = -state[1].sin(); // -sin(1.0)
assert_relative_eq!(j_num.get(0, 0).unwrap(), j00, epsilon = 1e-5);
assert_relative_eq!(j_num.get(0, 1).unwrap(), j01, epsilon = 1e-5);
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);
}
}