Skip to main content

qsm_core/inversion/
sti.rs

1//! STI: susceptibility tensor imaging
2//!
3//! COSMOS combines orientations to make a *scalar* susceptibility well-posed. STI keeps the
4//! orientations but changes the model: susceptibility becomes a symmetric rank-2 tensor, so
5//! tissue whose magnetic response depends on direction — white matter, whose myelin sheaths
6//! are diamagnetic along the fibre — is described rather than averaged away.
7//!
8//! Each orientation measures one projection of the tensor onto its own B0 direction:
9//!
10//! ```text
11//!     delta_t(k) = (1/3) H_t^T X(k) H_t  -  (H_t . k) (k^T X(k) H_t) / |k|^2
12//! ```
13//!
14//! With `X` symmetric there are six unknowns per voxel — `[X11, X12, X13, X22, X23, X33]` —
15//! so at least six orientations are needed, and in practice ten or more are acquired for
16//! conditioning. Setting `X = chi I` collapses the expression to `chi (1/3 - (H.k)^2/|k|^2)`,
17//! the ordinary dipole kernel, which is the sense in which STI contains scalar QSM.
18//!
19//! # Why this is a per-voxel solve rather than a global one
20//!
21//! The reference implementations take two routes that look very different. STI Suite solves a
22//! small least-squares problem at each k-point; Bilgic's toolbox builds a six-channel operator
23//! and runs LSQR over the whole volume. They solve the same system: the operator couples the
24//! six tensor channels to the N orientations **at each k-point independently**, with no
25//! spatial coupling anywhere, so the global system is block-diagonal in k and separates into
26//! `N_k` independent six-unknown problems. The per-k solve is therefore the exact answer, and
27//! the global LSQR an early-stopped approximation of it.
28//!
29//! This module takes the direct route: form the 6x6 normal equations at each k-point and
30//! solve by Cholesky. Exact, embarrassingly parallel, and with no iteration count to tune.
31//! `lambda` adds a ridge term for the ill-conditioned case.
32//!
33//! # Units and gauge
34//!
35//! Local (tissue) field in ppm in, tensor components in ppm out. As with the scalar dipole
36//! kernel there is no DC response, so the k = 0 term is null and every component is recovered
37//! zero-mean.
38//!
39//! # A difference from the scalar dipole kernel
40//!
41//! [`crate::kernels::dipole::dipole_kernel`] keeps the Nyquist planes of an even-length axis;
42//! this module drops them (see `is_nyquist_bin` below). The asymmetry is deliberate. Scalar
43//! inversion divides by the kernel pointwise, so whatever convention the kernel picks at an
44//! ambiguous bin cancels exactly between forward and inverse. STI instead solves a six-way
45//! coupled system at each bin, where the same ambiguity leaves the components inseparable, so
46//! the bins have to be excluded rather than carried through. An isotropic tensor therefore
47//! reproduces the scalar dipole kernel exactly on an odd grid, and everywhere except those
48//! planes on an even one.
49//!
50//! # Memory
51//!
52//! Every orientation's k-space is needed simultaneously, so this holds `N_orientations`
53//! complex volumes at once — for twelve orientations of a 164x205x205 volume, about 1.3 GB.
54//!
55//! # References
56//!
57//! Liu C. "Susceptibility tensor imaging." Magnetic Resonance in Medicine. 2010;63(6):1471-7.
58//! <https://doi.org/10.1002/mrm.22482>
59//!
60//! Li W, Liu C, Duong TQ, van Zijl PCM, Li X. "Susceptibility tensor imaging (STI) of the
61//! brain." NMR in Biomedicine. 2017;30(4):e3540. <https://doi.org/10.1002/nbm.3540>
62//!
63//! Reference implementations: `STI_Parfor.m` (STI Suite v3.0, Chunlei Liu) and `apply_STI.m`
64//! (COSMOS_STI_Toolbox, Berkin Bilgic). Ported from the published equations; the code was used
65//! only to resolve component ordering and sign conventions, which the two agree on.
66
67use crate::fft::{fft3d, fftfreq, ifft3d};
68use crate::utils::denoise::jacobi_eigh;
69use crate::Grid;
70use num_complex::Complex64;
71
72#[cfg(feature = "parallel")]
73use rayon::prelude::*;
74
75/// Number of independent components of a symmetric 3x3 tensor.
76pub const N_TENSOR: usize = 6;
77
78/// STI parameters.
79#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
80#[derive(Clone, Debug)]
81pub struct StiParams {
82    /// Ridge term added to the diagonal of the 6x6 normal equations at each k-point.
83    ///
84    /// Zero is the plain least-squares solution and is appropriate when there are enough
85    /// well-spread orientations to determine all six components. Raise it when orientations
86    /// are few, clustered, or near-coplanar, where some k-points leave the six unknowns
87    /// under-determined and the unregularized solve amplifies noise.
88    pub lambda: f64,
89}
90
91impl Default for StiParams {
92    fn default() -> Self {
93        Self { lambda: 0.0 }
94    }
95}
96
97/// A symmetric susceptibility tensor field, in ppm.
98///
99/// Components are stored in the order `[X11, X12, X13, X22, X23, X33]`, each a full volume
100/// laid out like every other volume in the crate (`i + j*nx + k*nx*ny`).
101#[derive(Clone, Debug)]
102pub struct SusceptibilityTensor {
103    pub components: [Vec<f64>; N_TENSOR],
104}
105
106impl SusceptibilityTensor {
107    /// The full symmetric 3x3 matrix at voxel `i`, row-major.
108    pub fn matrix_at(&self, i: usize) -> [f64; 9] {
109        let [c11, c12, c13, c22, c23, c33] = [
110            self.components[0][i],
111            self.components[1][i],
112            self.components[2][i],
113            self.components[3][i],
114            self.components[4][i],
115            self.components[5][i],
116        ];
117        [c11, c12, c13, c12, c22, c23, c13, c23, c33]
118    }
119}
120
121/// Rotationally invariant maps derived from the tensor's eigen-decomposition.
122#[derive(Clone, Debug)]
123pub struct TensorMaps {
124    /// Eigenvalues per voxel in ASCENDING order: `[lambda_min, lambda_mid, lambda_max]`.
125    pub eigenvalues: [Vec<f64>; 3],
126    /// Mean magnetic susceptibility: the mean of the three eigenvalues. This is the
127    /// orientation-independent counterpart of a scalar QSM map.
128    pub mms: Vec<f64>,
129    /// Magnetic susceptibility anisotropy: `lambda_max - (lambda_min + lambda_mid)/2`.
130    pub msa: Vec<f64>,
131    /// Principal eigenvector, the eigenvector of the LARGEST eigenvalue, as three component
132    /// volumes `[x, y, z]` in voxel coordinates.
133    ///
134    /// Paired with the `msa` convention above, so the two describe the same axis. Note that
135    /// which end of the spectrum is called "principal" differs between STI papers; this
136    /// module is internally consistent and follows the reference implementations, but compare
137    /// carefully before matching numbers against another toolbox.
138    pub pev: [Vec<f64>; 3],
139}
140
141/// Build the row of the STI system for one orientation at one k-point.
142///
143/// This is the forward model written out against `[X11, X12, X13, X22, X23, X33]`. Both
144/// reference implementations produce exactly this row.
145#[inline]
146fn sti_row(h: (f64, f64, f64), k: (f64, f64, f64), k2: f64) -> [f64; N_TENSOR] {
147    let (h1, h2, h3) = h;
148    let (kx, ky, kz) = k;
149    // (H . k) / |k|^2 — the longitudinal projection that turns the tensor contraction into
150    // the familiar dipole falloff.
151    let hk = (h1 * kx + h2 * ky + h3 * kz) / k2;
152    [
153        h1 * h1 / 3.0 - h1 * kx * hk,
154        2.0 * h1 * h2 / 3.0 - (h1 * ky + h2 * kx) * hk,
155        2.0 * h1 * h3 / 3.0 - (h1 * kz + h3 * kx) * hk,
156        h2 * h2 / 3.0 - h2 * ky * hk,
157        2.0 * h2 * h3 / 3.0 - (h2 * kz + h3 * ky) * hk,
158        h3 * h3 / 3.0 - h3 * kz * hk,
159    ]
160}
161
162/// Whether a bin sits on a Nyquist plane, which only exists on even-length axes.
163///
164/// These bins are excluded from the reconstruction, for the same reason DC is: the model does
165/// not determine them.
166///
167/// At a Nyquist bin `+f` and `-f` alias onto the same sample, so `fftfreq` must pick one (it
168/// reports the negative) and the stored frequency no longer flips sign under the Hermitian
169/// pairing `k -> -k`. That matters because the STI row is *not* even in `k` component-wise —
170/// `(H.k)/|k|^2` is linear in `k`, so flipping one axis while leaving the others alone changes
171/// the row. Away from Nyquist this never bites, since the Hermitian partner of a bin negates
172/// every axis and the row is even under a full negation. At a Nyquist bin the partner flips
173/// only the other axes, evenness fails, and the forward projection stops being Hermitian —
174/// after which the imaginary part discarded by the inverse FFT is real signal.
175///
176/// Restoring evenness by averaging the row over both signs does make the projection Hermitian,
177/// but the terms linear in that axis then cancel, leaving the six tensor components no longer
178/// separable at those bins — the system becomes rank-deficient rather than merely awkward. The
179/// information genuinely is not there, so these bins are zeroed rather than guessed at. They
180/// are the outermost sampled frequencies, where a tissue field map carries almost no energy;
181/// on a 164x205x205 volume exactly one axis is even, so this is one plane in 164.
182#[inline]
183fn is_nyquist_bin(i: usize, j: usize, k: usize, dims: (usize, usize, usize)) -> bool {
184    let (nx, ny, nz) = dims;
185    (nx % 2 == 0 && i == nx / 2)
186        || (ny % 2 == 0 && j == ny / 2)
187        || (nz % 2 == 0 && k == nz / 2)
188}
189
190/// Cholesky factorization of a 6x6 symmetric positive-definite matrix, in place.
191/// Returns `false` if the matrix is not positive definite.
192fn cholesky6(a: &mut [f64; 36]) -> bool {
193    for i in 0..N_TENSOR {
194        for j in 0..=i {
195            let mut sum = a[i * N_TENSOR + j];
196            for k in 0..j {
197                sum -= a[i * N_TENSOR + k] * a[j * N_TENSOR + k];
198            }
199            if i == j {
200                if sum <= 0.0 {
201                    return false;
202                }
203                a[i * N_TENSOR + i] = sum.sqrt();
204            } else {
205                a[i * N_TENSOR + j] = sum / a[j * N_TENSOR + j];
206            }
207        }
208    }
209    true
210}
211
212/// Solve `L L^T x = b` in place given the lower factor from [`cholesky6`].
213fn cholesky6_solve(l: &[f64; 36], b: &mut [f64; N_TENSOR]) {
214    for i in 0..N_TENSOR {
215        let mut sum = b[i];
216        for k in 0..i {
217            sum -= l[i * N_TENSOR + k] * b[k];
218        }
219        b[i] = sum / l[i * N_TENSOR + i];
220    }
221    for i in (0..N_TENSOR).rev() {
222        let mut sum = b[i];
223        for k in (i + 1)..N_TENSOR {
224            sum -= l[k * N_TENSOR + i] * b[k];
225        }
226        b[i] = sum / l[i * N_TENSOR + i];
227    }
228}
229
230/// Reconstruct the susceptibility tensor from multi-orientation local field maps.
231///
232/// # Arguments
233/// * `local_fields` - One local (tissue) field map per orientation, ppm, all on the same grid.
234/// * `bdirs` - B0 direction for each orientation, in the voxel frame of that shared grid.
235///   Derive with [`crate::geometry::b0_direction_from_affine`].
236/// * `mask` - Binary mask (1 = inside ROI); applied to every output component.
237/// * `grid` - The shared volume grid.
238/// * `params` - See [`StiParams`].
239///
240/// # Panics
241/// If fewer than [`N_TENSOR`] orientations are given (the tensor is then under-determined at
242/// every k-point), if field and direction counts differ, or if any field does not match `grid`.
243pub fn sti<F: AsRef<[f64]>>(
244    local_fields: &[F],
245    bdirs: &[(f64, f64, f64)],
246    mask: &[u8],
247    grid: &Grid,
248    params: &StiParams,
249) -> SusceptibilityTensor {
250    let (nx, ny, nz) = grid.dims;
251    let n = grid.n_total();
252    let n_dir = local_fields.len();
253
254    assert!(
255        n_dir >= N_TENSOR,
256        "sti: needs at least {} orientations to determine {} tensor components, got {}",
257        N_TENSOR, N_TENSOR, n_dir
258    );
259    assert_eq!(
260        n_dir, bdirs.len(),
261        "sti: got {} field maps but {} B0 directions", n_dir, bdirs.len()
262    );
263    for (t, f) in local_fields.iter().enumerate() {
264        assert_eq!(
265            f.as_ref().len(), n,
266            "sti: orientation {} has {} voxels, expected {}", t, f.as_ref().len(), n
267        );
268    }
269
270    // Normalize the directions once; the forward model assumes unit vectors.
271    let h: Vec<(f64, f64, f64)> = bdirs
272        .iter()
273        .map(|&(x, y, z)| {
274            let m = (x * x + y * y + z * z).sqrt();
275            assert!(m > 0.0, "sti: B0 direction must be non-zero");
276            (x / m, y / m, z / m)
277        })
278        .collect();
279
280    // k-space of every orientation, all needed at once.
281    let spectra: Vec<Vec<Complex64>> = local_fields
282        .iter()
283        .map(|f| {
284            let mut c: Vec<Complex64> =
285                f.as_ref().iter().map(|&v| Complex64::new(v, 0.0)).collect();
286            fft3d(&mut c, nx, ny, nz);
287            c
288        })
289        .collect();
290
291    let (kx, ky, kz) = (
292        fftfreq(nx, grid.voxel_size.0),
293        fftfreq(ny, grid.voxel_size.1),
294        fftfreq(nz, grid.voxel_size.2),
295    );
296
297    // Solve each k-point independently into an interleaved 6-per-voxel buffer.
298    let mut out = vec![Complex64::new(0.0, 0.0); n * N_TENSOR];
299    let lambda = params.lambda;
300
301    let solve_chunk = |(idx, slot): (usize, &mut [Complex64])| {
302        let i = idx % nx;
303        let j = (idx / nx) % ny;
304        let k = idx / (nx * ny);
305        let kvec = (kx[i], ky[j], kz[k]);
306        let k2 = kvec.0 * kvec.0 + kvec.1 * kvec.1 + kvec.2 * kvec.2;
307
308        // No DC response (as for the scalar dipole kernel), and no determinate solution on a
309        // Nyquist plane — see [`is_nyquist_bin`].
310        if k2 <= 1e-20 || is_nyquist_bin(i, j, k, grid.dims) {
311            for s in slot.iter_mut() {
312                *s = Complex64::new(0.0, 0.0);
313            }
314            return;
315        }
316
317        // Normal equations: A^T A and A^T b, accumulated over orientations.
318        let mut ata = [0.0f64; 36];
319        let mut atb = [Complex64::new(0.0, 0.0); N_TENSOR];
320        for (t, &hv) in h.iter().enumerate() {
321            let row = sti_row(hv, kvec, k2);
322            let b = spectra[t][idx];
323            for a in 0..N_TENSOR {
324                atb[a] += b * row[a];
325                for c in 0..=a {
326                    ata[a * N_TENSOR + c] += row[a] * row[c];
327                }
328            }
329        }
330        for a in 0..N_TENSOR {
331            ata[a * N_TENSOR + a] += lambda;
332        }
333
334        if !cholesky6(&mut ata) {
335            // Under-determined at this k-point and no ridge to rescue it: contribute nothing
336            // rather than amplifying noise through a singular system.
337            for s in slot.iter_mut() {
338                *s = Complex64::new(0.0, 0.0);
339            }
340            return;
341        }
342
343        // The system matrix is real, so the real and imaginary halves share one factorization.
344        let mut re = [0.0f64; N_TENSOR];
345        let mut im = [0.0f64; N_TENSOR];
346        for a in 0..N_TENSOR {
347            re[a] = atb[a].re;
348            im[a] = atb[a].im;
349        }
350        cholesky6_solve(&ata, &mut re);
351        cholesky6_solve(&ata, &mut im);
352        for a in 0..N_TENSOR {
353            slot[a] = Complex64::new(re[a], im[a]);
354        }
355    };
356
357    #[cfg(feature = "parallel")]
358    out.par_chunks_mut(N_TENSOR).enumerate().for_each(solve_chunk);
359    #[cfg(not(feature = "parallel"))]
360    out.chunks_mut(N_TENSOR).enumerate().for_each(solve_chunk);
361
362    // De-interleave, transform back, mask.
363    let mut components: [Vec<f64>; N_TENSOR] = Default::default();
364    for (a, comp) in components.iter_mut().enumerate() {
365        let mut channel: Vec<Complex64> = (0..n).map(|i| out[i * N_TENSOR + a]).collect();
366        ifft3d(&mut channel, nx, ny, nz);
367        *comp = channel
368            .iter()
369            .zip(mask.iter())
370            .map(|(c, &m)| if m == 0 { 0.0 } else { c.re })
371            .collect();
372    }
373
374    SusceptibilityTensor { components }
375}
376
377/// Forward STI model: the local field each orientation would measure from `tensor`.
378///
379/// The inverse of [`sti`], useful for simulation and for checking a reconstruction against
380/// the data it came from.
381pub fn sti_forward(
382    tensor: &SusceptibilityTensor,
383    bdirs: &[(f64, f64, f64)],
384    grid: &Grid,
385) -> Vec<Vec<f64>> {
386    let (nx, ny, nz) = grid.dims;
387    let n = grid.n_total();
388
389    let spectra: Vec<Vec<Complex64>> = tensor
390        .components
391        .iter()
392        .map(|c| {
393            let mut z: Vec<Complex64> = c.iter().map(|&v| Complex64::new(v, 0.0)).collect();
394            fft3d(&mut z, nx, ny, nz);
395            z
396        })
397        .collect();
398
399    let (kxs, kys, kzs) = (
400        fftfreq(nx, grid.voxel_size.0),
401        fftfreq(ny, grid.voxel_size.1),
402        fftfreq(nz, grid.voxel_size.2),
403    );
404
405    bdirs
406        .iter()
407        .map(|&(x, y, z)| {
408            let m = (x * x + y * y + z * z).sqrt();
409            let hv = (x / m, y / m, z / m);
410            let mut field = vec![Complex64::new(0.0, 0.0); n];
411            for idx in 0..n {
412                let i = idx % nx;
413                let j = (idx / nx) % ny;
414                let k = idx / (nx * ny);
415                let kvec = (kxs[i], kys[j], kzs[k]);
416                let k2 = kvec.0 * kvec.0 + kvec.1 * kvec.1 + kvec.2 * kvec.2;
417                if k2 <= 1e-20 || is_nyquist_bin(i, j, k, grid.dims) {
418                    continue;
419                }
420                let row = sti_row(hv, kvec, k2);
421                let mut acc = Complex64::new(0.0, 0.0);
422                for a in 0..N_TENSOR {
423                    acc += spectra[a][idx] * row[a];
424                }
425                field[idx] = acc;
426            }
427            ifft3d(&mut field, nx, ny, nz);
428            field.iter().map(|c| c.re).collect()
429        })
430        .collect()
431}
432
433/// Eigen-decompose the tensor and derive MMS, MSA and the principal eigenvector.
434///
435/// Only voxels inside `mask` are decomposed; everything else is left zero.
436pub fn tensor_maps(tensor: &SusceptibilityTensor, mask: &[u8]) -> TensorMaps {
437    let n = tensor.components[0].len();
438    let mut eigenvalues: [Vec<f64>; 3] = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
439    let mut pev: [Vec<f64>; 3] = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
440    let mut mms = vec![0.0; n];
441    let mut msa = vec![0.0; n];
442
443    for i in 0..n {
444        if mask[i] == 0 {
445            continue;
446        }
447        // Ascending eigenvalues; eigenvectors column-wise, so column 2 is the largest.
448        let (d, v) = jacobi_eigh(&tensor.matrix_at(i), 3);
449        for a in 0..3 {
450            eigenvalues[a][i] = d[a];
451            pev[a][i] = v[a * 3 + 2];
452        }
453        mms[i] = (d[0] + d[1] + d[2]) / 3.0;
454        msa[i] = d[2] - (d[0] + d[1]) / 2.0;
455    }
456
457    TensorMaps { eigenvalues, mms, msa, pev }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::kernels::dipole::dipole_kernel;
464    use crate::fft::apply_real_kernel;
465
466    /// `n` B0 directions spread over the hemisphere on a Fibonacci spiral, varying both polar
467    /// angle and azimuth so the six tensor components are actually separable.
468    fn hemisphere_dirs(n: usize) -> Vec<(f64, f64, f64)> {
469        let golden = std::f64::consts::PI * (3.0 - 5.0f64.sqrt());
470        (0..n)
471            .map(|t| {
472                let z = 1.0 - (t as f64) / (n as f64);
473                let r = (1.0 - z * z).max(0.0).sqrt();
474                let az = golden * t as f64;
475                (r * az.cos(), r * az.sin(), z)
476            })
477            .collect()
478    }
479
480    /// A smooth zero-mean volume, scaled — DC is unrecoverable so the truth must have none.
481    fn smooth_volume(grid: &Grid, scale: f64, phase: f64) -> Vec<f64> {
482        let (nx, ny, nz) = grid.dims;
483        let mut v = vec![0.0f64; grid.n_total()];
484        for k in 0..nz {
485            for j in 0..ny {
486                for i in 0..nx {
487                    let (x, y, z) = (i as f64, j as f64, k as f64);
488                    v[i + j * nx + k * nx * ny] =
489                        scale * ((0.6 * x + phase).sin() * (0.4 * y).cos() + 0.5 * (0.35 * z + phase).sin());
490                }
491            }
492        }
493        let mean = v.iter().sum::<f64>() / v.len() as f64;
494        for x in v.iter_mut() {
495            *x -= mean;
496        }
497        v
498    }
499
500    fn random_tensor(grid: &Grid) -> SusceptibilityTensor {
501        SusceptibilityTensor {
502            components: [
503                smooth_volume(grid, 0.10, 0.0),
504                smooth_volume(grid, 0.03, 1.1),
505                smooth_volume(grid, 0.02, 2.2),
506                smooth_volume(grid, 0.09, 3.3),
507                smooth_volume(grid, 0.025, 4.4),
508                smooth_volume(grid, 0.11, 5.5),
509            ],
510        }
511    }
512
513    fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
514        a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
515    }
516
517    /// The forward model's keystone check: an isotropic tensor `chi I` must reproduce the
518    /// ordinary scalar dipole kernel exactly. This validates `sti_row` against entirely
519    /// independent code (`dipole_kernel`), so a sign or ordering slip cannot hide.
520    ///
521    /// Run on an odd grid, where neither operator excludes anything. On an even grid the two
522    /// deliberately differ on the Nyquist planes, which `sti` drops and `dipole_kernel` keeps —
523    /// see [`is_nyquist_bin`] and the note in the module docs.
524    #[test]
525    fn isotropic_tensor_reduces_to_the_dipole_kernel() {
526        let grid = Grid::new(13, 13, 13, 1.0, 1.0, 1.0);
527        let chi = smooth_volume(&grid, 0.1, 0.0);
528        let zero = vec![0.0; grid.n_total()];
529        let iso = SusceptibilityTensor {
530            components: [chi.clone(), zero.clone(), zero.clone(), chi.clone(), zero, chi.clone()],
531        };
532
533        for bdir in [(0.0, 0.0, 1.0), (0.6, 0.0, 0.8), (0.3, -0.5, 0.81)] {
534            let via_sti = &sti_forward(&iso, &[bdir], &grid)[0];
535            let (nx, ny, nz) = grid.dims;
536            let via_dipole = apply_real_kernel(&chi, &dipole_kernel(&grid, bdir), nx, ny, nz);
537            let err = max_abs_diff(via_sti, &via_dipole);
538            let scale = via_dipole.iter().fold(0.0f64, |m, v| m.max(v.abs()));
539            assert!(
540                err / scale < 1e-12,
541                "isotropic STI forward disagrees with the dipole kernel for {:?}: rel {}",
542                bdir, err / scale
543            );
544        }
545    }
546
547    /// Forward-project a known tensor at enough orientations, invert, recover it.
548    #[test]
549    fn recovers_tensor_from_simulated_orientations() {
550        // Odd dimensions, so there is no Nyquist plane and every sampled frequency is
551        // determinate — this isolates the solver from the exclusion in `is_nyquist_bin`.
552        let grid = Grid::new(13, 13, 13, 1.0, 1.0, 1.0);
553        let mask = vec![1u8; grid.n_total()];
554        let truth = random_tensor(&grid);
555        let dirs = hemisphere_dirs(16);
556
557        let fields = sti_forward(&truth, &dirs, &grid);
558        let rec = sti(&fields, &dirs, &mask, &grid, &StiParams::default());
559
560        for a in 0..N_TENSOR {
561            let scale = truth.components[a].iter().fold(0.0f64, |m, v| m.max(v.abs()));
562            let rel = max_abs_diff(&rec.components[a], &truth.components[a]) / scale;
563            assert!(rel < 1e-12, "component {} not recovered: rel err {}", a, rel);
564        }
565    }
566
567    #[test]
568    fn recovers_tensor_with_anisotropic_voxels() {
569        let grid = Grid::new(13, 13, 11, 0.9, 0.9, 2.0);
570        let mask = vec![1u8; grid.n_total()];
571        let truth = random_tensor(&grid);
572        let dirs = hemisphere_dirs(16);
573
574        let fields = sti_forward(&truth, &dirs, &grid);
575        let rec = sti(&fields, &dirs, &mask, &grid, &StiParams::default());
576
577        for a in 0..N_TENSOR {
578            let scale = truth.components[a].iter().fold(0.0f64, |m, v| m.max(v.abs()));
579            assert!(max_abs_diff(&rec.components[a], &truth.components[a]) / scale < 1e-12);
580        }
581    }
582
583    /// An isotropic tensor must come back with zero anisotropy and MMS equal to the scalar.
584    #[test]
585    fn isotropic_tensor_has_no_anisotropy() {
586        let grid = Grid::new(10, 10, 10, 1.0, 1.0, 1.0);
587        let n = grid.n_total();
588        let mask = vec![1u8; n];
589        let chi = smooth_volume(&grid, 0.1, 0.0);
590        let zero = vec![0.0; n];
591        let iso = SusceptibilityTensor {
592            components: [chi.clone(), zero.clone(), zero.clone(), chi.clone(), zero, chi.clone()],
593        };
594
595        let maps = tensor_maps(&iso, &mask);
596        let scale = chi.iter().fold(0.0f64, |m, v| m.max(v.abs()));
597        assert!(max_abs_diff(&maps.mms, &chi) / scale < 1e-12, "MMS should equal the scalar");
598        assert!(
599            maps.msa.iter().all(|v| v.abs() / scale < 1e-12),
600            "an isotropic tensor must have zero MSA"
601        );
602    }
603
604    /// A tensor built with a known principal axis must report that axis, and an MSA equal to
605    /// the anisotropy it was built with.
606    #[test]
607    fn recovers_known_anisotropy_and_principal_axis() {
608        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
609        let n = grid.n_total();
610        let mask = vec![1u8; n];
611
612        // Diagonal tensor, largest along y: eigenvalues (0.02, 0.05, 0.09) with the maximum
613        // on the y axis, so MSA = 0.09 - (0.02 + 0.05)/2 = 0.055.
614        let t = SusceptibilityTensor {
615            components: [
616                vec![0.02; n], vec![0.0; n], vec![0.0; n],
617                vec![0.09; n], vec![0.0; n], vec![0.05; n],
618            ],
619        };
620        let maps = tensor_maps(&t, &mask);
621
622        for i in 0..n {
623            assert!((maps.mms[i] - (0.02 + 0.09 + 0.05) / 3.0).abs() < 1e-12);
624            assert!((maps.msa[i] - 0.055).abs() < 1e-12, "MSA {} != 0.055", maps.msa[i]);
625            // Principal axis is +/- y.
626            assert!(maps.pev[0][i].abs() < 1e-9 && maps.pev[2][i].abs() < 1e-9);
627            assert!((maps.pev[1][i].abs() - 1.0).abs() < 1e-9);
628        }
629    }
630
631    /// Eigenvalues must come back ascending, which the MSA and PEV conventions rely on.
632    #[test]
633    fn eigenvalues_are_ascending() {
634        let grid = Grid::new(6, 6, 6, 1.0, 1.0, 1.0);
635        let mask = vec![1u8; grid.n_total()];
636        let maps = tensor_maps(&random_tensor(&grid), &mask);
637        for i in 0..grid.n_total() {
638            assert!(maps.eigenvalues[0][i] <= maps.eigenvalues[1][i]);
639            assert!(maps.eigenvalues[1][i] <= maps.eigenvalues[2][i]);
640        }
641    }
642
643    #[test]
644    fn masked_voxels_are_zero() {
645        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
646        let n = grid.n_total();
647        let mut mask = vec![1u8; n];
648        mask[0] = 0;
649        mask[3] = 0;
650        let truth = random_tensor(&grid);
651        let dirs = hemisphere_dirs(12);
652        let fields = sti_forward(&truth, &dirs, &grid);
653
654        let rec = sti(&fields, &dirs, &mask, &grid, &StiParams::default());
655        for a in 0..N_TENSOR {
656            assert_eq!(rec.components[a][0], 0.0);
657            assert_eq!(rec.components[a][3], 0.0);
658        }
659    }
660
661    /// The ridge term must actually damp, and must not be silently ignored.
662    #[test]
663    fn lambda_shrinks_the_solution() {
664        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
665        let mask = vec![1u8; grid.n_total()];
666        let truth = random_tensor(&grid);
667        let dirs = hemisphere_dirs(8);
668        let fields = sti_forward(&truth, &dirs, &grid);
669
670        let energy = |t: &SusceptibilityTensor| -> f64 {
671            t.components.iter().flat_map(|c| c.iter()).map(|v| v * v).sum()
672        };
673        let plain = sti(&fields, &dirs, &mask, &grid, &StiParams::default());
674        let reg = sti(&fields, &dirs, &mask, &grid, &StiParams { lambda: 0.05 });
675        assert!(energy(&reg) < energy(&plain), "lambda did not damp the solution");
676    }
677
678    #[test]
679    fn cholesky_solves_a_known_system() {
680        // A = L L^T for a simple lower-triangular L, so the answer is known independently.
681        let mut a = [0.0f64; 36];
682        for i in 0..6 {
683            for j in 0..6 {
684                a[i * 6 + j] = if i == j { (i as f64) + 2.0 } else { 0.5 };
685            }
686        }
687        let x_true = [1.0, -2.0, 3.0, 0.5, -1.5, 2.5];
688        let mut b = [0.0f64; 6];
689        for i in 0..6 {
690            for j in 0..6 {
691                b[i] += a[i * 6 + j] * x_true[j];
692            }
693        }
694        let mut l = a;
695        assert!(cholesky6(&mut l), "matrix should be positive definite");
696        cholesky6_solve(&l, &mut b);
697        for i in 0..6 {
698            assert!((b[i] - x_true[i]).abs() < 1e-10, "x[{}] = {} != {}", i, b[i], x_true[i]);
699        }
700    }
701
702    #[test]
703    fn matrix_at_is_symmetric() {
704        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
705        let t = random_tensor(&grid);
706        let m = t.matrix_at(7);
707        assert_eq!(m[1], m[3]);
708        assert_eq!(m[2], m[6]);
709        assert_eq!(m[5], m[7]);
710    }
711
712    /// On an even-length axis the outermost sampled frequency is not determined by the model,
713    /// so it must come back empty rather than filled with a guess. See `is_nyquist_bin`.
714    #[test]
715    fn nyquist_planes_are_excluded_on_even_grids() {
716        let grid = Grid::new(12, 12, 12, 1.0, 1.0, 1.0);
717        let (nx, ny, nz) = grid.dims;
718        let n = grid.n_total();
719        let mask = vec![1u8; n];
720        let dirs = hemisphere_dirs(16);
721        let fields = sti_forward(&random_tensor(&grid), &dirs, &grid);
722        let rec = sti(&fields, &dirs, &mask, &grid, &StiParams::default());
723
724        for comp in rec.components.iter() {
725            let mut spec: Vec<Complex64> =
726                comp.iter().map(|&v| Complex64::new(v, 0.0)).collect();
727            crate::fft::fft3d(&mut spec, nx, ny, nz);
728            let scale = spec.iter().map(|c| c.norm()).fold(0.0, f64::max);
729            for (idx, c) in spec.iter().enumerate() {
730                let (i, j, k) = (idx % nx, (idx / nx) % ny, idx / (nx * ny));
731                if is_nyquist_bin(i, j, k, grid.dims) {
732                    assert!(
733                        c.norm() / scale < 1e-12,
734                        "Nyquist bin ({},{},{}) should be empty, got {:.3e}",
735                        i, j, k, c.norm()
736                    );
737                }
738            }
739        }
740    }
741
742    /// Odd-length axes have no Nyquist bin, so nothing is excluded there.
743    #[test]
744    fn odd_grids_have_no_excluded_bins() {
745        let dims = (13, 13, 13);
746        for idx in 0..(13 * 13 * 13) {
747            let (i, j, k) = (idx % 13, (idx / 13) % 13, idx / 169);
748            assert!(!is_nyquist_bin(i, j, k, dims));
749        }
750        // A mixed grid excludes only the plane belonging to its even axis.
751        assert!(is_nyquist_bin(6, 0, 0, (12, 13, 13)));
752        assert!(!is_nyquist_bin(0, 6, 0, (12, 13, 13)));
753    }
754
755    #[test]
756    #[should_panic(expected = "at least 6 orientations")]
757    fn rejects_too_few_orientations() {
758        let grid = Grid::new(6, 6, 6, 1.0, 1.0, 1.0);
759        let n = grid.n_total();
760        let fields: Vec<Vec<f64>> = (0..5).map(|_| vec![0.0; n]).collect();
761        let dirs = hemisphere_dirs(5);
762        sti(&fields, &dirs, &vec![1u8; n], &grid, &StiParams::default());
763    }
764
765    #[test]
766    #[should_panic(expected = "but 7 B0 directions")]
767    fn rejects_mismatched_direction_count() {
768        let grid = Grid::new(6, 6, 6, 1.0, 1.0, 1.0);
769        let n = grid.n_total();
770        let fields: Vec<Vec<f64>> = (0..8).map(|_| vec![0.0; n]).collect();
771        sti(&fields, &hemisphere_dirs(7), &vec![1u8; n], &grid, &StiParams::default());
772    }
773
774    #[test]
775    #[should_panic(expected = "orientation 2 has")]
776    fn rejects_wrong_sized_field() {
777        let grid = Grid::new(6, 6, 6, 1.0, 1.0, 1.0);
778        let n = grid.n_total();
779        let mut fields: Vec<Vec<f64>> = (0..8).map(|_| vec![0.0; n]).collect();
780        fields[2] = vec![0.0; n - 1];
781        sti(&fields, &hemisphere_dirs(8), &vec![1u8; n], &grid, &StiParams::default());
782    }
783}