outram_foam_basic_lib/ldu_matrix/fv_matrix.rs
1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
21
22use std::ops::{Add, AddAssign, Neg, Sub, SubAssign};
23use std::sync::Arc;
24
25use super::ldu_matrix::LduMatrix;
26use super::solvers::conjugate_gradient::conjugate_gradient;
27use super::solvers::gamg::gamg;
28use super::solvers::gauss_seidel::gauss_seidel;
29use super::solvers::krylov_solve::{krylov_solve, KrylovMethod, KrylovOptions};
30use crate::fields::boundary::bc::PatchField;
31use crate::fields::field::Field;
32use crate::fields::vol_field::VolScalarField;
33use crate::mesh::fv_mesh::FvMesh;
34
35/// Sparse implicit matrix equation `A·φ = b` for a scalar field φ.
36///
37/// Mirrors `Foam::fvMatrix<scalar>` from
38/// `src/finiteVolume/fvMatrices/fvMatrix/fvMatrix.H`.
39///
40/// Assembled incrementally by `fvm::` operators in Layer 3; solved via
41/// `self.solve()`.
42pub struct FvMatrix {
43 /// Mesh the equation is defined on (shares the face addressing).
44 pub mesh: Arc<FvMesh>,
45 /// Sparse LDU coefficients of the operator `A`.
46 pub ldu: LduMatrix,
47 /// Right-hand-side source term, length `n_cells`.
48 pub source: Field<f64>,
49}
50
51/// Solver settings passed to `FvMatrix::solve`.
52#[derive(Debug, Clone, Copy)]
53pub struct SolverSettings {
54 /// Convergence tolerance on the normalised residual (dimensionless).
55 pub tolerance: f64,
56 /// Maximum iteration/sweep count before giving up.
57 pub max_iter: usize,
58}
59
60impl Default for SolverSettings {
61 fn default() -> Self {
62 Self {
63 tolerance: 1e-7,
64 max_iter: 1000,
65 }
66 }
67}
68
69/// Summary of a linear solve.
70#[derive(Debug, Clone, Copy)]
71pub struct SolverPerformance {
72 /// Number of iterations/sweeps actually performed.
73 pub n_iterations: usize,
74 /// Normalised residual at exit (dimensionless).
75 pub final_residual: f64,
76 /// `true` if `final_residual` dropped below the requested tolerance.
77 pub converged: bool,
78}
79
80impl FvMatrix {
81 /// Create a new zero-initialised FvMatrix for the given mesh.
82 ///
83 /// The LDU face addressing holds the mesh's internal faces, then one slot
84 /// per [`CyclicCoupling`](crate::mesh::CyclicCoupling) (conformal periodic
85 /// seam), then one slot per [`AmiWeight`](crate::mesh::AmiWeight) of each
86 /// [`AmiCoupling`](crate::mesh::AmiCoupling) (non-conformal periodic / AMI
87 /// seam, in `ami_couplings` order — see
88 /// [`FvMesh::ami_ldu_start`](crate::mesh::FvMesh::ami_ldu_start)). Every
89 /// matrix built on a given mesh therefore shares one structure — a
90 /// prerequisite for the coefficient-wise matrix arithmetic (`+`/`−`).
91 /// Operators that ignore seams (e.g. `fvm::ddt`) simply leave those slots
92 /// zero; `fvm::laplacian`/`fvm::div` fill them via the paired-cell coupling.
93 pub fn new(mesh: Arc<FvMesh>) -> Self {
94 let mut owner = mesh.owner[..mesh.n_internal_faces].to_vec();
95 let mut neighbour = mesh.neighbour.to_vec();
96 for cc in &mesh.cyclic_couplings {
97 owner.push(cc.owner);
98 neighbour.push(cc.neighbour);
99 }
100 // Non-conformal (AMI) seams: one LDU face per weighted (target, source)
101 // pair, appended after the cyclic couplings in `ami_couplings` order.
102 for cc in &mesh.ami_couplings {
103 for w in &cc.weights {
104 owner.push(cc.target_cell);
105 neighbour.push(w.source_cell);
106 }
107 }
108 let n_cells = mesh.n_cells;
109 Self {
110 mesh,
111 ldu: LduMatrix::new(n_cells, owner, neighbour),
112 source: Field::zeros(n_cells),
113 }
114 }
115
116 /// Solve `A·φ = source` and return the solution as a `VolScalarField`.
117 ///
118 /// Uses Gauss-Seidel as the default solver. Layer 3 will add conjugate
119 /// gradient / GAMG when those are ported.
120 pub fn solve(
121 &self,
122 name: impl Into<String>,
123 settings: SolverSettings,
124 ) -> (VolScalarField, SolverPerformance) {
125 let mut x = vec![0.0_f64; self.mesh.n_cells];
126 let b: Vec<f64> = self.source.iter().copied().collect();
127 let (iters, res) =
128 gauss_seidel(&self.ldu, &b, &mut x, settings.tolerance, settings.max_iter);
129
130 let boundary = self
131 .mesh
132 .patches
133 .iter()
134 .map(|p| PatchField::zero_gradient(p.size))
135 .collect();
136 let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
137 let perf = SolverPerformance {
138 n_iterations: iters,
139 final_residual: res,
140 converged: res < settings.tolerance,
141 };
142 (field, perf)
143 }
144
145 /// Solve the system with preconditioned conjugate gradient (cold start).
146 ///
147 /// PCG requires a symmetric SPD matrix (`upper == lower`), which holds for
148 /// the pressure Poisson equation assembled by `fvm::laplacian`. It converges
149 /// in O(√κ) iterations versus O(κ) for Gauss-Seidel — dramatically faster
150 /// for the elliptic pressure solve. Do **not** use it for the asymmetric
151 /// convection-bearing momentum matrix.
152 ///
153 /// This starts the iteration from `x = 0`. In a transient solver, prefer
154 /// [`solve_cg_with_guess`](Self::solve_cg_with_guess) to warm-start from the
155 /// previous time step's field — near steady state that converges in a
156 /// handful of iterations instead of from scratch every step.
157 pub fn solve_cg(
158 &self,
159 name: impl Into<String>,
160 settings: SolverSettings,
161 ) -> (VolScalarField, SolverPerformance) {
162 self.solve_cg_inner(name, None, settings)
163 }
164
165 /// Solve with PCG, **warm-started** from `initial` (typically the previous
166 /// time step's field).
167 ///
168 /// The DIC-preconditioned CG begins from `initial.internal` instead of zero.
169 /// For a transient run approaching steady state the solution barely changes
170 /// between steps, so the starting residual is tiny and the solve completes
171 /// in very few iterations — often zero. See
172 /// [`conjugate_gradient`](crate::ldu_matrix::solvers::conjugate_gradient()) for the
173 /// preconditioner and warm-start details.
174 pub fn solve_cg_with_guess(
175 &self,
176 name: impl Into<String>,
177 initial: &VolScalarField,
178 settings: SolverSettings,
179 ) -> (VolScalarField, SolverPerformance) {
180 self.solve_cg_inner(name, Some(initial.internal.as_slice()), settings)
181 }
182
183 fn solve_cg_inner(
184 &self,
185 name: impl Into<String>,
186 x0: Option<&[f64]>,
187 settings: SolverSettings,
188 ) -> (VolScalarField, SolverPerformance) {
189 let b: Vec<f64> = self.source.iter().copied().collect();
190 let (x, perf) = conjugate_gradient(&self.ldu, &b, x0, &settings);
191
192 let boundary = self
193 .mesh
194 .patches
195 .iter()
196 .map(|p| PatchField::zero_gradient(p.size))
197 .collect();
198 let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
199 (field, perf)
200 }
201
202 /// Solve the system with GAMG (algebraic multigrid), cold-started from
203 /// `x = 0`.
204 ///
205 /// GAMG requires a symmetric SPD matrix (`upper == lower`) — the pressure
206 /// Poisson case. It is near mesh-independent (a handful of V-cycles), unlike
207 /// PCG whose iteration count grows with the mesh, so it is the better choice
208 /// for the pressure solve on fine grids. As with PCG, prefer
209 /// [`solve_gamg_with_guess`](Self::solve_gamg_with_guess) in a transient
210 /// loop to warm-start from the previous time step's field.
211 pub fn solve_gamg(
212 &self,
213 name: impl Into<String>,
214 settings: SolverSettings,
215 ) -> (VolScalarField, SolverPerformance) {
216 self.solve_gamg_inner(name, None, settings)
217 }
218
219 /// Solve with GAMG, **warm-started** from `initial` (typically the previous
220 /// time step's field). See [`gamg`](crate::ldu_matrix::solvers::gamg()) for the
221 /// multigrid algorithm and [`solve_gamg`](Self::solve_gamg) for the cold
222 /// variant.
223 pub fn solve_gamg_with_guess(
224 &self,
225 name: impl Into<String>,
226 initial: &VolScalarField,
227 settings: SolverSettings,
228 ) -> (VolScalarField, SolverPerformance) {
229 self.solve_gamg_inner(name, Some(initial.internal.as_slice()), settings)
230 }
231
232 fn solve_gamg_inner(
233 &self,
234 name: impl Into<String>,
235 x0: Option<&[f64]>,
236 settings: SolverSettings,
237 ) -> (VolScalarField, SolverPerformance) {
238 let b: Vec<f64> = self.source.iter().copied().collect();
239 let (x, perf) = gamg(&self.ldu, &b, x0, &settings);
240
241 let boundary = self
242 .mesh
243 .patches
244 .iter()
245 .map(|p| PatchField::zero_gradient(p.size))
246 .collect();
247 let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
248 (field, perf)
249 }
250
251 // ── Asymmetric Krylov solves (convection-bearing matrices) ─────────────
252
253 /// Solve the system with **preconditioned BiCGStab**, cold-started from
254 /// `x = 0`.
255 ///
256 /// This is the solver to use for any equation carrying a convection term —
257 /// scalar transport, energy, species — where the matrix is **asymmetric**
258 /// (`lower[f] != upper[f]`) because upwinding puts the flux on the donor side
259 /// only. PCG ([`solve_cg`](Self::solve_cg)) and GAMG
260 /// ([`solve_gamg`](Self::solve_gamg)) require symmetry and must not be used
261 /// on those matrices; the only prior alternative was
262 /// [`solve`](Self::solve)'s Gauss-Seidel, whose iteration count grows like
263 /// the condition number.
264 ///
265 /// With the default [`KrylovOptions`] this is ILU(0)-preconditioned
266 /// BiCGStab — the direct analogue of OpenFOAM's `PBiCGStab` with `DILU`.
267 ///
268 /// `settings.tolerance` is tested against the **relative 2-norm residual**
269 /// `||b − A·phi||₂ / ||b||₂` (the same measure `solve_cg` uses), not the
270 /// L1-scaled measure `solve` reports.
271 ///
272 /// In a transient loop prefer
273 /// [`solve_bicgstab_with_guess`](Self::solve_bicgstab_with_guess) to
274 /// warm-start from the previous time step.
275 pub fn solve_bicgstab(
276 &self,
277 name: impl Into<String>,
278 options: KrylovOptions,
279 settings: SolverSettings,
280 ) -> (VolScalarField, SolverPerformance) {
281 self.solve_krylov_inner(name, None, KrylovMethod::BiCGStab, options, settings)
282 }
283
284 /// Solve with preconditioned BiCGStab, **warm-started** from `initial`
285 /// (typically the previous time step's field).
286 ///
287 /// See [`solve_bicgstab`](Self::solve_bicgstab) for when to use BiCGStab at
288 /// all. Near steady state the starting residual is already tiny, so the
289 /// solve completes in a handful of iterations — often zero.
290 pub fn solve_bicgstab_with_guess(
291 &self,
292 name: impl Into<String>,
293 initial: &VolScalarField,
294 options: KrylovOptions,
295 settings: SolverSettings,
296 ) -> (VolScalarField, SolverPerformance) {
297 self.solve_krylov_inner(
298 name,
299 Some(initial.internal.as_slice()),
300 KrylovMethod::BiCGStab,
301 options,
302 settings,
303 )
304 }
305
306 /// Solve the system with **restarted, right-preconditioned GMRES(m)**,
307 /// cold-started from `x = 0`.
308 ///
309 /// GMRES minimises the residual over the Krylov subspace, so its residual
310 /// history is monotone and it cannot break down the way BiCGStab can — at
311 /// the cost of storing `options.restart` basis vectors
312 /// (`O(restart · n_cells)` memory). Reach for it when
313 /// [`solve_bicgstab`](Self::solve_bicgstab) reports `converged = false` on a
314 /// strongly nonnormal (high-Peclet, highly non-orthogonal) matrix.
315 ///
316 /// Like [`solve_bicgstab`](Self::solve_bicgstab), `settings.tolerance` is
317 /// tested against `||b − A·phi||₂ / ||b||₂`.
318 pub fn solve_gmres(
319 &self,
320 name: impl Into<String>,
321 options: KrylovOptions,
322 settings: SolverSettings,
323 ) -> (VolScalarField, SolverPerformance) {
324 self.solve_krylov_inner(name, None, KrylovMethod::Gmres, options, settings)
325 }
326
327 /// Solve with restarted GMRES(m), **warm-started** from `initial`.
328 ///
329 /// See [`solve_gmres`](Self::solve_gmres).
330 pub fn solve_gmres_with_guess(
331 &self,
332 name: impl Into<String>,
333 initial: &VolScalarField,
334 options: KrylovOptions,
335 settings: SolverSettings,
336 ) -> (VolScalarField, SolverPerformance) {
337 self.solve_krylov_inner(
338 name,
339 Some(initial.internal.as_slice()),
340 KrylovMethod::Gmres,
341 options,
342 settings,
343 )
344 }
345
346 /// Solve with the Krylov method named by `method`, optionally warm-started.
347 ///
348 /// The general form behind [`solve_bicgstab`](Self::solve_bicgstab) and
349 /// [`solve_gmres`](Self::solve_gmres); use it when the method is chosen at
350 /// run time (e.g. fall back from BiCGStab to GMRES after a breakdown)
351 /// instead of being fixed at the call site.
352 ///
353 /// `initial` is the starting iterate; `None` starts from zero.
354 pub fn solve_krylov(
355 &self,
356 name: impl Into<String>,
357 initial: Option<&VolScalarField>,
358 method: KrylovMethod,
359 options: KrylovOptions,
360 settings: SolverSettings,
361 ) -> (VolScalarField, SolverPerformance) {
362 self.solve_krylov_inner(
363 name,
364 initial.map(|f| f.internal.as_slice()),
365 method,
366 options,
367 settings,
368 )
369 }
370
371 fn solve_krylov_inner(
372 &self,
373 name: impl Into<String>,
374 x0: Option<&[f64]>,
375 method: KrylovMethod,
376 options: KrylovOptions,
377 settings: SolverSettings,
378 ) -> (VolScalarField, SolverPerformance) {
379 let b: Vec<f64> = self.source.iter().copied().collect();
380 let (x, perf) = krylov_solve(&self.ldu, &b, x0, method, options, &settings);
381
382 let boundary = self
383 .mesh
384 .patches
385 .iter()
386 .map(|p| PatchField::zero_gradient(p.size))
387 .collect();
388 let field = VolScalarField::new(name, self.mesh.clone(), Field::new(x), boundary);
389 (field, perf)
390 }
391
392 // ── Operator helpers (used by fvm:: in Layer 3) ────────────────────────
393
394 /// Add `coeff * I` to the diagonal (e.g. from a time derivative term).
395 pub fn add_to_diag(&mut self, coeff: &Field<f64>) {
396 for (d, &c) in self.ldu.diag.iter_mut().zip(coeff.iter()) {
397 *d += c;
398 }
399 }
400
401 /// Add `coeff[c]` to the source at cell `c`.
402 pub fn add_to_source(&mut self, term: &Field<f64>) {
403 self.source += term.clone();
404 }
405
406 /// Add upper/lower contributions from a face (used by fvm::laplacian etc.).
407 pub fn add_face_coeff(&mut self, face: usize, coeff: f64) {
408 let o = self.ldu.owner[face];
409 let n = self.ldu.neighbour[face];
410 // Laplacian: upper[f] = lower[f] = -coeff (off-diagonal negative)
411 self.ldu.upper[face] -= coeff;
412 self.ldu.lower[face] -= coeff;
413 self.ldu.diag[o] += coeff;
414 self.ldu.diag[n] += coeff;
415 }
416
417 /// Pin one cell to a reference value — fixes the singular pressure matrix
418 /// in a closed domain (no fixed-pressure BCs).
419 ///
420 /// Adds a large coefficient to `diag[cell]` and a matching term to
421 /// `source[cell]` so that the solution is forced toward `value`.
422 pub fn set_reference(&mut self, cell: usize, value: f64) {
423 self.ldu.diag[cell] += 1e30;
424 self.source[cell] += 1e30 * value;
425 }
426
427 /// Diagonal coefficient per cell: `A[c] = diag[c]`.
428 ///
429 /// Used in PISO: `rAU = 1 / pEqn.a_field()`.
430 pub fn a_field(&self) -> VolScalarField {
431 let boundary = self
432 .mesh
433 .patches
434 .iter()
435 .map(|p| PatchField::zero_gradient(p.size))
436 .collect();
437 VolScalarField::new(
438 "A",
439 self.mesh.clone(),
440 Field::new(self.ldu.diag.clone()),
441 boundary,
442 )
443 }
444
445 /// Off-diagonal residual: `H[c] = source[c] − Σ off-diag · x`.
446 ///
447 /// For a zero initial guess `x = 0` this is just `source`.
448 /// Used in pressure correction: `H = pEqn.h_field(p)`.
449 pub fn h_field(&self, x: &VolScalarField) -> VolScalarField {
450 let n = self.mesh.n_cells;
451 let mut h = vec![0.0_f64; n];
452 for c in 0..n {
453 h[c] = self.source[c];
454 }
455 // Iterate the full LDU face count (internal + appended cyclic couplings)
456 // so periodic-seam off-diagonals contribute to H.
457 for f in 0..self.ldu.n_internal_faces {
458 let o = self.ldu.owner[f];
459 let nb = self.ldu.neighbour[f];
460 h[o] -= x.internal[nb] * self.ldu.upper[f];
461 h[nb] -= x.internal[o] * self.ldu.lower[f];
462 }
463 let boundary = self
464 .mesh
465 .patches
466 .iter()
467 .map(|p| PatchField::zero_gradient(p.size))
468 .collect();
469 VolScalarField::new("H", self.mesh.clone(), Field::new(h), boundary)
470 }
471}
472
473// ── Arithmetic ────────────────────────────────────────────────────────────────
474
475impl Add for FvMatrix {
476 type Output = Self;
477 fn add(mut self, rhs: Self) -> Self {
478 for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
479 *a += b;
480 }
481 for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
482 *a += b;
483 }
484 for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
485 *a += b;
486 }
487 self.source += rhs.source;
488 self
489 }
490}
491
492impl Sub for FvMatrix {
493 type Output = Self;
494 fn sub(mut self, rhs: Self) -> Self {
495 for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
496 *a -= b;
497 }
498 for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
499 *a -= b;
500 }
501 for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
502 *a -= b;
503 }
504 self.source -= rhs.source;
505 self
506 }
507}
508
509impl Neg for FvMatrix {
510 type Output = Self;
511 fn neg(mut self) -> Self {
512 for x in self.ldu.diag.iter_mut() {
513 *x = -*x;
514 }
515 for x in self.ldu.lower.iter_mut() {
516 *x = -*x;
517 }
518 for x in self.ldu.upper.iter_mut() {
519 *x = -*x;
520 }
521 self.source = -self.source;
522 self
523 }
524}
525
526impl AddAssign for FvMatrix {
527 fn add_assign(&mut self, rhs: Self) {
528 for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
529 *a += b;
530 }
531 for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
532 *a += b;
533 }
534 for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
535 *a += b;
536 }
537 self.source += rhs.source;
538 }
539}
540
541impl SubAssign for FvMatrix {
542 fn sub_assign(&mut self, rhs: Self) {
543 for (a, b) in self.ldu.diag.iter_mut().zip(&rhs.ldu.diag) {
544 *a -= b;
545 }
546 for (a, b) in self.ldu.lower.iter_mut().zip(&rhs.ldu.lower) {
547 *a -= b;
548 }
549 for (a, b) in self.ldu.upper.iter_mut().zip(&rhs.ldu.upper) {
550 *a -= b;
551 }
552 self.source -= rhs.source;
553 }
554}
555
556// ── Tests ─────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
562 use crate::primitives::Vector3;
563 use approx::assert_relative_eq;
564
565 fn unit_mesh() -> Arc<FvMesh> {
566 Arc::new(
567 FvMeshBuilder::new()
568 .n_cells(2)
569 .n_internal_faces(1)
570 .owner(vec![0, 1, 0])
571 .neighbour(vec![1])
572 .patches(vec![
573 BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
574 BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
575 ])
576 .cell_volumes(vec![1.0, 1.0])
577 .cell_centres(vec![
578 Vector3::new(0.25, 0.0, 0.0),
579 Vector3::new(0.75, 0.0, 0.0),
580 ])
581 .face_area_vectors(vec![
582 Vector3::new(1.0, 0.0, 0.0),
583 Vector3::new(1.0, 0.0, 0.0),
584 Vector3::new(-1.0, 0.0, 0.0),
585 ])
586 .face_centres(vec![
587 Vector3::new(0.5, 0.0, 0.0),
588 Vector3::new(1.0, 0.0, 0.0),
589 Vector3::new(0.0, 0.0, 0.0),
590 ])
591 .build()
592 .unwrap(),
593 )
594 }
595
596 #[test]
597 fn solve_diagonal_system() {
598 let mesh = unit_mesh();
599 let mut mat = FvMatrix::new(mesh);
600
601 // Set A = [[3,0],[0,5]], b = [6, 10] → x = [2, 2]
602 mat.ldu.diag[0] = 3.0;
603 mat.ldu.diag[1] = 5.0;
604 mat.source = Field::new(vec![6.0, 10.0]);
605
606 let (phi, perf) = mat.solve("phi", SolverSettings::default());
607 assert!(
608 perf.converged,
609 "did not converge: residual = {}",
610 perf.final_residual
611 );
612 assert_relative_eq!(phi.internal[0], 2.0, epsilon = 1e-6);
613 assert_relative_eq!(phi.internal[1], 2.0, epsilon = 1e-6);
614 }
615
616 #[test]
617 fn add_face_coeff_symmetrises_diag() {
618 let mesh = unit_mesh();
619 let mut mat = FvMatrix::new(mesh);
620 mat.add_face_coeff(0, 1.0);
621 // owner = 0, neighbour = 1 → diag[0] += 1, diag[1] += 1
622 assert_relative_eq!(mat.ldu.diag[0], 1.0);
623 assert_relative_eq!(mat.ldu.diag[1], 1.0);
624 assert_relative_eq!(mat.ldu.upper[0], -1.0);
625 assert_relative_eq!(mat.ldu.lower[0], -1.0);
626 }
627}