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}