Skip to main content

qsm_core/inversion/
lsqr_qsm.rs

1//! Minimally regularised LSQR dipole inversion (Schweser 2010)
2//!
3//! Solves the dipole inversion as one large least-squares system and leans on
4//! LSQR's minimum-norm property instead of an explicit regularisation term —
5//! hence "minimally regularised".
6//!
7//! Two extra sets of unknowns make that work:
8//!
9//! * a **residual field** `r`, added to the data equation with weight `w`, which
10//!   absorbs field contributions the dipole model cannot explain (unmodelled
11//!   background, noise, non-local sources). Because LSQR converges to the
12//!   minimum-norm solution, `‖r‖` is implicitly penalised, and `w` sets how
13//!   cheaply the fit may spend field on `r` rather than on χ.
14//! * a **global offset** `c`, one scalar, absorbing any constant field shift
15//!   left over from background removal.
16//!
17//! With χ over the whole (cropped and padded) grid, `r` and the data over the mask only:
18//!
19//! ```text
20//! minimise ‖ W · ( M[ F⁻¹(D · F χ) + c ] + w·r − b ) ‖₂   over  [χ; r; c]
21//! ```
22//!
23//! `W` is the optional magnitude image (used as is, as upstream does) and `M` restricts to
24//! the mask.
25//!
26//! Ported from the authors' code, `inversion_lsqr.m` and `computesusceptibility_lsqr.m` of
27//! qsm_heidi (<https://gitlab.com/R01NS114227/qsm_heidi>), with its settings:
28//!
29//! * the grid: field, mask and magnitude are cropped to the mask's bounding box, each odd
30//!   extent grown by one voxel, zero-padded by 20 voxels per side and the last voxel dropped
31//!   (`zealouscrop`, `prepareforconvolution` with its 40-voxel anti-aliasing frame), so every
32//!   extent is odd and the crop starts 20 voxels in;
33//! * the dipole kernel: the unit dipole response `(3cos²θ − 1)/(4πr³)` sampled in the
34//!   **spatial domain** on that grid and Fourier transformed (`generateunitdipoleresponse`
35//!   with `isFourierDomainFormula = false`), see [`spatial_dipole_kernel`];
36//! * `w = 0.2/9.4 · B0`, the global offset, tolerance 1e-5 and at most 400 iterations of
37//!   MATLAB's `lsqr` (its stopping rules, via [`super::ilsqr::lsqr_matlab`]).
38//!
39//! # Units
40//! The problem is linear and homogeneous in the field: scaling the field scales χ, `r` and `c`
41//! alike and leaves LSQR's iterates and stopping tests unchanged. Upstream solves for the
42//! phase in radians and converts χ to ppm at the end; here the field goes in ppm and χ comes
43//! out in ppm, which is the same solution (on a 3T scan the two agree to rounding).
44//!
45//! This map is what HEIDI consumes: [`super::heidi`] keeps its well-conditioned
46//! k-space coefficients and re-derives the rest under a weighted-TV prior.
47//!
48//! Reference:
49//! Schweser, F., Deistung, A., Lehr, B.W., Reichenbach, J.R. (2010).
50//! "Differentiation between diamagnetic and paramagnetic cerebral lesions based
51//! on magnetic susceptibility mapping." Medical Physics, 37(10):5165-5178.
52//! https://doi.org/10.1118/1.3481505
53//!
54//! # Reference pipeline
55//! qsm_heidi (<https://gitlab.com/R01NS114227/qsm_heidi>, commit 4592631): the `makefile`
56//! (L14-20) and `def.sh` feed `inversion_lsqr.m` a SHARP-corrected local field
57//! (`background_correction_sharp.mat`, phase in rad), the magnitude, and masks made by an
58//! upstream step not in the repository (`create_masks.mat`). The mask the inversion uses is the
59//! product of a tissue mask, a phase mask and the background-removal mask
60//! (`inversion_lsqr.m`, `suscCompMask = mask_tissue .* mask_phase .* phsInput.mask`). The SHARP
61//! settings are not in the repository. The phase-to-ppm factor uses the last echo time
62//! (`phase2susceptibilityfactor.m` L70-71), which does not change the LSQR solution (see Units).
63//!
64//! # Pipeline rules
65//!
66//! The facts above that a configuration can depart from are published as rules in
67//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `lsqr.bg-sharp`.
68
69use num_complex::Complex64;
70
71use crate::fft::Fft3dWorkspace;
72use crate::utils::apply_mask_zero;
73use crate::Grid;
74
75use super::ilsqr::lsqr_matlab;
76
77/// Residual weighting per tesla: upstream's `0.2 / 9.4 * B0` ("0.2 optimal in QUASAR paper").
78pub const RESIDUAL_WEIGHTING_PER_TESLA: f64 = 0.2 / 9.4;
79
80/// Width of upstream's anti-aliasing frame (`DEFAULT_ANTIALIASINGFRAMEWIDTH`), in voxels,
81/// split evenly over both sides of each axis.
82pub const ANTIALIASING_FRAME: usize = 40;
83
84/// Parameters for [`lsqr_qsm`].
85#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
86#[derive(Clone, Debug)]
87pub struct LsqrQsmParams {
88    /// Field strength in tesla. A scan parameter, not a user knob — the pipeline
89    /// dispatcher overrides it from scan metadata.
90    pub b0: f64,
91    /// Weight `w` on the residual-field unknowns. `None` uses
92    /// `RESIDUAL_WEIGHTING_PER_TESLA * b0`; set to `Some(0.0)` to drop the
93    /// residual term entirely and solve the plain masked system.
94    pub residual_weighting: Option<f64>,
95    /// Fit a single global field offset alongside χ.
96    pub fit_global_offset: bool,
97    /// LSQR convergence tolerance (MATLAB `lsqr`'s `tol`).
98    pub tol: f64,
99    /// Maximum LSQR iterations.
100    pub max_iter: usize,
101    /// Zero the returned χ outside the mask. HEIDI wants this off — it low-passes
102    /// χ through the dipole cone, and a hard mask edge rings.
103    pub mask_output: bool,
104}
105
106impl Default for LsqrQsmParams {
107    /// `inversion_lsqr.m`'s settings.
108    fn default() -> Self {
109        Self {
110            b0: 3.0,
111            residual_weighting: None,
112            fit_global_offset: true,
113            tol: 1e-5,
114            max_iter: 400,
115            mask_output: true,
116        }
117    }
118}
119
120impl LsqrQsmParams {
121    /// Effective residual weight `w`.
122    pub fn effective_residual_weighting(&self) -> f64 {
123        self.residual_weighting
124            .unwrap_or(RESIDUAL_WEIGHTING_PER_TESLA * self.b0)
125    }
126}
127
128/// Per axis: (first voxel of the crop, its extent, the padded extent). See [`crop_grid`].
129pub(crate) type CropBox = [(usize, usize, usize); 3];
130
131/// The grid upstream solves on: the mask's bounding box (`zealouscrop`), each extent made even
132/// by one trailing voxel and then grown by the anti-aliasing frame less one voxel
133/// (`prepareforconvolution`: `makeeven`, 20 voxels per side, `makeodd`).
134///
135/// Returns `(start, extent, padded)` per axis, or `None` for an empty mask. The crop sits
136/// [`FRAME_OFFSET`] voxels in, as upstream's (for LSQR only the grid size matters, as every
137/// operator is a circular convolution or pointwise; HEIDI's gradients and diffusion see the
138/// grid's edges).
139pub(crate) fn crop_grid(mask: &[u8], dims: (usize, usize, usize)) -> Option<CropBox> {
140    let (nx, ny, nz) = dims;
141    let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
142    for k in 0..nz {
143        for j in 0..ny {
144            for i in 0..nx {
145                if mask[i + nx * (j + ny * k)] != 0 {
146                    for (d, c) in [i, j, k].into_iter().enumerate() {
147                        lo[d] = lo[d].min(c);
148                        hi[d] = hi[d].max(c);
149                    }
150                }
151            }
152        }
153    }
154    if lo[0] == usize::MAX {
155        return None;
156    }
157    Some(std::array::from_fn(|d| {
158        let e = hi[d] - lo[d] + 1;
159        (lo[d], e, e + e % 2 + ANTIALIASING_FRAME - 1)
160    }))
161}
162
163/// Where the crop starts on the padded grid: half the anti-aliasing frame.
164pub(crate) const FRAME_OFFSET: usize = ANTIALIASING_FRAME / 2;
165
166/// Copy the `crop` box of `x` (on `dims`) into a zero grid of the padded size, [`FRAME_OFFSET`]
167/// voxels in along each axis.
168pub(crate) fn crop_pad<T: Copy + Default>(x: &[T], dims: (usize, usize, usize), crop: &CropBox) -> Vec<T> {
169    let (nx, ny, _) = dims;
170    let (px, py, pz) = (crop[0].2, crop[1].2, crop[2].2);
171    let mut out = vec![T::default(); px * py * pz];
172    for k in 0..crop[2].1 {
173        for j in 0..crop[1].1 {
174            let src = crop[0].0 + nx * (crop[1].0 + j + ny * (crop[2].0 + k));
175            let dst = FRAME_OFFSET + px * (FRAME_OFFSET + j + py * (FRAME_OFFSET + k));
176            out[dst..dst + crop[0].1].copy_from_slice(&x[src..src + crop[0].1]);
177        }
178    }
179    out
180}
181
182/// Inverse of [`crop_pad`]: the crop box back in place on `dims`, zero elsewhere.
183pub(crate) fn uncrop(x: &[f64], dims: (usize, usize, usize), crop: &CropBox) -> Vec<f64> {
184    let (nx, ny, nz) = dims;
185    let (px, py) = (crop[0].2, crop[1].2);
186    let mut out = vec![0.0; nx * ny * nz];
187    for k in 0..crop[2].1 {
188        for j in 0..crop[1].1 {
189            let dst = crop[0].0 + nx * (crop[1].0 + j + ny * (crop[2].0 + k));
190            let src = FRAME_OFFSET + px * (FRAME_OFFSET + j + py * (FRAME_OFFSET + k));
191            out[dst..dst + crop[0].1].copy_from_slice(&x[src..src + crop[0].1]);
192        }
193    }
194    out
195}
196
197/// The dipole kernel in k-space (FFT order, real) as qsm_heidi's
198/// `generateunitdipoleresponse(dims, true, Options)` builds it with
199/// `isFourierDomainFormula = false` and a voxel size given: the unit dipole response
200/// `(3(b·r̂)² − 1)/(4π|r|³)` (zero at `r = 0`) sampled on an isotropic grid of the smallest
201/// voxel size, `2⌊n·a/2⌋ + 1` voxels along an axis of `n` voxels and aspect `a = vs/min(vs)`,
202/// Fourier transformed, cropped to the central `n` frequencies, real part, DC set to zero.
203/// For isotropic voxels the fine grid is the grid itself.
204///
205/// Every extent must be odd (upstream refuses even grids).
206pub fn spatial_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
207    let dims = [grid.dims.0, grid.dims.1, grid.dims.2];
208    let vs = [grid.voxel_size.0, grid.voxel_size.1, grid.voxel_size.2];
209    assert!(dims.iter().all(|&n| n % 2 == 1), "the spatial dipole kernel needs odd extents, got {dims:?}");
210    let vmin = vs.iter().cloned().fold(f64::INFINITY, f64::min);
211    let fine: [usize; 3] = std::array::from_fn(|d| {
212        let x = dims[d] as f64 * vs[d] / vmin;
213        2 * (x / 2.0).floor() as usize + 1
214    });
215    let bn = (bdir.0 * bdir.0 + bdir.1 * bdir.1 + bdir.2 * bdir.2).sqrt();
216    let b = [bdir.0 / bn, bdir.1 / bn, bdir.2 / bn];
217    // FFT order: index i holds coordinate i (i <= (n-1)/2) or i - n.
218    let coord = |i: usize, n: usize| if i <= (n - 1) / 2 { i as f64 } else { i as f64 - n as f64 };
219    let (fx, fy, fz) = (fine[0], fine[1], fine[2]);
220    let mut k: Vec<Complex64> = vec![Complex64::new(0.0, 0.0); fx * fy * fz];
221    for iz in 0..fz {
222        let z = coord(iz, fz);
223        for iy in 0..fy {
224            let y = coord(iy, fy);
225            for ix in 0..fx {
226                let x = coord(ix, fx);
227                let r2 = x * x + y * y + z * z;
228                if r2 == 0.0 {
229                    continue;
230                }
231                let r = r2.sqrt();
232                let c = (b[0] * x + b[1] * y + b[2] * z) / r;
233                k[ix + fx * (iy + fy * iz)] = Complex64::new((3.0 * c * c - 1.0) / (r2 * r * 4.0 * std::f64::consts::PI), 0.0);
234            }
235        }
236    }
237    Fft3dWorkspace::new(fx, fy, fz).fft3d(&mut k);
238    // Frequency index m of the coarse grid sits at m mod fine on the fine one.
239    let fidx = |i: usize, n: usize, f: usize| if i <= (n - 1) / 2 { i } else { f + i - n };
240    let (nx, ny, nz) = grid.dims;
241    let mut d = vec![0.0; nx * ny * nz];
242    for iz in 0..nz {
243        let jz = fidx(iz, nz, fz);
244        for iy in 0..ny {
245            let jy = fidx(iy, ny, fy);
246            for ix in 0..nx {
247                d[ix + nx * (iy + ny * iz)] = k[fidx(ix, nx, fx) + fx * (jy + fy * jz)].re;
248            }
249        }
250    }
251    d[0] = 0.0;
252    d
253}
254
255/// The solve on upstream's grid. Returns the cropped-and-padded grid, its crop box, and χ on
256/// it (unmasked), or `None` for an empty mask.
257pub(crate) fn lsqr_qsm_padded(
258    local_field: &[f64],
259    mask: &[u8],
260    magnitude: Option<&[f64]>,
261    grid: &Grid,
262    bdir: (f64, f64, f64),
263    params: &LsqrQsmParams,
264    progress: impl FnMut(usize, usize),
265) -> Option<(Grid, CropBox, Vec<f64>)> {
266    let n_total = grid.n_total();
267    assert_eq!(local_field.len(), n_total, "field length must match grid");
268    assert_eq!(mask.len(), n_total, "mask length must match grid");
269    if let Some(m) = magnitude {
270        assert_eq!(m.len(), n_total, "magnitude length must match grid");
271    }
272    let crop = crop_grid(mask, grid.dims)?;
273    let (vsx, vsy, vsz) = grid.voxel_size;
274    let pgrid = Grid::new(crop[0].2, crop[1].2, crop[2].2, vsx, vsy, vsz);
275    let field = crop_pad(local_field, grid.dims, &crop);
276    let pmask = crop_pad(mask, grid.dims, &crop);
277    let pmag = magnitude.map(|m| crop_pad(m, grid.dims, &crop));
278    let chi = lsqr_qsm_core(&field, &pmask, pmag.as_deref(), &pgrid, bdir, params, progress);
279    Some((pgrid, crop, chi))
280}
281
282/// Minimally regularised LSQR dipole inversion.
283///
284/// # Arguments
285/// * `local_field` - Local field in ppm (`nx * ny * nz`, Fortran order)
286/// * `mask` - Binary mask; the data equation is written over these voxels only
287/// * `magnitude` - Optional magnitude image, the row weight `W` (used as is; LSQR's solution
288///   does not depend on its scale)
289/// * `grid` - Volume grid
290/// * `bdir` - B0 direction
291/// * `params` - Algorithm parameters
292/// * `progress` - Progress callback `(iteration, max_iter)`
293///
294/// # Returns
295/// Susceptibility map in ppm (zero outside the mask's bounding box, and outside the mask
296/// with `mask_output`).
297pub fn lsqr_qsm(
298    local_field: &[f64],
299    mask: &[u8],
300    magnitude: Option<&[f64]>,
301    grid: &Grid,
302    bdir: (f64, f64, f64),
303    params: &LsqrQsmParams,
304    progress: impl FnMut(usize, usize),
305) -> Vec<f64> {
306    let Some((_, crop, chi)) = lsqr_qsm_padded(local_field, mask, magnitude, grid, bdir, params, progress) else {
307        return vec![0.0; grid.n_total()];
308    };
309    let mut chi = uncrop(&chi, grid.dims, &crop);
310    if params.mask_output {
311        apply_mask_zero(&mut chi, mask);
312    }
313    chi
314}
315
316fn lsqr_qsm_core(
317    local_field: &[f64],
318    mask: &[u8],
319    magnitude: Option<&[f64]>,
320    grid: &Grid,
321    bdir: (f64, f64, f64),
322    params: &LsqrQsmParams,
323    progress: impl FnMut(usize, usize),
324) -> Vec<f64> {
325    let (nx, ny, nz) = grid.dims;
326    let n_total = grid.n_total();
327
328    // Voxels carrying a data row.
329    let mask_idx: Vec<usize> = (0..n_total).filter(|&i| mask[i] != 0).collect();
330    let m = mask_idx.len();
331
332    let w_res = params.effective_residual_weighting();
333    let use_residual = w_res != 0.0;
334    let n_res = if use_residual { m } else { 0 };
335    let n_offset = usize::from(params.fit_global_offset);
336    let n_unknown = n_total + n_res + n_offset;
337
338    // Row weights: the magnitude inside the mask, as upstream's `spatialWeightingMatrix`.
339    let row_weight: Vec<f64> = match magnitude {
340        Some(mag) => mask_idx.iter().map(|&i| mag[i]).collect(),
341        None => vec![1.0; m],
342    };
343
344    // Right-hand side: the field over the mask, row-weighted. Non-finite values are set to 0,
345    // as upstream does.
346    let b: Vec<Complex64> = mask_idx
347        .iter()
348        .zip(&row_weight)
349        .map(|(&i, &w)| {
350            let v = local_field[i] * w;
351            Complex64::new(if v.is_finite() { v } else { 0.0 }, 0.0)
352        })
353        .collect();
354
355    let d = spatial_dipole_kernel(grid, bdir);
356    let mut fft = Fft3dWorkspace::new(nx, ny, nz);
357    let mut buf = vec![Complex64::new(0.0, 0.0); n_total];
358    let mut apply_dipole = |x: &[Complex64], buf: &mut Vec<Complex64>| {
359        for (o, v) in buf.iter_mut().zip(x) {
360            *o = Complex64::new(v.re, 0.0);
361        }
362        fft.fft3d(buf);
363        for (o, &dk) in buf.iter_mut().zip(&d) {
364            *o *= dk;
365        }
366        fft.ifft3d(buf);
367        // upstream takes the real part of the convolution
368        for o in buf.iter_mut() {
369            o.im = 0.0;
370        }
371    };
372
373    let mut progress = progress;
374    let max_iter = params.max_iter;
375    let mut calls = 0usize;
376    let buf_cell = std::cell::RefCell::new(&mut buf);
377    let apply_dipole_cell = std::cell::RefCell::new(&mut apply_dipole);
378
379    let apply_a = |x: &[Complex64]| -> Vec<Complex64> {
380        calls += 1;
381        progress(calls.min(max_iter), max_iter);
382        let mut buf = buf_cell.borrow_mut();
383        (apply_dipole_cell.borrow_mut())(&x[..n_total], &mut buf);
384        let offset = if n_offset == 1 { x[n_total + n_res].re } else { 0.0 };
385        mask_idx
386            .iter()
387            .zip(&row_weight)
388            .enumerate()
389            .map(|(row, (&idx, &w))| {
390                let mut v = buf[idx].re + offset;
391                if use_residual {
392                    v += w_res * x[n_total + row].re;
393                }
394                Complex64::new(w * v, 0.0)
395            })
396            .collect()
397    };
398
399    let apply_at = |y: &[Complex64]| -> Vec<Complex64> {
400        // Scatter the weighted residual back onto the grid.
401        let mut scattered = vec![Complex64::new(0.0, 0.0); n_total];
402        for (row, (&idx, &w)) in mask_idx.iter().zip(&row_weight).enumerate() {
403            scattered[idx] = Complex64::new(y[row].re * w, 0.0);
404        }
405        let mut out = vec![Complex64::new(0.0, 0.0); n_unknown];
406        if use_residual {
407            for (row, &idx) in mask_idx.iter().enumerate() {
408                out[n_total + row] = scattered[idx] * w_res;
409            }
410        }
411        if n_offset == 1 {
412            out[n_total + n_res] = Complex64::new(mask_idx.iter().map(|&i| scattered[i].re).sum(), 0.0);
413        }
414        // The dipole kernel is real and even, so it is its own adjoint.
415        let mut buf = buf_cell.borrow_mut();
416        (apply_dipole_cell.borrow_mut())(&scattered, &mut buf);
417        out[..n_total].copy_from_slice(&buf[..]);
418        out
419    };
420
421    let (solution, _) = lsqr_matlab(apply_a, apply_at, &b, params.tol, params.max_iter);
422    solution[..n_total].iter().map(|c| c.re).collect()
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use crate::kernels::dipole::dipole_kernel;
429
430    fn test_grid(n: usize) -> Grid {
431        Grid::new(n, n, n, 1.0, 1.0, 1.0)
432    }
433
434    #[test]
435    fn zero_field_gives_zero_chi() {
436        let g = test_grid(12);
437        let n = g.n_total();
438        let chi = lsqr_qsm(
439            &vec![0.0; n],
440            &vec![1u8; n],
441            None,
442            &g,
443            (0.0, 0.0, 1.0),
444            &LsqrQsmParams::default(),
445            |_, _| {},
446        );
447        assert!(chi.iter().all(|v| v.abs() < 1e-12), "expected an all-zero map");
448    }
449
450    #[test]
451    fn empty_mask_is_handled() {
452        let g = test_grid(8);
453        let n = g.n_total();
454        let chi = lsqr_qsm(
455            &vec![0.1; n],
456            &vec![0u8; n],
457            None,
458            &g,
459            (0.0, 0.0, 1.0),
460            &LsqrQsmParams::default(),
461            |_, _| {},
462        );
463        assert_eq!(chi.len(), n);
464        assert!(chi.iter().all(|v| *v == 0.0));
465    }
466
467    /// A prime-factored grid takes the fast-FFT padding path.
468    #[test]
469    fn padding_path_preserves_dimensions() {
470        let (nx, ny, nz) = (7usize, 11usize, 13usize);
471        let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
472        let n_total = g.n_total();
473        let field: Vec<f64> = (0..n_total).map(|i| ((i as f64) * 0.21).sin() * 0.02).collect();
474        let chi = lsqr_qsm(
475            &field,
476            &vec![1u8; n_total],
477            None,
478            &g,
479            (0.0, 0.0, 1.0),
480            &LsqrQsmParams { max_iter: 20, ..Default::default() },
481            |_, _| {},
482        );
483        assert_eq!(chi.len(), n_total);
484        assert!(chi.iter().all(|v| v.is_finite()));
485        assert!(chi.iter().any(|v| v.abs() > 0.0));
486    }
487
488    /// Forward-simulate a sphere, invert, and check we land near the truth.
489    #[test]
490    fn recovers_a_sphere() {
491        let n = 32;
492        let g = test_grid(n);
493        let n_total = g.n_total();
494        let d = dipole_kernel(&g, (0.0, 0.0, 1.0));
495
496        let c = n as f64 / 2.0;
497        let mut chi_true = vec![0.0; n_total];
498        let mut mask = vec![0u8; n_total];
499        for k in 0..n {
500            for j in 0..n {
501                for i in 0..n {
502                    let idx = i + j * n + k * n * n;
503                    let r = ((i as f64 - c).powi(2)
504                        + (j as f64 - c).powi(2)
505                        + (k as f64 - c).powi(2))
506                    .sqrt();
507                    if r < 4.0 {
508                        chi_true[idx] = 0.1;
509                    }
510                    if r < 11.0 {
511                        mask[idx] = 1;
512                    }
513                }
514            }
515        }
516
517        // Forward model: field = real(ifft(D * fft(chi)))
518        let mut ws = Fft3dWorkspace::new(n, n, n);
519        let mut buf: Vec<Complex64> =
520            chi_true.iter().map(|&v| Complex64::new(v, 0.0)).collect();
521        ws.fft3d(&mut buf);
522        for (b, &dk) in buf.iter_mut().zip(&d) {
523            *b *= dk;
524        }
525        ws.ifft3d(&mut buf);
526        let field: Vec<f64> = buf.iter().map(|c| c.re).collect();
527
528        let params = LsqrQsmParams {
529            residual_weighting: Some(0.0),
530            fit_global_offset: false,
531            max_iter: 120,
532            ..Default::default()
533        };
534        let chi = lsqr_qsm(&field, &mask, None, &g, (0.0, 0.0, 1.0), &params, |_, _| {});
535
536        // The sphere comes back positive and with the right shape. A masked,
537        // cone-degenerate system solved for its minimum-norm solution
538        // under-estimates amplitude — ~0.07 for a true 0.1 here — so this
539        // checks structure (correlation) and sign, not absolute recovery.
540        let (sum, count) = chi_true.iter().zip(&chi).fold((0.0, 0usize), |(s, c), (t, r)| {
541            if *t > 0.0 { (s + r, c + 1) } else { (s, c) }
542        });
543        let mean = sum / count as f64;
544        assert!(
545            (0.04..0.12).contains(&mean),
546            "sphere mean {mean} is outside the expected range for a min-norm solve"
547        );
548
549        let n_vox = chi.len() as f64;
550        let mean_true: f64 = chi_true.iter().sum::<f64>() / n_vox;
551        let mean_rec: f64 = chi.iter().sum::<f64>() / n_vox;
552        let (mut cov, mut var_t, mut var_r) = (0.0, 0.0, 0.0);
553        for (t, r) in chi_true.iter().zip(&chi) {
554            let (a, b) = (t - mean_true, r - mean_rec);
555            cov += a * b;
556            var_t += a * a;
557            var_r += b * b;
558        }
559        let corr = cov / (var_t.sqrt() * var_r.sqrt());
560        assert!(corr > 0.8, "recovered map correlates only {corr} with the truth");
561    }
562
563    /// Upstream's grid: the mask's bounding box, made even, plus 39 voxels, so always odd.
564    #[test]
565    fn crop_grid_matches_prepareforconvolution() {
566        let dims = (20, 21, 22);
567        let mut mask = vec![0u8; 20 * 21 * 22];
568        // box x 3..=9 (7), y 2..=11 (10), z 5..=5 (1)
569        for j in 2..=11 {
570            for i in 3..=9 {
571                mask[i + 20 * (j + 21 * 5)] = 1;
572            }
573        }
574        let c = crop_grid(&mask, dims).unwrap();
575        assert_eq!(c, [(3, 7, 47), (2, 10, 49), (5, 1, 41)]);
576        assert!(crop_grid(&vec![0u8; 20 * 21 * 22], dims).is_none());
577        let x: Vec<f64> = (0..mask.len()).map(|i| i as f64).collect();
578        let back = uncrop(&crop_pad(&x, dims, &c), dims, &c);
579        for (i, (&a, &b)) in x.iter().zip(&back).enumerate() {
580            let (ii, jj, kk) = (i % 20, (i / 20) % 21, i / 420);
581            let inside = (3..=9).contains(&ii) && (2..=11).contains(&jj) && kk == 5;
582            assert_eq!(b, if inside { a } else { 0.0 });
583        }
584    }
585
586    /// The spatial-domain kernel is real, zero at DC, symmetric, and close to the k-space
587    /// formula 1/3 - (k·b)²/k² away from the highest frequencies.
588    #[test]
589    fn spatial_dipole_kernel_approximates_the_kspace_kernel() {
590        let n = 33;
591        let g = Grid::new(n, n, n, 1.0, 1.0, 1.0);
592        let d = spatial_dipole_kernel(&g, (0.0, 0.0, 1.0));
593        assert_eq!(d[0], 0.0);
594        let at = |i: usize, j: usize, k: usize| d[i + n * (j + n * k)];
595        // symmetric under k -> -k
596        assert!((at(3, 5, 7) - at(n - 3, n - 5, n - 7)).abs() < 1e-12);
597        // along B0 (kz axis) ~ -2/3, perpendicular ~ 1/3, at low frequencies
598        assert!((at(0, 0, 2) + 2.0 / 3.0).abs() < 0.05, "{}", at(0, 0, 2));
599        assert!((at(2, 0, 0) - 1.0 / 3.0).abs() < 0.05, "{}", at(2, 0, 0));
600        // an anisotropic grid is built on a finer isotropic one: 2*floor(9*2/2)+1 = 19 along z
601        let ga = Grid::new(9, 9, 9, 1.0, 1.0, 2.0);
602        let da = spatial_dipole_kernel(&ga, (0.0, 0.0, 1.0));
603        assert_eq!(da.len(), 729);
604        assert_eq!(da[0], 0.0);
605        assert!(da.iter().all(|v| v.is_finite()));
606    }
607
608    #[test]
609    #[should_panic(expected = "odd extents")]
610    fn spatial_dipole_kernel_rejects_even_grids() {
611        spatial_dipole_kernel(&Grid::new(8, 9, 9, 1.0, 1.0, 1.0), (0.0, 0.0, 1.0));
612    }
613
614    /// The magnitude weight is used as is; its scale does not change the solution (a power of
615    /// two keeps the floating-point arithmetic exact, so the maps are bit-identical).
616    #[test]
617    fn magnitude_scale_does_not_matter() {
618        let n = 10;
619        let g = test_grid(n);
620        let nt = g.n_total();
621        let field: Vec<f64> = (0..nt).map(|i| ((i as f64) * 0.37).sin() * 0.02).collect();
622        let mag: Vec<f64> = (0..nt).map(|i| 1.0 + ((i as f64) * 0.11).cos().abs()).collect();
623        let mag2: Vec<f64> = mag.iter().map(|v| v * 1024.0).collect();
624        let mask = vec![1u8; nt];
625        let p = LsqrQsmParams { max_iter: 15, ..Default::default() };
626        let a = lsqr_qsm(&field, &mask, Some(&mag), &g, (0.0, 0.0, 1.0), &p, |_, _| {});
627        let b = lsqr_qsm(&field, &mask, Some(&mag2), &g, (0.0, 0.0, 1.0), &p, |_, _| {});
628        for (x, y) in a.iter().zip(&b) {
629            assert_eq!(x.to_bits(), y.to_bits(), "{x} vs {y}");
630        }
631    }
632
633    #[test]
634    fn residual_weighting_scales_with_field_strength() {
635        let p = LsqrQsmParams { b0: 9.4, residual_weighting: None, ..Default::default() };
636        assert!((p.effective_residual_weighting() - 0.2).abs() < 1e-12);
637
638        let p3 = LsqrQsmParams { b0: 3.0, residual_weighting: None, ..Default::default() };
639        assert!((p3.effective_residual_weighting() - 0.2 / 9.4 * 3.0).abs() < 1e-12);
640
641        let fixed = LsqrQsmParams { residual_weighting: Some(0.5), ..Default::default() };
642        assert_eq!(fixed.effective_residual_weighting(), 0.5);
643    }
644}