Skip to main content

qsm_core/inversion/
cosmos.rs

1//! COSMOS: calculation of susceptibility through multiple orientation sampling
2//!
3//! Single-orientation dipole inversion is ill-posed because the dipole kernel vanishes on a
4//! pair of cones at the magic angle, so those spatial frequencies carry no information and
5//! have to be regularized back in. COSMOS removes the ill-posedness at acquisition time
6//! instead: the cones sit at a fixed angle to B0, so re-imaging the same object at a
7//! different orientation moves them somewhere else. Combining N orientations, every
8//! frequency is sampled well by at least one of them, and the inversion becomes a
9//! well-conditioned least-squares problem needing no regularization at all.
10//!
11//! Minimizing the sum of squared residuals over orientations,
12//!
13//! ```text
14//!     chi = argmin_x  sum_t || F^-1 D_t F x  -  f_t ||^2
15//! ```
16//!
17//! diagonalizes in k-space (every operator involved is a k-space multiplication), giving a
18//! closed form with no iteration:
19//!
20//! ```text
21//!     chi = Re . IFFT(  sum_t D_t . FFT(f_t)  /  ( sum_t |D_t|^2 + lambda )  )
22//! ```
23//!
24//! [`cosmos`] is that expression. [`cosmos_weighted`] is the original formulation, which
25//! weights each orientation's residual by its signal magnitude; spatial weights do not
26//! commute with the k-space operators, so that variant loses the closed form and solves the
27//! normal equations with conjugate gradient instead.
28//!
29//! # Orientations
30//!
31//! Each orientation contributes its own B0 direction expressed *in the voxel frame of the
32//! common (registered) grid* — the object is resampled into one shared frame, so what varies
33//! between orientations is where B0 points relative to it. Derive each one with
34//! [`crate::geometry::b0_direction_from_affine`] from that acquisition's affine, which
35//! factors the voxel sizes out before taking the direction and so stays correct for
36//! anisotropic voxels.
37//!
38//! # How many orientations, and what limits accuracy
39//!
40//! k-space coverage saturates fast. Measured on a 164x205x205 1 mm grid, the number of
41//! k-points where *every* orientation's kernel is negligible goes 16952 (one orientation,
42//! the magic-angle cone) to 16 (two orientations 60 degrees apart, where the two cones still
43//! intersect along curves) to 0 (three). Even the real in-vivo orientations of the reference
44//! dataset, which span only 0 to 25 degrees from B0 because that is as far as a head turns
45//! inside a coil, leave nothing uncovered once there are twelve of them — a 17-degree
46//! rotation already shifts the cone clear of where it started.
47//!
48//! So beyond about three orientations, extra scans buy noise averaging rather than coverage.
49//! That still matters a great deal: with field noise at 5% of the field RMS, reconstruction
50//! error on the same phantom runs roughly 205 / 0.09 / 0.03 (relative NRMSE) for one, two and
51//! four orientations. The single-orientation case is catastrophic precisely because the cone
52//! is uncovered and there is no redundancy to average against — which is the whole argument
53//! for acquiring more than one.
54//!
55//! What is *not* fixed by adding orientations:
56//!
57//! - **Anisotropy.** COSMOS models susceptibility as a scalar. White matter is measurably
58//!   anisotropic, and that mismatch does not shrink with more orientations; it is the
59//!   motivation for susceptibility tensor imaging.
60//! - **Registration.** Misalignment between orientations propagates straight into the result.
61//! - **Rotations about a single axis.** These leave residual streaking that L2 regularization
62//!   suppresses (Schweser & Zivadinov 2024) — one reason `lambda` is exposed.
63//!
64//! COSMOS is widely treated as a susceptibility ground truth; those three are what that
65//! status actually rests on, not the orientation count.
66//!
67//! # When the orientations are too close
68//!
69//! The closed form divides by `sum_t |D_t|^2`, so its noise gain at a frequency is
70//! `1 / sqrt(sum_t |D_t|^2)`. A single orientation's cone of `|D| < 0.1` ([`CONE_THRESHOLD`])
71//! is where that gain exceeds 10, and with `lambda = 0` nothing damps it. Orientations only
72//! help where they move that cone, so the question for a set is how much of it the set still
73//! leaves ill-conditioned: [`uncovered_cone`], the share of a single orientation's cone where
74//! `sum_t |D_t|^2` is still below `CONE_THRESHOLD^2`. One orientation leaves all of it. Three
75//! pointing the same way leave 57%: repeats average the noise down by `sqrt(3)`, which narrows
76//! the cone, but they cover none of it. The share falls further as the directions tilt apart.
77//!
78//! The largest angle between directions is not the measure: two orientations always leave
79//! the curves where their cones intersect, and three that tilt about one axis leave more than
80//! three spread around B0. Measured with [`uncovered_cone`]:
81//!
82//! | Directions | Uncovered |
83//! |---|---|
84//! | Three runs of one head position, re-registered (B0 within 1.1°, 2.6°, 3.3° of each other) | 55% |
85//! | Two orientations 5° / 10° / 15° / 20° apart | 64% / 35% / 22% / 16% |
86//! | Three of the twelve in-vivo orientations of Bilgic et al. (2015): median, worst | 0.4%, 28% |
87//! | Three at 30° to each other, all twelve of Bilgic et al. | 0% |
88//!
89//! The first is what three runs of a single-orientation protocol give: registration recovers
90//! the head's small movements between them, and COSMOS from those directions is a
91//! single-orientation inversion with no regularization, its cone filled with amplified noise.
92//! [`MAX_UNCOVERED_CONE`] is the share above which a set is that rather than a
93//! multi-orientation acquisition: it refuses the first two rows below about 13°, and passes
94//! designed acquisitions and most real triples.
95//!
96//! # Units and gauge
97//!
98//! Local (tissue) field in ppm in, susceptibility in ppm out, matching the rest of the crate.
99//! The dipole kernel is zero at DC, so the recovered chi has zero mean by construction;
100//! referencing to a tissue region is a separate pipeline stage ([`crate::pipeline::apply_reference`]).
101//!
102//! # References
103//!
104//! Liu T, Spincemaille P, de Rochefort L, Kressler B, Wang Y. "Calculation of susceptibility
105//! through multiple orientation sampling (COSMOS): a method for conditioning the inverse
106//! problem from measured magnetic field map to susceptibility source image in MRI."
107//! Magnetic Resonance in Medicine. 2009;61(1):196-204. <https://doi.org/10.1002/mrm.21828>
108//!
109//! Bilgic B, Fan AP, Polimeni JR, et al. "Fast quantitative susceptibility mapping with
110//! L1-regularization and automatic parameter selection" / "Rapid multi-orientation
111//! quantitative susceptibility mapping." NeuroImage. 2015;111:622-630.
112//! <https://doi.org/10.1016/j.neuroimage.2015.02.036>
113//!
114//! Schweser F, Zivadinov R. "Chaos and COSMOS - considerations on QSM methods with multiple
115//! and single orientations and effects from local anisotropy." Magnetic Resonance Imaging.
116//! 2024;109:135-146. <https://doi.org/10.1016/j.mri.2024.04.001>
117
118use crate::fft::{apply_real_kernel, fft3d, ifft3d};
119use crate::kernels::dipole::dipole_kernel;
120use crate::solvers::cg_solve;
121use crate::utils::apply_mask_zero;
122use crate::Grid;
123use num_complex::Complex64;
124
125/// The dipole kernel magnitude below which a frequency is ill-conditioned: where one
126/// orientation's noise gain, `1 / |D|`, exceeds 10. The cone [`uncovered_cone`] measures.
127pub const CONE_THRESHOLD: f64 = 0.1;
128
129/// The most of a single orientation's cone a direction set may leave ill-conditioned and still
130/// be treated as multi-orientation (module docs, *When the orientations are too close*).
131pub const MAX_UNCOVERED_CONE: f64 = 0.25;
132
133/// The share of a single orientation's ill-conditioned cone (`|D| < CONE_THRESHOLD`) that the
134/// directions `bdirs` still leave ill-conditioned: where `sum_t |D_t|^2 < CONE_THRESHOLD^2`.
135/// 1 for one direction; 0 when the set covers the cone. Repeats of one direction only average
136/// noise: three leave 57%. Directions need
137/// not be unit length; a zero one is skipped.
138///
139/// Computed over a Fibonacci lattice of 200 000 directions on the sphere, which is the measure
140/// of k-space by direction (the kernel depends on direction only); the share is good to about
141/// 0.005.
142pub fn uncovered_cone(bdirs: &[(f64, f64, f64)]) -> f64 {
143    let dirs: Vec<(f64, f64, f64)> = bdirs
144        .iter()
145        .filter_map(|&(x, y, z)| {
146            let n = (x * x + y * y + z * z).sqrt();
147            (n > 0.0).then(|| (x / n, y / n, z / n))
148        })
149        .collect();
150    if dirs.is_empty() {
151        return 1.0;
152    }
153    const POINTS: usize = 200_000;
154    let golden = std::f64::consts::PI * (3.0 - 5.0f64.sqrt());
155    let tau2 = CONE_THRESHOLD * CONE_THRESHOLD;
156    let mut ill = 0usize;
157    for i in 0..POINTS {
158        let z = 1.0 - 2.0 * (i as f64 + 0.5) / POINTS as f64;
159        let r = (1.0 - z * z).sqrt();
160        let (y, x) = (golden * i as f64).sin_cos();
161        let k = (r * x, r * y, z);
162        let sum: f64 = dirs.iter().map(|b| {
163            let c = k.0 * b.0 + k.1 * b.1 + k.2 * b.2;
164            let d = 1.0 / 3.0 - c * c;
165            d * d
166        }).sum();
167        if sum < tau2 {
168            ill += 1;
169        }
170    }
171    // One orientation's cone, exactly: |1/3 - cos^2| < t, a band of cos on the uniform sphere.
172    let single = (1.0 / 3.0 + CONE_THRESHOLD).sqrt() - (1.0 / 3.0 - CONE_THRESHOLD).sqrt();
173    (ill as f64 / POINTS as f64 / single).min(1.0)
174}
175
176/// COSMOS parameters.
177#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
178#[derive(Clone, Debug)]
179pub struct CosmosParams {
180    /// L2 regularization weight added to the k-space denominator.
181    ///
182    /// Zero — the default — is the plain least-squares solution, and is the right choice
183    /// whenever three or more orientations cover k-space, which they generally do (see the
184    /// module docs). Raise it (1e-3 to 1e-2 is the useful range) when there are only one or
185    /// two orientations, so the magic-angle cone is still partly uncovered and noise there is
186    /// amplified, or when the rotations share a single axis and streaking survives. With
187    /// twelve well-covering orientations it does not help and a large value visibly damps the
188    /// result.
189    pub lambda: f64,
190    /// Conjugate-gradient tolerance. [`cosmos_weighted`] only.
191    pub tol: f64,
192    /// Maximum conjugate-gradient iterations. [`cosmos_weighted`] only.
193    pub max_iter: usize,
194}
195
196impl Default for CosmosParams {
197    fn default() -> Self {
198        Self { lambda: 0.0, tol: 1e-6, max_iter: 100 }
199    }
200}
201
202/// Validate the shared preconditions of both entry points and return `(n_orientations, n_voxels)`.
203fn check_inputs(n_fields: usize, n_bdirs: usize, lens: impl Iterator<Item = usize>, n: usize) -> usize {
204    assert!(n_fields > 0, "cosmos: at least one orientation is required");
205    assert_eq!(
206        n_fields, n_bdirs,
207        "cosmos: got {} field maps but {} B0 directions",
208        n_fields, n_bdirs
209    );
210    for (t, len) in lens.enumerate() {
211        assert_eq!(
212            len, n,
213            "cosmos: orientation {} has {} voxels, expected {}",
214            t, len, n
215        );
216    }
217    n_fields
218}
219
220/// COSMOS dipole inversion, closed form.
221///
222/// # Arguments
223/// * `local_fields` - One local (tissue) field map per orientation, ppm, all resampled onto
224///   the same grid.
225/// * `bdirs` - B0 direction for each orientation, in the voxel frame of that shared grid.
226/// * `mask` - Binary mask (1 = inside ROI).
227/// * `grid` - The shared volume grid.
228/// * `params` - See [`CosmosParams`]; only `lambda` applies here.
229///
230/// # Returns
231/// Susceptibility map in ppm, zero outside `mask`.
232///
233/// # Panics
234/// If no orientations are given, if the field and direction counts differ, or if any field
235/// map does not match `grid`.
236pub fn cosmos<F: AsRef<[f64]>>(
237    local_fields: &[F],
238    bdirs: &[(f64, f64, f64)],
239    mask: &[u8],
240    grid: &Grid,
241    params: &CosmosParams,
242) -> Vec<f64> {
243    let (nx, ny, nz) = grid.dims;
244    let n = grid.n_total();
245    let n_dir = check_inputs(
246        local_fields.len(),
247        bdirs.len(),
248        local_fields.iter().map(|f| f.as_ref().len()),
249        n,
250    );
251
252    // Accumulate numerator sum_t D_t . FFT(f_t) and denominator sum_t |D_t|^2 together, so
253    // only one kernel is ever held at a time.
254    let mut num = vec![Complex64::new(0.0, 0.0); n];
255    let mut den = vec![0.0f64; n];
256
257    for t in 0..n_dir {
258        let d = dipole_kernel(grid, bdirs[t]);
259
260        let mut f: Vec<Complex64> = local_fields[t]
261            .as_ref()
262            .iter()
263            .map(|&v| Complex64::new(v, 0.0))
264            .collect();
265        fft3d(&mut f, nx, ny, nz);
266
267        for i in 0..n {
268            // The dipole kernel is real, so D* = D.
269            num[i] += f[i] * d[i];
270            den[i] += d[i] * d[i];
271        }
272    }
273
274    // Divide, guarding the null space. The kernel is zero at DC for every orientation, so
275    // the denominator vanishes there no matter how many orientations are supplied; dividing
276    // through anyway lands a huge constant pedestal on the result.
277    for i in 0..n {
278        let dd = den[i] + params.lambda;
279        num[i] = if dd > 1e-20 { num[i] / dd } else { Complex64::new(0.0, 0.0) };
280    }
281
282    ifft3d(&mut num, nx, ny, nz);
283
284    let mut chi: Vec<f64> = num.iter().map(|c| c.re).collect();
285    apply_mask_zero(&mut chi, mask);
286    chi
287}
288
289/// COSMOS dipole inversion with per-orientation spatial weighting.
290///
291/// This is the original formulation: each orientation's residual is weighted by its signal
292/// magnitude, so voxels where that orientation had poor SNR (or was corrupted, or fell
293/// outside its own coverage) contribute less. Weighting is a spatial-domain operation and
294/// the dipole convolution is a k-space one, so the two no longer diagonalize together and
295/// the closed form of [`cosmos`] is unavailable. Instead the normal equations
296///
297/// ```text
298///     ( sum_t D_t W_t^2 D_t  +  lambda I ) chi  =  sum_t D_t W_t^2 f_t
299/// ```
300///
301/// are solved with conjugate gradient. The operator is symmetric positive semi-definite
302/// (`D_t` is a real, even k-space multiplier, hence self-adjoint), so CG applies directly.
303/// Each iteration costs two FFTs per orientation, making this substantially more expensive
304/// than [`cosmos`] — prefer the closed form unless the weighting earns its cost.
305///
306/// `weights` are multiplied by `mask` internally, so they need not be pre-masked. Typically
307/// they are the per-orientation magnitude images, normalized to a comparable scale across
308/// orientations (their *relative* size between orientations is what matters; a global factor
309/// only rescales `lambda`).
310///
311/// # Panics
312/// As [`cosmos`], and additionally if the weight count or any weight map size is wrong.
313pub fn cosmos_weighted<F: AsRef<[f64]>, W: AsRef<[f64]>>(
314    local_fields: &[F],
315    weights: &[W],
316    bdirs: &[(f64, f64, f64)],
317    mask: &[u8],
318    grid: &Grid,
319    params: &CosmosParams,
320) -> Vec<f64> {
321    let (nx, ny, nz) = grid.dims;
322    let n = grid.n_total();
323    let n_dir = check_inputs(
324        local_fields.len(),
325        bdirs.len(),
326        local_fields.iter().map(|f| f.as_ref().len()),
327        n,
328    );
329    assert_eq!(
330        weights.len(),
331        n_dir,
332        "cosmos_weighted: got {} weight maps but {} orientations",
333        weights.len(),
334        n_dir
335    );
336    for (t, w) in weights.iter().enumerate() {
337        assert_eq!(
338            w.as_ref().len(),
339            n,
340            "cosmos_weighted: weight map {} has {} voxels, expected {}",
341            t,
342            w.as_ref().len(),
343            n
344        );
345    }
346
347    let kernels: Vec<Vec<f64>> = bdirs.iter().map(|&b| dipole_kernel(grid, b)).collect();
348
349    // Fold the mask in once and square, so the inner loops multiply by W^2 directly.
350    let w2: Vec<Vec<f64>> = weights
351        .iter()
352        .map(|w| {
353            w.as_ref()
354                .iter()
355                .zip(mask.iter())
356                .map(|(&wi, &m)| if m == 0 { 0.0 } else { wi * wi })
357                .collect()
358        })
359        .collect();
360
361    // b = sum_t D_t (W_t^2 f_t)
362    let mut b = vec![0.0f64; n];
363    for t in 0..n_dir {
364        let f = local_fields[t].as_ref();
365        let wf: Vec<f64> = (0..n).map(|i| w2[t][i] * f[i]).collect();
366        let term = apply_real_kernel(&wf, &kernels[t], nx, ny, nz);
367        for i in 0..n {
368            b[i] += term[i];
369        }
370    }
371
372    // A x = sum_t D_t (W_t^2 (D_t x)) + lambda x
373    let lambda = params.lambda;
374    let a_op = |x: &[f64]| -> Vec<f64> {
375        let mut out = vec![0.0f64; n];
376        for t in 0..n_dir {
377            let dx = apply_real_kernel(x, &kernels[t], nx, ny, nz);
378            let wdx: Vec<f64> = (0..n).map(|i| w2[t][i] * dx[i]).collect();
379            let term = apply_real_kernel(&wdx, &kernels[t], nx, ny, nz);
380            for i in 0..n {
381                out[i] += term[i];
382            }
383        }
384        if lambda != 0.0 {
385            for i in 0..n {
386                out[i] += lambda * x[i];
387            }
388        }
389        out
390    };
391
392    let x0 = vec![0.0f64; n];
393    let mut chi = cg_solve(a_op, &b, &x0, params.tol, params.max_iter);
394    apply_mask_zero(&mut chi, mask);
395    chi
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    /// One orientation, or several that agree, leave the whole cone; a spread set covers it.
403    #[test]
404    fn uncovered_cone_runs_from_one_to_none() {
405        assert!((uncovered_cone(&[(0.0, 0.0, 1.0)]) - 1.0).abs() < 0.01);
406        // Three repeats average the noise down by sqrt(3): the cone of |D| < 0.1 / sqrt(3).
407        let t = CONE_THRESHOLD / 3.0f64.sqrt();
408        let repeats = ((1.0f64 / 3.0 + t).sqrt() - (1.0f64 / 3.0 - t).sqrt())
409            / ((1.0f64 / 3.0 + CONE_THRESHOLD).sqrt() - (1.0f64 / 3.0 - CONE_THRESHOLD).sqrt());
410        let u = uncovered_cone(&[(0.0, 0.0, 1.0); 3]);
411        assert!((u - repeats).abs() < 0.01 && u > MAX_UNCOVERED_CONE, "repeats only average noise: {u}");
412        assert!((uncovered_cone(&[(0.0, 0.0, 2.0)]) - 1.0).abs() < 0.01, "length does not matter");
413        assert_eq!(uncovered_cone(&spread_bdirs()), 0.0);
414        assert_eq!(uncovered_cone(&[]), 1.0);
415    }
416
417    /// The module docs' table: three re-registered runs of one head position are refused, two
418    /// orientations tilt apart past the limit near 13 degrees, and designed sets pass.
419    #[test]
420    fn uncovered_cone_matches_the_docs() {
421        let runs = [(0.0, 0.0, 1.0), (0.003, -0.019, 1.0), (-0.042, 0.016, 0.999)];
422        let u = uncovered_cone(&runs);
423        assert!((u - 0.55).abs() < 0.02 && u > MAX_UNCOVERED_CONE, "{u}");
424        let pair = |deg: f64| uncovered_cone(&[(0.0, 0.0, 1.0), (deg.to_radians().sin(), 0.0, deg.to_radians().cos())]);
425        for (deg, want) in [(5.0, 0.64), (10.0, 0.35), (15.0, 0.22), (20.0, 0.16)] {
426            assert!((pair(deg) - want).abs() < 0.02, "{deg} degrees: {}", pair(deg));
427        }
428        assert!(pair(10.0) > MAX_UNCOVERED_CONE && pair(15.0) < MAX_UNCOVERED_CONE);
429        let thirty = [(0.0, 0.0, 1.0), (0.0, 0.5, 0.866), (0.5, 0.0, 0.866)];
430        assert!(uncovered_cone(&thirty) < 0.001);
431    }
432
433    /// A spread of B0 directions wide enough to condition the inversion: straight along z,
434    /// then 60 degrees off it in each of two perpendicular planes.
435    fn spread_bdirs() -> Vec<(f64, f64, f64)> {
436        let s = (60.0f64).to_radians().sin();
437        let c = (60.0f64).to_radians().cos();
438        vec![(0.0, 0.0, 1.0), (s, 0.0, c), (0.0, s, c)]
439    }
440
441    /// Forward dipole model: the field this chi would produce with B0 along `bdir`.
442    fn simulate_field(chi: &[f64], grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
443        let (nx, ny, nz) = grid.dims;
444        apply_real_kernel(chi, &dipole_kernel(grid, bdir), nx, ny, nz)
445    }
446
447    /// A smooth, zero-mean test susceptibility distribution. Zero-mean because the dipole
448    /// kernel annihilates DC, so no method can recover a constant offset.
449    fn test_chi(grid: &Grid) -> Vec<f64> {
450        let (nx, ny, nz) = grid.dims;
451        let mut chi = vec![0.0f64; grid.n_total()];
452        for k in 0..nz {
453            for j in 0..ny {
454                for i in 0..nx {
455                    let (x, y, z) = (i as f64, j as f64, k as f64);
456                    chi[i + j * nx + k * nx * ny] =
457                        (0.7 * x).sin() * (0.5 * y).cos() + 0.4 * (0.3 * z).sin();
458                }
459            }
460        }
461        let mean = chi.iter().sum::<f64>() / chi.len() as f64;
462        for v in chi.iter_mut() {
463            *v -= mean;
464        }
465        chi
466    }
467
468    fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
469        a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
470    }
471
472    /// The test that matters: forward-simulate one chi at several orientations, invert, and
473    /// get the chi back. With noiseless data and a well-spread set this is exact to
474    /// round-off, which is what makes COSMOS worth acquiring.
475    #[test]
476    fn recovers_chi_from_simulated_orientations() {
477        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
478        let mask = vec![1u8; grid.n_total()];
479        let chi_true = test_chi(&grid);
480        let bdirs = spread_bdirs();
481
482        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
483        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
484
485        let err = max_abs_diff(&chi, &chi_true);
486        let scale = chi_true.iter().fold(0.0f64, |m, v| m.max(v.abs()));
487        assert!(err / scale < 1e-9, "relative error {} too large", err / scale);
488    }
489
490    /// Anisotropic voxels exercise the k-space scaling in the kernel; recovery must hold.
491    #[test]
492    fn recovers_chi_with_anisotropic_voxels() {
493        let grid = Grid::new(16, 16, 12, 0.8, 0.8, 2.0);
494        let mask = vec![1u8; grid.n_total()];
495        let chi_true = test_chi(&grid);
496        let bdirs = spread_bdirs();
497
498        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
499        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
500
501        let scale = chi_true.iter().fold(0.0f64, |m, v| m.max(v.abs()));
502        assert!(max_abs_diff(&chi, &chi_true) / scale < 1e-9);
503    }
504
505    /// A single orientation reduces COSMOS to plain k-space division, which is ill-posed at
506    /// the magic angle. It must still return finite numbers rather than dividing by zero.
507    #[test]
508    fn single_orientation_stays_finite() {
509        let grid = Grid::new(12, 12, 12, 1.0, 1.0, 1.0);
510        let mask = vec![1u8; grid.n_total()];
511        let chi_true = test_chi(&grid);
512        let bdirs = vec![(0.0, 0.0, 1.0)];
513        let fields = vec![simulate_field(&chi_true, &grid, bdirs[0])];
514
515        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
516        assert!(chi.iter().all(|v| v.is_finite()), "single orientation produced non-finite chi");
517    }
518
519    /// The kernel is zero at DC for every orientation, so the denominator vanishes there no
520    /// matter how many are supplied. Without the guard this leaves a constant pedestal.
521    #[test]
522    fn no_dc_pedestal() {
523        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
524        let mask = vec![1u8; grid.n_total()];
525        let mut chi_true = test_chi(&grid);
526        // Add a large constant the inversion cannot possibly recover.
527        for v in chi_true.iter_mut() {
528            *v += 5.0;
529        }
530        let bdirs = spread_bdirs();
531        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
532
533        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
534        let mean = chi.iter().sum::<f64>() / chi.len() as f64;
535        assert!(chi.iter().all(|v| v.is_finite()), "DC guard failed, result not finite");
536        assert!(mean.abs() < 1e-9, "expected zero-mean output, got mean {}", mean);
537    }
538
539    #[test]
540    fn zero_field_gives_zero_chi() {
541        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
542        let n = grid.n_total();
543        let mask = vec![1u8; n];
544        let bdirs = spread_bdirs();
545        let fields: Vec<Vec<f64>> = (0..3).map(|_| vec![0.0; n]).collect();
546
547        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
548        assert!(chi.iter().all(|v| v.abs() < 1e-12));
549    }
550
551    #[test]
552    fn masked_voxels_are_zero() {
553        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
554        let n = grid.n_total();
555        let mut mask = vec![1u8; n];
556        mask[0] = 0;
557        mask[5] = 0;
558        let bdirs = spread_bdirs();
559        let chi_true = test_chi(&grid);
560        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
561
562        let chi = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
563        assert_eq!(chi[0], 0.0);
564        assert_eq!(chi[5], 0.0);
565    }
566
567    /// lambda damps the solution; it must not be silently ignored.
568    #[test]
569    fn lambda_shrinks_the_solution() {
570        let grid = Grid::new(12, 12, 12, 1.0, 1.0, 1.0);
571        let mask = vec![1u8; grid.n_total()];
572        let chi_true = test_chi(&grid);
573        let bdirs = spread_bdirs();
574        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
575
576        let energy = |c: &[f64]| c.iter().map(|v| v * v).sum::<f64>();
577        let plain = cosmos(&fields, &bdirs, &mask, &grid, &CosmosParams::default());
578        let reg = cosmos(
579            &fields,
580            &bdirs,
581            &mask,
582            &grid,
583            &CosmosParams { lambda: 0.1, ..Default::default() },
584        );
585
586        assert!(energy(&reg) < energy(&plain), "lambda did not damp the solution");
587    }
588
589    /// With uniform weights the weighted normal equations have the same solution as the
590    /// closed form, so CG must converge onto it. This cross-checks the two code paths
591    /// against each other.
592    #[test]
593    fn weighted_with_uniform_weights_matches_closed_form() {
594        let grid = Grid::new(12, 12, 12, 1.0, 1.0, 1.0);
595        let n = grid.n_total();
596        let mask = vec![1u8; n];
597        let chi_true = test_chi(&grid);
598        let bdirs = spread_bdirs();
599        let fields: Vec<Vec<f64>> = bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
600        let weights: Vec<Vec<f64>> = (0..bdirs.len()).map(|_| vec![1.0; n]).collect();
601
602        let params = CosmosParams { tol: 1e-12, max_iter: 400, ..Default::default() };
603        let closed = cosmos(&fields, &bdirs, &mask, &grid, &params);
604        let weighted = cosmos_weighted(&fields, &weights, &bdirs, &mask, &grid, &params);
605
606        let scale = closed.iter().fold(0.0f64, |m, v| m.max(v.abs()));
607        let rel = max_abs_diff(&closed, &weighted) / scale;
608        assert!(rel < 1e-6, "weighted and closed-form solutions differ by {}", rel);
609    }
610
611    /// Down-weighting an orientation should pull the solution toward what the remaining
612    /// orientations alone say, not toward the corrupted one.
613    #[test]
614    fn weighting_suppresses_a_corrupted_orientation() {
615        let grid = Grid::new(12, 12, 12, 1.0, 1.0, 1.0);
616        let n = grid.n_total();
617        let mask = vec![1u8; n];
618        let chi_true = test_chi(&grid);
619        let bdirs = spread_bdirs();
620        let mut fields: Vec<Vec<f64>> =
621            bdirs.iter().map(|&b| simulate_field(&chi_true, &grid, b)).collect();
622
623        // Corrupt the last orientation.
624        for (i, v) in fields[2].iter_mut().enumerate() {
625            *v += 0.5 * ((i as f64) * 0.37).sin();
626        }
627
628        let params = CosmosParams { tol: 1e-12, max_iter: 400, ..Default::default() };
629        let equal: Vec<Vec<f64>> = (0..3).map(|_| vec![1.0; n]).collect();
630        let mut downweighted = equal.clone();
631        downweighted[2] = vec![1e-3; n];
632
633        let err_equal = max_abs_diff(
634            &cosmos_weighted(&fields, &equal, &bdirs, &mask, &grid, &params),
635            &chi_true,
636        );
637        let err_down = max_abs_diff(
638            &cosmos_weighted(&fields, &downweighted, &bdirs, &mask, &grid, &params),
639            &chi_true,
640        );
641
642        assert!(
643            err_down < err_equal,
644            "down-weighting the corrupted orientation made things worse ({} vs {})",
645            err_down,
646            err_equal
647        );
648    }
649
650    #[test]
651    #[should_panic(expected = "but 2 B0 directions")]
652    fn rejects_mismatched_direction_count() {
653        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
654        let n = grid.n_total();
655        let fields: Vec<Vec<f64>> = (0..3).map(|_| vec![0.0; n]).collect();
656        let bdirs = vec![(0.0, 0.0, 1.0), (1.0, 0.0, 0.0)];
657        cosmos(&fields, &bdirs, &vec![1u8; n], &grid, &CosmosParams::default());
658    }
659
660    #[test]
661    #[should_panic(expected = "at least one orientation")]
662    fn rejects_empty_input() {
663        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
664        let fields: Vec<Vec<f64>> = Vec::new();
665        cosmos(&fields, &[], &vec![1u8; grid.n_total()], &grid, &CosmosParams::default());
666    }
667
668    #[test]
669    #[should_panic(expected = "orientation 1 has")]
670    fn rejects_wrong_sized_field() {
671        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
672        let n = grid.n_total();
673        let fields = vec![vec![0.0; n], vec![0.0; n - 1]];
674        let bdirs = vec![(0.0, 0.0, 1.0), (1.0, 0.0, 0.0)];
675        cosmos(&fields, &bdirs, &vec![1u8; n], &grid, &CosmosParams::default());
676    }
677}