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(®) < 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, ¶ms);
604 let weighted = cosmos_weighted(&fields, &weights, &bdirs, &mask, &grid, ¶ms);
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, ¶ms),
635 &chi_true,
636 );
637 let err_down = max_abs_diff(
638 &cosmos_weighted(&fields, &downweighted, &bdirs, &mask, &grid, ¶ms),
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}