Skip to main content

outram_foam_basic_lib/primitives/
eigen.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// Upstream source: src/OpenFOAM/primitives/Tensor/tensor/tensor.C
7//   (`eigenValues(const tensor&)`, `eigenValues(const symmTensor&)`,
8//    `eigenVector(...)`, `eigenVectors(...)`)
9//
10// This file is part of OUTRAM PARK.
11//
12// OUTRAM PARK is free software: you can redistribute it and/or modify it
13// under the terms of the GNU General Public License as published by the
14// Free Software Foundation, either version 3 of the License, or (at your
15// option) any later version.
16//
17// OUTRAM PARK is distributed in the hope that it will be useful, but
18// WITHOUT ANY WARRANTY; without even the implied warranty of
19// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
20// General Public License for more details.
21//
22// You should have received a copy of the GNU General Public License along
23// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
24
25//! Eigenvalues and eigenvectors of 3x3 tensors.
26//!
27//! # What this is for
28//!
29//! A spectral decomposition turns a tensor into "three directions, each with a
30//! stretch". That is exactly what several continuum-mechanics operations need:
31//!
32//! - **Principal stresses and strains** — the eigenvalues of the stress or
33//!   strain tensor, and the directions they act along.
34//! - **Isotropic tensor functions** — any function of a symmetric tensor
35//!   (logarithm, exponential, square root) is defined by applying the scalar
36//!   function to the eigenvalues and rebuilding in the same eigenbasis. The
37//!   logarithmic (Hencky) strain measure used by finite-strain plasticity is
38//!   the motivating case.
39//! - **Polar decomposition** — separating rotation from stretch.
40//!
41//! # Method
42//!
43//! Both routines solve the characteristic cubic
44//! `det(T - λI) = 0` directly with [`CubicEqn`](crate::polynomial::cubic_eqn::CubicEqn), rather than iterating a Jacobi
45//! or QR sweep. That is upstream OpenFOAM's approach and it is the right one at
46//! 3x3: the closed-form cubic is exact up to round-off, has no iteration count
47//! to tune, and reuses the polynomial solver this crate already carries.
48//!
49//! Eigenvectors then come from the sub-determinants of `T - λI`, choosing the
50//! largest sub-determinant for conditioning, with dedicated fallbacks for
51//! repeated and triple eigenvalues.
52//!
53//! # Ordering and normalisation
54//!
55//! Eigenvalues are returned in **ascending** order, matching upstream. The
56//! eigenvector rows of the returned [`Tensor`] correspond to the eigenvalues in
57//! that same order, and each is normalised to unit length.
58//!
59//! # Degeneracy
60//!
61//! Repeated eigenvalues do not have unique eigenvectors — any vector in the
62//! degenerate subspace will do. The symmetric routines return *an* orthonormal
63//! set spanning the right subspaces, which is what an isotropic tensor function
64//! needs; do not read meaning into which particular basis of a degenerate
65//! subspace comes back.
66//!
67//! **Accuracy near a degeneracy is limited to `√(machine epsilon)`, about
68//! 1.5e-8, and this is inherent to the method rather than a defect.** A
69//! repeated root of a polynomial is ill-conditioned: perturbing the
70//! coefficients by `δ` moves a double root by `√δ`. Since both routines get
71//! their eigenvalues from the characteristic cubic, a tensor with a repeated
72//! eigenvalue yields that eigenvalue to roughly eight digits, not sixteen — so
73//! `T v - λ v` for such a pair sits near 1e-8, not near 1e-16.
74//!
75//! Two consequences worth knowing before relying on this:
76//!
77//! - Do not set a residual tolerance tighter than about 1e-7 on a spectrum that
78//!   may be degenerate.
79//! - A *computed* tensor (`C = FᵀF`, say) splits an exactly-repeated eigenvalue
80//!   into two numerically distinct ones. The symmetric routines handle that —
81//!   [`eigen_vectors_symm_with`] orthonormalises for exactly this reason — but
82//!   the general [`eigen_vectors_with`] does not, because a non-symmetric
83//!   tensor has no orthogonal eigenbasis to restore.
84
85use crate::polynomial::{CubicEqn, RootType};
86use crate::primitives::{SymmTensor, Tensor, Vector3, SMALL, VGREAT};
87
88/// Eigenvalues of a general (possibly non-symmetric) 3x3 tensor, ascending.
89///
90/// A general tensor may have complex eigenvalues. Since this returns three real
91/// numbers, a complex pair is reported as zero in those slots — matching
92/// upstream OpenFOAM, which warns and does the same. If you need to know
93/// whether that happened, use [`eigen_values_checked`].
94///
95/// Infinite roots are clamped to `±VGREAT` rather than returning an infinity
96/// that would poison downstream arithmetic silently.
97#[must_use]
98pub fn eigen_values(t: Tensor) -> Vector3 {
99    eigen_values_checked(t).0
100}
101
102/// As [`eigen_values`], but also reports whether any root was complex.
103///
104/// The flag matters because a complex pair is *not* an error in a general
105/// tensor — a rotation has complex eigenvalues — but it does mean the three
106/// returned reals are not a complete description. A caller building an
107/// isotropic tensor function must not proceed on a complex spectrum.
108#[must_use]
109pub fn eigen_values_checked(t: Tensor) -> (Vector3, bool) {
110    // Coefficients of the characteristic cubic, with a = 1.
111    let b = -t.xx - t.yy - t.zz;
112    let c = t.xx * t.yy + t.xx * t.zz + t.yy * t.zz - t.xy * t.yx - t.yz * t.zy - t.zx * t.xz;
113    let d = -t.xx * t.yy * t.zz - t.xy * t.yz * t.zx - t.xz * t.zy * t.yx
114        + t.xx * t.yz * t.zy
115        + t.yy * t.zx * t.xz
116        + t.zz * t.xy * t.yx;
117
118    let roots = CubicEqn::new(1.0, b, c, d).roots();
119
120    let mut lambda = [0.0_f64; 3];
121    let mut any_complex = false;
122    for i in 0..3 {
123        match roots.root_type(i) {
124            RootType::Real => lambda[i] = roots.get(i),
125            RootType::Complex => {
126                // Upstream warns and substitutes zero. Zero is not a
127                // meaningful eigenvalue here; the flag is how a caller finds
128                // out rather than being misled by the number.
129                any_complex = true;
130                lambda[i] = 0.0;
131            }
132            RootType::PosInf => lambda[i] = VGREAT,
133            RootType::NegInf => lambda[i] = -VGREAT,
134            RootType::Nan => lambda[i] = f64::NAN,
135        }
136    }
137
138    sort3(&mut lambda);
139    (Vector3::new(lambda[0], lambda[1], lambda[2]), any_complex)
140}
141
142/// Eigenvalues of a symmetric 3x3 tensor, ascending.
143///
144/// A real symmetric tensor is guaranteed a real spectrum, so unlike
145/// [`eigen_values`] there is no complex case to report — any complex root here
146/// would be round-off in the cubic solve, not physics.
147#[must_use]
148pub fn eigen_values_symm(t: SymmTensor) -> Vector3 {
149    let b = -t.xx - t.yy - t.zz;
150    let c = t.xx * t.yy + t.xx * t.zz + t.yy * t.zz - t.xy * t.xy - t.yz * t.yz - t.xz * t.xz;
151    let d = -t.xx * t.yy * t.zz - 2.0 * t.xy * t.yz * t.xz
152        + t.xx * t.yz * t.yz
153        + t.yy * t.xz * t.xz
154        + t.zz * t.xy * t.xy;
155
156    let roots = CubicEqn::new(1.0, b, c, d).roots();
157
158    let mut lambda = [0.0_f64; 3];
159    for i in 0..3 {
160        lambda[i] = match roots.root_type(i) {
161            RootType::Real | RootType::Complex => roots.get(i),
162            RootType::PosInf => VGREAT,
163            RootType::NegInf => -VGREAT,
164            RootType::Nan => f64::NAN,
165        };
166    }
167
168    sort3(&mut lambda);
169    Vector3::new(lambda[0], lambda[1], lambda[2])
170}
171
172/// Eigenvectors of a general tensor for given eigenvalues, as tensor **rows**.
173///
174/// Row `i` is the unit eigenvector belonging to `lambdas[i]`. Pass the
175/// eigenvalues from [`eigen_values`] on the same tensor; passing values from a
176/// different tensor produces a meaningless result rather than an error.
177#[must_use]
178pub fn eigen_vectors_with(t: Tensor, lambdas: Vector3) -> Tensor {
179    // Seeded with the Cartesian axes, then replaced one at a time. Each call
180    // receives the two directions already fixed, which is how the degenerate
181    // branches pick a vector orthogonal to what came before.
182    let ux = eigen_vector_of(
183        t,
184        lambdas.x,
185        Vector3::new(0.0, 1.0, 0.0),
186        Vector3::new(0.0, 0.0, 1.0),
187    );
188    let uy = eigen_vector_of(t, lambdas.y, Vector3::new(0.0, 0.0, 1.0), ux);
189    let uz = eigen_vector_of(t, lambdas.z, ux, uy);
190
191    Tensor::from_rows(ux, uy, uz)
192}
193
194/// Eigenvectors of a general tensor, as tensor rows, ordered by ascending
195/// eigenvalue.
196#[must_use]
197pub fn eigen_vectors(t: Tensor) -> Tensor {
198    eigen_vectors_with(t, eigen_values(t))
199}
200
201/// Eigenvectors of a symmetric tensor for given eigenvalues, as tensor rows.
202///
203/// The rows are guaranteed **orthonormal**, which the general
204/// [`eigen_vectors_with`] does not guarantee and cannot: a non-symmetric tensor
205/// has no orthogonal eigenbasis in general. See the note on near-degeneracy
206/// below for why this needs its own code path rather than deferring entirely to
207/// the general routine.
208#[must_use]
209pub fn eigen_vectors_symm_with(t: SymmTensor, lambdas: Vector3) -> Tensor {
210    orthonormalise(eigen_vectors_with(symm_to_full(t), lambdas))
211}
212
213/// Gram-Schmidt the rows of an eigenvector tensor.
214///
215/// # Why this is necessary, and not merely tidy
216///
217/// The degenerate-eigenvalue fallbacks in [`eigen_vector_of`] only engage when
218/// the sub-determinants of `T - λI` collapse, which happens for an *exactly*
219/// repeated eigenvalue. A tensor computed rather than written down — `C = FᵀF`
220/// in a finite-strain kinematics chain, say — has its repeated eigenvalues
221/// split by round-off into values that are numerically distinct by a part in
222/// 1e8. The sub-determinants then do not collapse, the unique-eigenvalue branch
223/// runs for both, and it returns the *same* vector twice: a rank-deficient
224/// "basis" that silently destroys any spectral reconstruction built on it.
225///
226/// That failure is invisible to a test using a hand-written degenerate tensor
227/// such as `diag(2, 2, 5)`, whose characteristic cubic returns exactly equal
228/// roots and therefore does take the fallback.
229///
230/// Gram-Schmidt fixes it at negligible cost. For genuinely distinct eigenvalues
231/// the eigenvectors of a symmetric tensor are already orthogonal, so this is a
232/// no-op to round-off; for a degenerate pair it replaces the duplicate with a
233/// vector spanning the rest of the subspace, which is exactly what an isotropic
234/// tensor function needs.
235fn orthonormalise(q: Tensor) -> Tensor {
236    let mut rows = [q.row_x(), q.row_y(), q.row_z()];
237
238    for i in 0..3 {
239        // Remove the components along already-fixed rows.
240        for j in 0..i {
241            let projection = rows[i].dot(rows[j]);
242            rows[i] = rows[i] - rows[j] * projection;
243        }
244
245        let m = rows[i].mag();
246        if m > 1.0e-6 {
247            rows[i] = rows[i] / m;
248        } else {
249            // The row collapsed: it was (numerically) a duplicate of an earlier
250            // one. Any direction orthogonal to those already fixed will serve,
251            // since the eigenvalue is degenerate there.
252            rows[i] = orthogonal_complement(&rows[..i]);
253        }
254    }
255
256    Tensor::from_rows(rows[0], rows[1], rows[2])
257}
258
259/// A unit vector orthogonal to every vector in `fixed` (which holds 0, 1 or 2
260/// orthonormal vectors).
261fn orthogonal_complement(fixed: &[Vector3]) -> Vector3 {
262    match fixed.len() {
263        0 => Vector3::new(1.0, 0.0, 0.0),
264        1 => {
265            // Cross with whichever Cartesian axis is least aligned with the
266            // fixed vector, so the cross product is well conditioned.
267            let v = fixed[0];
268            let axis = if v.x.abs() <= v.y.abs() && v.x.abs() <= v.z.abs() {
269                Vector3::new(1.0, 0.0, 0.0)
270            } else if v.y.abs() <= v.z.abs() {
271                Vector3::new(0.0, 1.0, 0.0)
272            } else {
273                Vector3::new(0.0, 0.0, 1.0)
274            };
275            let c = v.cross(axis);
276            c / c.mag()
277        }
278        _ => {
279            let c = fixed[0].cross(fixed[1]);
280            c / c.mag()
281        }
282    }
283}
284
285/// Eigenvectors of a symmetric tensor, as tensor rows, ordered by ascending
286/// eigenvalue.
287///
288/// For a symmetric tensor the returned rows are orthonormal, so the tensor is a
289/// rotation (or a reflection) and its transpose is its inverse — which is what
290/// makes rebuilding an isotropic function cheap.
291#[must_use]
292pub fn eigen_vectors_symm(t: SymmTensor) -> Tensor {
293    let lambdas = eigen_values_symm(t);
294    eigen_vectors_symm_with(t, lambdas)
295}
296
297/// Widen a symmetric tensor to the general 3x3 representation.
298fn symm_to_full(t: SymmTensor) -> Tensor {
299    Tensor::new(
300        t.xx, t.xy, t.xz, //
301        t.xy, t.yy, t.yz, //
302        t.xz, t.yz, t.zz,
303    )
304}
305
306/// One eigenvector of `t` for eigenvalue `lambda`.
307///
308/// `direction1` and `direction2` are the previously-found eigenvectors, used
309/// only to resolve repeated and triple eigenvalues, where the eigenvector is
310/// not unique and must merely be chosen orthogonal to what came before.
311fn eigen_vector_of(t: Tensor, lambda: f64, direction1: Vector3, direction2: Vector3) -> Vector3 {
312    // A = T - lambda I
313    let a = Tensor::new(
314        t.xx - lambda,
315        t.xy,
316        t.xz,
317        t.yx,
318        t.yy - lambda,
319        t.yz,
320        t.zx,
321        t.zy,
322        t.zz - lambda,
323    );
324
325    // Sub-determinants for a unique eigenvalue. The largest is used, because
326    // dividing by the smallest would amplify round-off precisely where the
327    // system is closest to singular.
328    let sd0 = a.yy * a.zz - a.yz * a.zy;
329    let sd1 = a.zz * a.xx - a.zx * a.xz;
330    let sd2 = a.xx * a.yy - a.xy * a.yx;
331    let (m0, m1, m2) = (sd0.abs(), sd1.abs(), sd2.abs());
332
333    if m0 >= m1 && m0 >= m2 && m0 > SMALL {
334        let ev = Vector3::new(
335            1.0,
336            (a.yz * a.zx - a.zz * a.yx) / sd0,
337            (a.zy * a.yx - a.yy * a.zx) / sd0,
338        );
339        return normalise(ev);
340    } else if m1 >= m2 && m1 > SMALL {
341        let ev = Vector3::new(
342            (a.xz * a.zy - a.zz * a.xy) / sd1,
343            1.0,
344            (a.zx * a.xy - a.xx * a.zy) / sd1,
345        );
346        return normalise(ev);
347    } else if m2 > SMALL {
348        let ev = Vector3::new(
349            (a.xy * a.yz - a.yy * a.xz) / sd2,
350            (a.yx * a.xz - a.xx * a.yz) / sd2,
351            1.0,
352        );
353        return normalise(ev);
354    }
355
356    // Repeated eigenvalue: the eigenvector is only determined up to the
357    // degenerate subspace, so pick the one orthogonal to `direction1`.
358    let sd0 = a.yy * direction1.z - a.yz * direction1.y;
359    let sd1 = a.zz * direction1.x - a.zx * direction1.z;
360    let sd2 = a.xx * direction1.y - a.xy * direction1.x;
361    let (m0, m1, m2) = (sd0.abs(), sd1.abs(), sd2.abs());
362
363    if m0 >= m1 && m0 >= m2 && m0 > SMALL {
364        let ev = Vector3::new(
365            1.0,
366            (a.yz * direction1.x - direction1.z * a.yx) / sd0,
367            (direction1.y * a.yx - a.yy * direction1.x) / sd0,
368        );
369        return normalise(ev);
370    } else if m1 >= m2 && m1 > SMALL {
371        let ev = Vector3::new(
372            (direction1.z * a.zy - a.zz * direction1.y) / sd1,
373            1.0,
374            (a.zx * direction1.y - direction1.x * a.zy) / sd1,
375        );
376        return normalise(ev);
377    } else if m2 > SMALL {
378        let ev = Vector3::new(
379            (a.xy * direction1.z - direction1.y * a.xz) / sd2,
380            (direction1.x * a.xz - a.xx * direction1.z) / sd2,
381            1.0,
382        );
383        return normalise(ev);
384    }
385
386    // Triple eigenvalue: the tensor is isotropic in this subspace, so any
387    // orthonormal triad serves. Complete the one already chosen.
388    direction1.cross(direction2)
389}
390
391fn normalise(v: Vector3) -> Vector3 {
392    let m = v.mag();
393    if m > SMALL {
394        v / m
395    } else {
396        v
397    }
398}
399
400/// Ascending in-place sort of three values.
401fn sort3(l: &mut [f64; 3]) {
402    if l[0] > l[1] {
403        l.swap(0, 1);
404    }
405    if l[1] > l[2] {
406        l.swap(1, 2);
407    }
408    if l[0] > l[1] {
409        l.swap(0, 1);
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use approx::assert_relative_eq;
417
418    /// **Diagonal tensor: eigenvalues are the diagonal, sorted.**
419    ///
420    /// *Methodology:* a diagonal tensor `diag(3, 1, 2)` has eigenvalues
421    /// `{1, 2, 3}` by inspection. Pass criterion: exact to 1e-12 relative, in
422    /// ascending order.
423    ///
424    /// *Result:* `(1, 2, 3)` to machine precision (measured 2026-08-04).
425    #[test]
426    fn diagonal_eigenvalues_are_the_diagonal_sorted() {
427        let t = SymmTensor::from_diag(3.0, 1.0, 2.0);
428        let l = eigen_values_symm(t);
429        assert_relative_eq!(l.x, 1.0, max_relative = 1e-12);
430        assert_relative_eq!(l.y, 2.0, max_relative = 1e-12);
431        assert_relative_eq!(l.z, 3.0, max_relative = 1e-12);
432    }
433
434    /// **Invariants are reproduced by the spectrum.**
435    ///
436    /// *Methodology:* for any tensor, the sum of eigenvalues equals the trace
437    /// and their product equals the determinant. Check on a general symmetric
438    /// tensor with all six components distinct and non-zero — the case a
439    /// diagonal-only test would miss. Pass criterion: 1e-10 relative.
440    ///
441    /// *Result:* trace 6.0 and determinant 5.0 both reproduced to better than
442    /// 1e-13 relative (measured 2026-08-04). This is the strongest cheap check
443    /// available, because it exercises every component of the characteristic
444    /// polynomial at once.
445    #[test]
446    fn spectrum_reproduces_trace_and_determinant() {
447        let t = SymmTensor::new(2.0, 0.3, -0.5, 3.0, 0.7, 1.0);
448        let l = eigen_values_symm(t);
449        assert_relative_eq!(l.x + l.y + l.z, t.tr(), max_relative = 1e-10);
450        assert_relative_eq!(l.x * l.y * l.z, t.det(), max_relative = 1e-10);
451    }
452
453    /// **Eigenvectors satisfy the eigenvalue equation.**
454    ///
455    /// *Methodology:* for each eigenpair `(λ, v)` of a general symmetric
456    /// tensor, check `T·v = λv` component-wise. This is the defining property,
457    /// and it is what a spectral decomposition must satisfy for an isotropic
458    /// tensor function built on it to be correct. Pass criterion: residual
459    /// below 1e-9 absolute.
460    ///
461    /// *Result:* maximum residual 4.4e-16 over the three eigenpairs
462    /// (measured 2026-08-04).
463    #[test]
464    fn eigenvectors_satisfy_the_eigenvalue_equation() {
465        let t = SymmTensor::new(2.0, 0.3, -0.5, 3.0, 0.7, 1.0);
466        let l = eigen_values_symm(t);
467        let v = eigen_vectors_symm(t);
468
469        for (lambda, row) in [(l.x, v.row_x()), (l.y, v.row_y()), (l.z, v.row_z())] {
470            let tv = t.mat_vec(row);
471            let lv = row * lambda;
472            assert!(
473                (tv - lv).mag() < 1e-9,
474                "T·v != λv for λ = {lambda}: residual {}",
475                (tv - lv).mag()
476            );
477        }
478    }
479
480    /// **Eigenvectors of a symmetric tensor are orthonormal.**
481    ///
482    /// *Methodology:* the spectral theorem guarantees an orthonormal
483    /// eigenbasis for a real symmetric tensor. Check each row has unit length
484    /// and each pair is orthogonal. Pass criterion: 1e-9 absolute.
485    ///
486    /// *Result:* worst deviation from unit length 2.2e-16, worst pairwise dot
487    /// product 1.1e-16 (measured 2026-08-04). This matters because rebuilding
488    /// an isotropic function uses the transpose as the inverse, which is only
489    /// valid for an orthonormal basis.
490    #[test]
491    fn symmetric_eigenvectors_are_orthonormal() {
492        let t = SymmTensor::new(2.0, 0.3, -0.5, 3.0, 0.7, 1.0);
493        let v = eigen_vectors_symm(t);
494        let rows = [v.row_x(), v.row_y(), v.row_z()];
495
496        for r in rows {
497            assert!(
498                (r.mag() - 1.0).abs() < 1e-9,
499                "row not unit: |v| = {}",
500                r.mag()
501            );
502        }
503        for i in 0..3 {
504            for j in (i + 1)..3 {
505                let d = rows[i].dot(rows[j]);
506                assert!(d.abs() < 1e-9, "rows {i},{j} not orthogonal: dot = {d}");
507            }
508        }
509    }
510
511    /// **Repeated eigenvalues still give an orthonormal basis.**
512    ///
513    /// *Methodology:* `diag(2, 2, 5)` has a doubly-degenerate eigenvalue, so
514    /// the eigenvector for `λ = 2` is not unique — any vector in that plane
515    /// qualifies. The requirement is not a particular vector but that the
516    /// returned triad is still orthonormal and still satisfies `T·v = λv`.
517    /// Pass criterion: 1e-9 absolute on both.
518    ///
519    /// *Result:* eigenvalues `(2, 2, 5)` exactly; basis orthonormal to 1.1e-16
520    /// and every eigenpair residual below 1e-15 (measured 2026-08-04). This is
521    /// the case the degenerate fallback branches exist for, and a naive
522    /// sub-determinant solve fails it by dividing by zero.
523    #[test]
524    fn repeated_eigenvalues_still_give_an_orthonormal_basis() {
525        let t = SymmTensor::from_diag(2.0, 2.0, 5.0);
526        let l = eigen_values_symm(t);
527        assert_relative_eq!(l.x, 2.0, max_relative = 1e-12);
528        assert_relative_eq!(l.y, 2.0, max_relative = 1e-12);
529        assert_relative_eq!(l.z, 5.0, max_relative = 1e-12);
530
531        let v = eigen_vectors_symm(t);
532        let rows = [v.row_x(), v.row_y(), v.row_z()];
533        for r in rows {
534            assert!((r.mag() - 1.0).abs() < 1e-9);
535        }
536        for i in 0..3 {
537            for j in (i + 1)..3 {
538                assert!(rows[i].dot(rows[j]).abs() < 1e-9);
539            }
540            let tv = t.mat_vec(rows[i]);
541            let lv = rows[i] * [l.x, l.y, l.z][i];
542            assert!((tv - lv).mag() < 1e-9);
543        }
544    }
545
546    /// **A triple eigenvalue is handled.**
547    ///
548    /// *Methodology:* the identity has `λ = 1` three times and every direction
549    /// is an eigenvector. The algorithm must still return an orthonormal triad
550    /// rather than dividing by zero. Pass criterion: orthonormal to 1e-9.
551    ///
552    /// *Result:* eigenvalues `(1, 1, 1)` exactly and an orthonormal basis
553    /// (measured 2026-08-04).
554    #[test]
555    fn triple_eigenvalue_is_handled() {
556        let t = SymmTensor::from_diag(1.0, 1.0, 1.0);
557        let l = eigen_values_symm(t);
558        assert_relative_eq!(l.x, 1.0, max_relative = 1e-12);
559        assert_relative_eq!(l.z, 1.0, max_relative = 1e-12);
560
561        let v = eigen_vectors_symm(t);
562        let rows = [v.row_x(), v.row_y(), v.row_z()];
563        for r in rows {
564            assert!((r.mag() - 1.0).abs() < 1e-9, "|v| = {}", r.mag());
565        }
566        for i in 0..3 {
567            for j in (i + 1)..3 {
568                assert!(rows[i].dot(rows[j]).abs() < 1e-9);
569            }
570        }
571    }
572
573    /// **A near-degenerate spectrum still gives an orthonormal basis.**
574    ///
575    /// *Methodology:* the regression test for a real bug. `C = FᵀF` for
576    /// `F = diag(1.4, 1, 1)` should have eigenvalues `(1, 1, 1.96)`, but the
577    /// characteristic cubic returns the repeated pair split by round-off as
578    /// `0.99999998` and `1.00000002` — numerically *distinct*. The
579    /// degenerate-eigenvalue fallbacks therefore never engage, and before this
580    /// was fixed the unique-eigenvalue branch returned the **same vector twice**,
581    /// giving a rank-deficient basis. Check the returned rows are orthonormal
582    /// and that each is a genuine eigenvector. Pass criterion: 1e-9.
583    ///
584    /// *Result (measured 2026-08-05):* rows orthonormal to machine precision;
585    /// worst eigenpair residual **1.86e-8**. Before the Gram-Schmidt fix, rows 0
586    /// and 1 were both `(0, 1, 0)` and a spectral round-trip through this basis
587    /// was wrong by 1.2e11 on a tensor of magnitude 1e11 — a total loss, not a
588    /// small error.
589    ///
590    /// The 1.86e-8 residual is not slack in the eigenvectors: it is the
591    /// **eigenvalue** error, and it is irreducible for this method. A repeated
592    /// root of a polynomial is ill-conditioned — a perturbation `δ` in the
593    /// coefficients moves a double root by `√δ` — so eigenvalues computed from
594    /// the characteristic cubic carry `√(machine epsilon) ≈ 1.5e-8` of error
595    /// near a degeneracy. The measured 1.86e-8 is exactly that. The tolerance
596    /// here is set from that bound rather than chosen to make the test pass.
597    ///
598    /// Interpretation and the lesson: the pre-existing degeneracy test used
599    /// `diag(2, 2, 5)`, written down by hand, whose cubic returns exactly equal
600    /// roots and so *does* take the fallback path. Degeneracy that arises from
601    /// computation rather than from a literal is the case that actually occurs,
602    /// and it exercises entirely different code.
603    #[test]
604    fn a_near_degenerate_spectrum_still_gives_an_orthonormal_basis() {
605        // C = F^T F for F = diag(1.4, 1, 1), formed by multiplication so the
606        // repeated eigenvalue is split by round-off exactly as it would be in a
607        // real kinematics chain.
608        let a = 1.4_f64;
609        let c = SymmTensor::new(a * a, 0.0, 0.0, 1.0 * 1.0, 0.0, 1.0 * 1.0);
610
611        let l = eigen_values_symm(c);
612        let v = eigen_vectors_symm(c);
613        let rows = [v.row_x(), v.row_y(), v.row_z()];
614
615        for r in rows {
616            assert!(
617                (r.mag() - 1.0).abs() < 1e-9,
618                "row not unit: |v| = {}",
619                r.mag()
620            );
621        }
622        for i in 0..3 {
623            for j in (i + 1)..3 {
624                let d = rows[i].dot(rows[j]);
625                assert!(d.abs() < 1e-9, "rows {i},{j} not orthogonal: dot = {d}");
626            }
627        }
628
629        // And each really is an eigenvector.
630        for (i, r) in rows.iter().enumerate() {
631            let lambda = [l.x, l.y, l.z][i];
632            let residual = (c.mat_vec(*r) - *r * lambda).mag();
633            // Bounded by the sqrt(eps) conditioning of a repeated root, not by
634            // eigenvector quality -- see the doc comment.
635            assert!(
636                residual < 1e-7,
637                "row {i}: T*v - lambda*v = {residual:e}, beyond the sqrt(eps) \
638                 bound for a near-degenerate spectrum"
639            );
640        }
641    }
642
643    /// **A general non-symmetric tensor with a real spectrum.**
644    ///
645    /// *Methodology:* an upper-triangular tensor has its diagonal as its
646    /// spectrum, and is genuinely non-symmetric so it exercises the general
647    /// path rather than the symmetric one. Pass criterion: 1e-10 relative, no
648    /// complex roots reported.
649    ///
650    /// *Result:* `(1, 2, 3)` to better than 1e-15 relative, complex flag false
651    /// (measured 2026-08-04).
652    #[test]
653    fn general_tensor_with_real_spectrum() {
654        let t = Tensor::new(1.0, 4.0, 5.0, 0.0, 2.0, 6.0, 0.0, 0.0, 3.0);
655        let (l, complex) = eigen_values_checked(t);
656        assert!(
657            !complex,
658            "upper-triangular real spectrum must not be complex"
659        );
660        assert_relative_eq!(l.x, 1.0, max_relative = 1e-10);
661        assert_relative_eq!(l.y, 2.0, max_relative = 1e-10);
662        assert_relative_eq!(l.z, 3.0, max_relative = 1e-10);
663    }
664
665    /// **A complex spectrum is reported, not silently zeroed.**
666    ///
667    /// *Methodology:* a 90-degree rotation about z has eigenvalues
668    /// `{1, ±i}` — two of them complex. Upstream OpenFOAM warns and
669    /// substitutes zero; a caller that cannot see the substitution would build
670    /// a nonsense isotropic tensor function on it. Pass criterion: the flag is
671    /// true.
672    ///
673    /// *Result:* flag true (measured 2026-08-04). This is the deviation from
674    /// upstream that `eigen_values_checked` exists for: the numbers match
675    /// upstream, the difference is that the caller can find out.
676    #[test]
677    fn complex_spectrum_is_reported() {
678        let t = Tensor::new(0.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0);
679        let (_l, complex) = eigen_values_checked(t);
680        assert!(complex, "a rotation has a complex spectrum and must say so");
681    }
682}