//! Jacobian matrix assembly and solving for Newton-Raphson. //! //! This module provides the `JacobianMatrix` type, which wraps `nalgebra::DMatrix` //! 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` 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); 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> { 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 = 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, Vec) { 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 { // 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 = vec![1.0, 2.0]; /// let residuals: Vec = 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( compute_residuals: F, state: &[f64], residuals: &[f64], relative_epsilon: f64, ) -> Result 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 { &self.0 } /// Returns a mutable reference to the underlying matrix. pub fn as_matrix_mut(&mut self) -> &mut DMatrix { &mut self.0 } /// Gets an element at (row, col). pub fn get(&self, row: usize, col: usize) -> Option { 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 { 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 = 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 = vec![2.0, 3.0]; let residuals: Vec = 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 = vec![0.5, 1.0]; let residuals: Vec = 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); } }