Skip to main content

qsm_core/inversion/
star.rs

1//! STAR-QSM: streaking artifact reduction for QSM
2//!
3//! [`star_qsm`] implements **STI Suite 3.0's `QSM_star`** (Wei et al. 2015), as called with
4//! `QSM_star(phase, mask, 'TE', TE, 'B0', B0, 'H', H, 'padsize', pad, 'voxelsize', vs)`
5//! with `pad` = [`StarParams::pad`]. Its default, [`STAR_GUI_PAD`] = [8, 8, 20] voxels, is
6//! what STI Suite's `QSM_GUI` passes (see [`StarParams::pad`]); `QSM_star` called without
7//! `'padsize'` pads [`QSM_STAR_DEFAULT_PAD`] = [0, 0, 0].
8//! STI Suite ships as protected P-code, so the algorithm was recovered by black-box probing
9//! (call tracing through logging wrappers around the functions it calls, capturing the arrays
10//! it hands them, then a MATLAB re-implementation that reproduces `QSM_star` to within 4e-16
11//! relative on phantoms and an in-vivo field):
12//!
13//! 1. Zero-pad the phase `φ` (radians at the echo time) and the mask `M` by `pad[a]` voxels on
14//!    both sides of axis `a`. STI's dipole kernel errors on odd extents, so `QSM_star` does
15//!    too; here an odd padded extent grows by one more voxel at the high end instead.
16//!    Everything below runs on that grid.
17//! 2. Dipole kernel `D = 1/3 − (k·H)²/|k|²` (`D = 0` at `k = 0`) with `k` in cycles/mm
18//!    (computed as STI does, to the bit: see `sti_kernel`), and
19//!    the k-space mask `K = |D| ≥ 0.065`. STI builds both on a centred k-grid and transforms
20//!    with centred FFTs (`fftshift ∘ fftn ∘ fftshift`, unitary); on an even grid every operator
21//!    below is the same as the plain FFT convolution used here.
22//! 3. **Initial guess**: `z = M·F⁻¹(D⁺ F(Mφ))` with `D⁺ = 1/D` on `K` and `±1/0.065` off it
23//!    (the sign of `D`, `+` where `D = 0`); then twice `z ← M·F⁻¹(K·F z)`; then
24//!    `x₀ = z / max|z|`. All of it stays complex.
25//! 4. **Solve** `min ½‖A x − Mφ‖² + τ‖x‖₁` (`A x = F⁻¹(D F(M x))`, `τ = 10⁻⁶`, complex `x`)
26//!    from `x₀` with SpaRSA 2.0 (Wright, Nowak & Figueiredo) at the options STI passes:
27//!    `'AT'` = `A` itself (not its adjoint `M F⁻¹ D F`), stopping criterion 0 (stop once fewer
28//!    than 1 % of the components change between zero and non-zero, after at least 5 and at
29//!    most 1000 iterations), and SpaRSA's defaults otherwise: Barzilai–Borwein step
30//!    `α = ‖A Δx‖²/‖Δx‖²` clamped to [10⁻³⁰, 10³⁰], `α = 1` at the start, no monotone or
31//!    safeguard test (every step is accepted), no continuation, no debiasing, and SpaRSA's
32//!    complex soft threshold `x ↦ max(|x| − τ/α, 0)/(max(|x| − τ/α, 0) + τ/α) · x`. STI also
33//!    passes a TV operator as `'PHI'`; SpaRSA ignores a `'PHI'` given without `'PSI'`.
34//!    If `τ ≥ max|A φ|` SpaRSA returns zero.
35//! 5. `χ = 1.1 · Re(x) / (2π·γ·B0·TE)` with γ = 42.575 MHz/T (STI's `ScalingFactor`), cropped
36//!    back to the input grid and masked. The factor 1.1 is a constant in STI's code.
37//!
38//! SpaRSA takes 5–12 iterations on the phantoms below and 5 on the in-vivo field.
39//!
40//! # Units
41//!
42//! Like the other inversions, [`star_qsm`] takes the field in ppm and returns χ in ppm, but
43//! unlike them the result depends on the field's scale: `x₀` is normalised to a peak of one
44//! (in radians), `τ` is fixed and the solve stops after a handful of iterations, so the same
45//! field in ppm gives a different χ at another `TE` or `B0`. [`StarParams::te`] and
46//! [`StarParams::b0`] are therefore part of the method: the field is converted to the phase
47//! `φ = 2π·42.575·B0·TE · field` it would have at the echo time `te` picks from
48//! [`StarParams::echo_times`], which is what STI is given. By default that is the sum of the
49//! echo times: STI Suite's `QSM_GUI` gives `QSM_star` the V-SHARP of the echoes' summed
50//! unwrapped phases with `'TE'` their summed echo times (see [`StarParams::te`]). With
51//! that conversion `star_qsm(field)` is `QSM_star(φ)` exactly (to rounding), 1.1 included.
52//!
53//! # Agreement with STI Suite
54//!
55//! On six 48×56×40 phantoms (3 T and 7 T, TE 5–20 ms, axial and oblique B0, voxels
56//! 0.8×0.9×1.5 mm, `pad` 6, 8 and 12 on every axis) and an in-vivo 7 T local field (164×206×206), χ agrees
57//! with STI Suite's output to rounding: relative L2 difference ≤ 1.1e-14, max |Δχ| ≤ 2.6e-15
58//! ppm, with the same iteration counts. Through STI Suite's GUI (`QSM_GUI`: load, "3D GRE
59//! data", "star QSM", with `QSM_star`'s inputs and output captured) on 64×64×48 at 1×1×1.5 mm,
60//! 80×72×40 at 0.8×0.9×2 mm and 64×64×64 at 1 mm, the default [`STAR_GUI_PAD`] reproduces the
61//! GUI's χ to rounding as well. `tests/star_sti_parity.rs` checks three of the phantoms and the
62//! GUI padding on the anisotropic one (licensed MATLAB R2023b, `tests/data/star_sti_small.m`).
63//! It also checks three 32×36×28 phantoms (3 T axial, anisotropic voxels, oblique 7 T with `pad`
64//! 8) against the verified QSM-CI `star-sti` container (QSMxT/QSM-CI#304), which writes χ as
65//! float32: QSM.rs is within half a float32 ulp of every voxel (relative L2 2.4–3.0e-8, the
66//! float32 rounding; measured 2026-10-30). That fixture is regenerated in Actions by
67//! `tests/data/star_sti_qsmci_small.py`.
68//!
69//! # Performance
70//!
71//! Each SpaRSA iteration costs four 3-D FFTs on the padded grid (`A` twice); the forward
72//! transforms skip the zero rows outside the mask's bounding box and multiply by `D` in their
73//! last pass. The initial guess and setup cost ten more. Memory is three complex and one real
74//! array on the padded grid. On the in-vivo field above (188×230×230 padded, 5 iterations)
75//! it takes 1.2–1.7 s on 16 cores; iLSQR takes 9 s on the same field and cores.
76//!
77//! # Reference pipeline
78//!
79//! What STI Suite 3.0's `QSM_GUI` runs around `QSM_star`, probed headless in MATLAB R2023b
80//! with logging wrappers (3-echo synthetic head, TE 4/10/16 ms, 1×1×1.5 mm). The "3D GRE data"
81//! + "star QSM" buttons and the "Single button for QSM" path call the same functions:
82//!
83//! - **Chain**: `MRPhaseUnwrap` on the raw, unmasked phase of every echo (default `padsize`,
84//!   12 voxels); the unwrapped echoes **summed** (V-SHARP's input is bitwise `sum(φ, 4)`);
85//!   `V_SHARP(Σφ, mask, 'smvsize', 12)` (no deconvolution; see [`crate::bgremove::vsharp`](mod@crate::bgremove::vsharp));
86//!   then `QSM_star` on V-SHARP's eroded mask.
87//! - **TE**: `QSM_star` gets `'TE'` = the **sum** of the echo times (the GUI's "sum_TE" field:
88//!   30 ms for 4 + 10 + 16; the GUI's "Frequency" output is `TissuePhase/(2π·ΣTE)`), so the
89//!   phase it inverts is the summed phase. STAR's result depends on the phase scale (see
90//!   *Units*), so this matters; [`StarParams::te`] defaults to [`EchoTime::Sum`] for this.
91//! - **Padding**: `[8, 8, 20]` from the GUI ([`STAR_GUI_PAD`]). Called **without** `'padsize'`,
92//!   `QSM_star` pads **0**: its output is bitwise equal to `'padsize', [0 0 0]`, and differs from
93//!   `[12 12 12]` by 8–15 % relative, on three grids (64×64×48 at 1×1×1.5 mm and at 1 mm,
94//!   54×60×40 at 0.8×0.9×2 mm). `[12, 12, 12]` ([`STI_TEMPLATES_STAR_PAD`]) is the default that
95//!   `STI_Templates.m` lists, not what the function does.
96//! - **Masking**: the GUI thresholds the magnitude (`Support_Functions/CreateMask.m`: rescale,
97//!   threshold, largest connected component, fill holes; then the P-coded
98//!   `Getmasking`/`PolishMask`), not BET.
99//! - **Data**: the v3.0 manual's test data are 3T multi-echo human brain GRE (0.9×0.9×2 mm,
100//!   3 echoes), single-echo human and mouse brain, and 2D GRE-EPI.
101//!
102//! Reference:
103//! Wei, H., Dibb, R., Zhou, Y., Sun, Y., Xu, J., Wang, N., Liu, C. (2015). "Streaking artifact
104//! reduction for quantitative susceptibility mapping of sources with large dynamic range."
105//! NMR in Biomedicine, 28(10):1294-1303. <https://doi.org/10.1002/nbm.3383>
106//!
107//! Wright, S.J., Nowak, R.D., Figueiredo, M.A.T. (2009). "Sparse reconstruction by separable
108//! approximation." IEEE Trans. Signal Process. 57(7):2479-2493.
109//! <https://doi.org/10.1109/TSP.2009.2016892>
110//!
111//! # Pipeline rules
112//!
113//! The facts above that a configuration can depart from are published as rules in
114//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `sti.unwrap-laplacian`, `sti.laplacian-solver`, `sti.echo-combination`, `sti.echo-weights`, `sti.bg-vsharp`, `sti.vsharp-no-deconvolve`, `sti.vsharp-radius`, `sti.vsharp-grid`, `star.padding`, `star.te-sum`.
115
116use num_complex::Complex64;
117#[cfg(feature = "parallel")]
118use rayon::prelude::*;
119use crate::fft::{Box3, Fft3dWorkspace, FftOpts};
120use crate::{EchoTime, Grid};
121
122/// Parameters for [`star_qsm`]. The defaults are STI Suite's: the GUI's padding, and
123/// `QSM_star`'s defaults otherwise.
124#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
125#[derive(Clone, Debug)]
126pub struct StarParams {
127    /// Zero padding per side in voxels, per axis (x, y, z in voxel axes): STI's `'padsize'`.
128    ///
129    /// Default [`STAR_GUI_PAD`] = `[8, 8, 20]`: what STI Suite 3.0's `QSM_GUI` passes to
130    /// `QSM_star`, so the default gives what STI Suite's GUI users get. It is a constant in the
131    /// GUI, not derived from the data. Probed in MATLAB R2023b by driving `QSM_GUI` headless
132    /// (load, "3D GRE data", "star QSM", and the "Single button for QSM" path) with a logging
133    /// wrapper around `QSM_star`: `'padsize'` was `[8 8 20]` on all 14 grid / voxel-size
134    /// combinations tried (32 to 128 voxels per axis, axes permuted; 0.5 to 3 mm voxels,
135    /// isotropic and anisotropic) and with other mask sizes and positions, field strengths,
136    /// B0 directions and V-SHARP radii. The GUI has no padding control. The 20 is on the third
137    /// voxel axis whatever the B0 direction. (The GUI's phase unwrapping fails on odd matrix
138    /// sizes, so it never gives `QSM_star` an odd grid; see step 1 of the module docs for odd
139    /// grids here.)
140    ///
141    /// [`QSM_STAR_DEFAULT_PAD`] (`[0, 0, 0]`) is what `QSM_star` does when called without
142    /// `'padsize'`. `STI_Templates.m` lists `[12 12 12]` as the default
143    /// ([`STI_TEMPLATES_STAR_PAD`]), but that is not what the function does. Any other padding
144    /// can be given per axis.
145    ///
146    /// **B0 direction (a deliberate deviation from the GUI).** The GUI takes `'H'` from its
147    /// `H_Vector` field, which loading the data does not fill from the scan (the same probe:
148    /// the loader returned an oblique direction and `QSM_star` still got `[0 0 1]`), so GUI
149    /// users get `[0 0 1]` unless they type a direction in. [`star_qsm`] instead takes the
150    /// direction as its `bdir` argument, and the pipeline passes the scan's B0 direction (from
151    /// the affine, as for every other inversion), which is correct for oblique acquisitions.
152    /// For the GUI's result, pass `(0, 0, 1)` explicitly; for axial scans the two agree.
153    pub pad: [usize; 3],
154    /// Echo times in seconds, in acquisition order (default `[0.04]`, STI's 40 ms). The
155    /// pipeline sets them from the scan metadata.
156    pub echo_times: Vec<f64>,
157    /// Which echo time the field is converted to phase at (see the module docs, *Units*).
158    /// Default [`EchoTime::Sum`], as STI Suite 3.0's multi-echo pipeline (`QSM_GUI`) gives
159    /// `QSM_star` its phase. Probed in MATLAB R2023b with logging wrappers, on 3 synthetic
160    /// echoes: loading the data sets its `sum_TE` field (`TE_value`) to `1000·sum(TEs)`;
161    /// "3D GRE data" (`VSHARPCallBackFcn`) runs `V_SHARP` on the sum of the echoes'
162    /// Laplacian-unwrapped phases (`MRPhaseUnwrap`), bit for bit, and divides that tissue
163    /// phase by `2π·ΣTE` for its frequency map; "star QSM" (`starQSMCallBack`) calls
164    /// `QSM_star(TissuePhase, NewMask, 'TE', sum_TE, ...)`. So the phase is the field over
165    /// `ΣTE`. (`STI_Templates.m` line 107's `TE=40` is that field's default.)
166    pub te: EchoTime,
167    /// Field strength in tesla (default 3). The pipeline sets it from the scan metadata.
168    pub b0: f64,
169}
170
171impl Default for StarParams {
172    fn default() -> Self {
173        Self { pad: STAR_GUI_PAD, echo_times: vec![0.04], te: EchoTime::Sum, b0: 3.0 }
174    }
175}
176
177impl StarParams {
178    /// Checks the parameters: the echo time `te` picks must be finite and > 0, and so must
179    /// `b0`.
180    pub fn validate(&self) -> Result<(), String> {
181        self.te.resolve(&self.echo_times).map_err(|e| format!("STAR-QSM: {e}"))?;
182        if !(self.b0.is_finite() && self.b0 > 0.0) {
183            return Err(format!("STAR-QSM field strength must be finite and > 0 T, got {}", self.b0));
184        }
185        Ok(())
186    }
187
188    /// Radians of phase per ppm of field at the echo time `te` picks and B0, with STI's
189    /// γ = 42.575 MHz/T.
190    ///
191    /// # Panics
192    /// If `te` cannot be resolved from `echo_times` ([`validate`](Self::validate) checks).
193    pub fn rad_per_ppm(&self) -> f64 {
194        let te = self.te.seconds(&self.echo_times, "STAR-QSM");
195        2.0 * std::f64::consts::PI * STI_GAMMA_MHZ_PER_T * self.b0 * te
196    }
197}
198
199/// Zero padding STI Suite 3.0's `QSM_GUI` gives `QSM_star`, in voxels per side on each axis,
200/// and the default of [`StarParams::pad`].
201pub const STAR_GUI_PAD: [usize; 3] = [8, 8, 20];
202
203/// Zero padding `QSM_star` uses when called without `'padsize'`, in voxels per side: none.
204/// Probed in MATLAB R2023b on STI Suite 3.0: `QSM_star` without `'padsize'` is bitwise equal to
205/// `'padsize', [0 0 0]` and differs from `[12 12 12]` by 8-15 % relative, on 64×64×48 at
206/// 1×1×1.5 mm and at 1 mm and on 54×60×40 at 0.8×0.9×2 mm.
207pub const QSM_STAR_DEFAULT_PAD: [usize; 3] = [0, 0, 0];
208
209/// Zero padding `STI_Templates.m` lists as `QSM_star`'s default (`padsize = [12 12 12]`), in
210/// voxels per side. Not what the function does without `'padsize'` ([`QSM_STAR_DEFAULT_PAD`]).
211pub const STI_TEMPLATES_STAR_PAD: [usize; 3] = [12, 12, 12];
212
213/// γ/2π in MHz/T as STI Suite's `ScalingFactor` uses it: the reference implementation's value,
214/// not `crate::constants`. Keep it.
215const STI_GAMMA_MHZ_PER_T: f64 = 42.575;
216/// The constant STI multiplies the solution by.
217const STI_CHI_FACTOR: f64 = 1.1;
218/// SpaRSA's L1 weight `τ` as STI passes it.
219const TAU: f64 = 1e-6;
220/// `|D|` below which the initial guess clamps `1/D`, and outside which `K` keeps k-space.
221const KSPACE_THRESHOLD: f64 = 0.065;
222/// SpaRSA's `MaxiterA` as STI passes it.
223const MAX_ITER: usize = 1000;
224/// SpaRSA's default `MiniterA`.
225const MIN_ITER: usize = 5;
226/// SpaRSA's default `ToleranceA` (stopping criterion 0: fraction of components changing).
227const TOL_A: f64 = 0.01;
228/// SpaRSA's default `AlphaMin` / `AlphaMax`.
229const ALPHA_MIN: f64 = 1e-30;
230const ALPHA_MAX: f64 = 1e30;
231
232/// Chunk length of the reductions: fixed, so every sum is independent of the thread count.
233const CHUNK: usize = 1 << 16;
234
235/// STAR-QSM as in STI Suite 3.0 (`QSM_star`).
236///
237/// See the module docs for the algorithm. `field` is the local field in ppm, `bdir` the B0
238/// direction in voxel axes (normalised here). `progress` is called after every SpaRSA
239/// iteration with `(iteration, 1000)`.
240///
241/// # Returns
242/// χ in ppm on the input grid, zero outside the mask.
243///
244/// # Panics
245/// If `params` fails [`StarParams::validate`] (the pipeline checks this first and returns
246/// `InvalidConfig`), or `field` / `mask` do not match `grid`.
247pub fn star_qsm(
248    field: &[f64],
249    mask: &[u8],
250    grid: &Grid,
251    bdir: (f64, f64, f64),
252    params: &StarParams,
253    progress: impl FnMut(usize, usize),
254) -> Vec<f64> {
255    if let Err(e) = params.validate() {
256        panic!("{e}");
257    }
258    let (nx, ny, nz) = grid.dims;
259    let n_in = nx * ny * nz;
260    assert_eq!(field.len(), n_in, "field does not match the grid");
261    assert_eq!(mask.len(), n_in, "mask does not match the grid");
262    let mut chi = vec![0.0; n_in];
263    let p = params.pad;
264    let n = padded_dims([nx, ny, nz], p);
265    let nt = n[0] * n[1] * n[2];
266    let src_dst = |i: usize, j: usize, k: usize| (i + j * nx + k * nx * ny, (i + p[0]) + (j + p[1]) * n[0] + (k + p[2]) * n[0] * n[1]);
267
268    // 1. padded masked phase, mask, and the mask's bounding box on the padded grid
269    let rad = params.rad_per_ppm();
270    let mut y = vec![0.0; nt];
271    let mut inside = vec![false; nt];
272    let (mut lo, mut hi) = ([usize::MAX; 3], [0usize; 3]);
273    for k in 0..nz {
274        for j in 0..ny {
275            for i in 0..nx {
276                let (s, d) = src_dst(i, j, k);
277                if mask[s] != 0 {
278                    y[d] = field[s] * rad;
279                    inside[d] = true;
280                    for (a, c) in [i + p[0], j + p[1], k + p[2]].into_iter().enumerate() {
281                        lo[a] = lo[a].min(c);
282                        hi[a] = hi[a].max(c);
283                    }
284                }
285            }
286        }
287    }
288    if lo[0] == usize::MAX {
289        return chi;
290    }
291    let support: Box3 = [0, 1, 2].map(|a| lo[a]..hi[a] + 1);
292
293    // 2. dipole kernel on the padded grid
294    let vs = grid.voxel_size;
295    let dk = sti_kernel(n, [vs.0, vs.1, vs.2], bdir);
296    let mut ws = Fft3dWorkspace::new(n[0], n[1], n[2]);
297
298    // 3. initial guess
299    let Some(mut x) = initial_guess(&y, &inside, &dk, &support, &mut ws) else {
300        return chi;
301    };
302
303    // 4. SpaRSA with A = F⁻¹ D F M (also as Aᵀ)
304    let mut apply_a = |v: &mut [Complex64]| {
305        ws.fft3d_with(v, &FftOpts { support: Some(&support), post: Some(&dk), ..FftOpts::default() });
306        ws.ifft3d(v);
307    };
308    sparsa(&mut x, &y, &inside, TAU, MAX_ITER, &mut apply_a, progress);
309
310    // 5. scale, crop, mask
311    let scale = STI_CHI_FACTOR * (1.0 / rad);
312    for k in 0..nz {
313        for j in 0..ny {
314            for i in 0..nx {
315                let (s, d) = src_dst(i, j, k);
316                if mask[s] != 0 {
317                    chi[s] = scale * x[d].re;
318                }
319            }
320        }
321    }
322    chi
323}
324
325/// STI's dipole kernel (`calcD2Matrix`) in unshifted order: `D = 1/3 − (k·H)²/|k|²`, 0 at
326/// `k = 0`, with `k_d = f/(n_d·vs_d)` for the signed frequency index `f` (−n/2 at Nyquist) and
327/// `H` normalised.
328///
329/// It is [`dipole_kernel`](crate::kernels::dipole::dipole_kernel)'s formula, but evaluated the
330/// way STI does, bit for bit: `k` by division rather than by multiplying with `1/(n·vs)`. The
331/// two differ in the last bit of `k` on anisotropic grids, which flips the sign of `D` at
332/// k-space points exactly on the magic-angle cone, and the initial guess's `±1/0.065` with it
333/// (a 3e-6 relative change in χ on the 0.8×0.9×1.5 mm phantom).
334fn sti_kernel(n: [usize; 3], vs: [f64; 3], bdir: (f64, f64, f64)) -> Vec<f64> {
335    let hn = (bdir.0 * bdir.0 + bdir.1 * bdir.1 + bdir.2 * bdir.2).sqrt();
336    let h = [bdir.0 / hn, bdir.1 / hn, bdir.2 / hn];
337    let k: Vec<Vec<f64>> = (0..3)
338        .map(|a| {
339            (0..n[a])
340                .map(|i| {
341                    let f = if i < n[a] / 2 { i as f64 } else { i as f64 - n[a] as f64 };
342                    f / (n[a] as f64 * vs[a])
343                })
344                .collect()
345        })
346        .collect();
347    let mut d = vec![0.0; n[0] * n[1] * n[2]];
348    crate::maybe_par_chunks_mut!(d, n[0] * n[1]).enumerate().for_each(|(kz, plane)| {
349        let z = k[2][kz];
350        for (l, v) in plane.iter_mut().enumerate() {
351            let (x, y) = (k[0][l % n[0]], k[1][l / n[0]]);
352            let k2 = x * x + y * y + z * z;
353            if k2 != 0.0 {
354                let kh = x * h[0] + y * h[1] + z * h[2];
355                *v = 1.0 / 3.0 - kh * kh / k2;
356            }
357        }
358    });
359    d
360}
361
362/// Padded grid: `pad[a]` voxels on both sides of axis `a`, plus one at the high end where
363/// that is odd.
364fn padded_dims(dims: [usize; 3], pad: [usize; 3]) -> [usize; 3] {
365    [0, 1, 2].map(|a| dims[a] + 2 * pad[a] + (dims[a] % 2))
366}
367
368/// Step 3: `x₀ = z / max|z|` with `z = M F⁻¹(K F(M F⁻¹(K F(M F⁻¹(D⁺ F y))))))`. `y` is zero
369/// outside `inside`, which lies in `support`. `None` if `z` is zero.
370fn initial_guess(
371    y: &[f64],
372    inside: &[bool],
373    dk: &[f64],
374    support: &Box3,
375    ws: &mut Fft3dWorkspace,
376) -> Option<Vec<Complex64>> {
377    let mut z: Vec<Complex64> = crate::maybe_par_iter!(y).map(|&v| Complex64::new(v, 0.0)).collect();
378    let opts = FftOpts { support: Some(support), ..FftOpts::default() };
379    ws.fft3d_with(&mut z, &opts);
380    crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(dk)).for_each(|(v, &d)| *v *= dinv(d));
381    ws.ifft3d(&mut z);
382    mask_inplace(&mut z, inside);
383    for _ in 0..2 {
384        ws.fft3d_with(&mut z, &opts);
385        crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(dk)).for_each(|(v, &d)| {
386            if d.abs() < KSPACE_THRESHOLD {
387                *v = Complex64::new(0.0, 0.0);
388            }
389        });
390        ws.ifft3d(&mut z);
391        mask_inplace(&mut z, inside);
392    }
393    let peak = z.iter().map(|v| v.norm()).fold(0.0, f64::max);
394    if peak == 0.0 {
395        return None;
396    }
397    crate::maybe_par_iter_mut!(z).for_each(|v| *v /= peak);
398    Some(z)
399}
400
401/// The initial guess's inverse kernel: `1/D` where `|D| ≥ 0.065`, else `±1/0.065` by the sign
402/// of `D` (`+` at `D = 0`).
403fn dinv(d: f64) -> f64 {
404    if d.abs() >= KSPACE_THRESHOLD {
405        1.0 / d
406    } else if d < 0.0 {
407        -1.0 / KSPACE_THRESHOLD
408    } else {
409        1.0 / KSPACE_THRESHOLD
410    }
411}
412
413fn mask_inplace(z: &mut [Complex64], inside: &[bool]) {
414    crate::maybe_par_iter_mut!(z).zip(crate::maybe_par_iter!(inside)).for_each(|(v, &m)| {
415        if !m {
416            *v = Complex64::new(0.0, 0.0);
417        }
418    });
419}
420
421/// SpaRSA's complex soft threshold (`soft.m`): `max(|v| − t, 0)/(max(|v| − t, 0) + t) · v`.
422#[inline]
423fn soft(v: Complex64, t: f64) -> Complex64 {
424    if t == 0.0 {
425        return v;
426    }
427    let s = (v.norm() - t).max(0.0);
428    v * (s / (s + t))
429}
430
431#[inline]
432fn nsq(z: Complex64) -> f64 {
433    z.re * z.re + z.im * z.im
434}
435
436/// SpaRSA 2.0 at STI's options (module docs, step 4) for `A x = op(M x)`, `Aᵀ` = `A`, with
437/// `y` zero outside `inside`. `x` holds the initial guess on entry and the solution on return.
438/// `apply_core` applies `op` in place. Returns the number of iterations (0 if `τ ≥ max|A y|`,
439/// when `x` is set to zero as SpaRSA does).
440fn sparsa(
441    x: &mut [Complex64],
442    y: &[f64],
443    inside: &[bool],
444    tau: f64,
445    max_iter: usize,
446    apply_core: &mut dyn FnMut(&mut [Complex64]),
447    mut progress: impl FnMut(usize, usize),
448) -> usize {
449    let nt = x.len();
450    let zero = Complex64::new(0.0, 0.0);
451    // resid and a work buffer (Aᵀ resid, then the masked step, then A·step)
452    let mut r: Vec<Complex64> = crate::maybe_par_iter!(y).map(|&v| Complex64::new(v, 0.0)).collect();
453    let mut g = vec![zero; nt];
454
455    // SpaRSA returns zero when τ ≥ max|Aᵀy| (y is already masked)
456    apply_core(&mut r);
457    let max_tau = r.iter().map(|v| v.norm()).fold(0.0, f64::max);
458    if tau >= max_tau {
459        x.fill(zero);
460        return 0;
461    }
462
463    // resid = A x − y; then g = M·resid, the input of Aᵀ resid
464    crate::maybe_par_iter_mut!(r).zip(crate::maybe_par_iter!(x)).zip(crate::maybe_par_iter!(inside))
465        .for_each(|((rv, &xv), &m)| *rv = if m { xv } else { zero });
466    apply_core(&mut r);
467    crate::maybe_par_iter_mut!(r).zip(crate::maybe_par_iter_mut!(g)).zip(crate::maybe_par_iter!(y)).zip(crate::maybe_par_iter!(inside))
468        .for_each(|(((rv, gv), &yv), &m)| {
469            *rv -= yv;
470            *gv = if m { *rv } else { zero };
471        });
472
473    let mut nz: Vec<bool> = crate::maybe_par_iter!(x).map(|v| *v != zero).collect();
474    let mut alpha = 1.0;
475    let mut iter = 1usize;
476    let mut keep_going = true;
477    while keep_going {
478        apply_core(&mut g); // gradq = Aᵀ resid
479
480        // x ← soft(x − gradq/α, τ/α); g ← M·Δx; ‖Δx‖² and the active-set change
481        let (inv_alpha, t) = (1.0 / alpha, tau / alpha);
482        let parts: Vec<(f64, usize, usize)> = crate::maybe_par_chunks_mut!(x, CHUNK)
483            .zip(crate::maybe_par_chunks_mut!(g, CHUNK))
484            .zip(crate::maybe_par_chunks_mut!(nz, CHUNK))
485            .zip(crate::maybe_par_chunks!(inside, CHUNK))
486            .map(|(((xc, gc), nc), mc)| {
487                let (mut dd, mut changed, mut nnz) = (0.0, 0usize, 0usize);
488                for (((xv, gv), nv), &m) in xc.iter_mut().zip(gc.iter_mut()).zip(nc.iter_mut()).zip(mc) {
489                    let xn = soft(*xv - *gv * inv_alpha, t);
490                    let dx = xn - *xv;
491                    *xv = xn;
492                    dd += nsq(dx);
493                    *gv = if m { dx } else { zero };
494                    let now = xn != zero;
495                    changed += (now != *nv) as usize;
496                    nnz += now as usize;
497                    *nv = now;
498                }
499                (dd, changed, nnz)
500            })
501            .collect();
502        let dd: f64 = parts.iter().map(|p| p.0).sum();
503        let changed: usize = parts.iter().map(|p| p.1).sum();
504        let nnz: usize = parts.iter().map(|p| p.2).sum();
505
506        apply_core(&mut g); // A Δx
507
508        // resid += A Δx, ‖A Δx‖², then g = M·resid for the next Aᵀ resid
509        let dgd: f64 = crate::maybe_par_chunks_mut!(r, CHUNK)
510            .zip(crate::maybe_par_chunks_mut!(g, CHUNK))
511            .zip(crate::maybe_par_chunks!(inside, CHUNK))
512            .map(|((rc, gc), mc)| {
513                let mut s = 0.0;
514                for ((rv, gv), &m) in rc.iter_mut().zip(gc.iter_mut()).zip(mc) {
515                    s += nsq(*gv);
516                    *rv += *gv;
517                    *gv = if m { *rv } else { zero };
518                }
519                s
520            })
521            .collect::<Vec<f64>>()
522            .iter()
523            .sum();
524
525        // min(αmax, max(αmin, ·)) as MATLAB evaluates it, where max(αmin, NaN) is αmin
526        let bb = dgd / (f64::MIN_POSITIVE + dd);
527        alpha = if bb.is_nan() { ALPHA_MIN } else { bb.clamp(ALPHA_MIN, ALPHA_MAX) };
528        iter += 1;
529        if nnz >= 1 {
530            keep_going = changed as f64 / nnz as f64 > TOL_A;
531        }
532        if iter <= MIN_ITER {
533            keep_going = true;
534        } else if iter > max_iter {
535            keep_going = false;
536        }
537        progress(iter - 1, max_iter);
538    }
539    iter - 1
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::kernels::dipole::dipole_kernel;
546    use std::f64::consts::PI;
547
548    /// fftshift index map on one axis (even n: its own inverse).
549    fn shift(i: usize, n: usize) -> usize {
550        (i + n / 2) % n
551    }
552
553    /// STI's kernel: `D = 1/3 − (k·H)²/|k|²` on the centred grid `k_d = (−n/2 … n/2−1)/(n·vs)`.
554    fn centred_kernel(n: [usize; 3], vs: [f64; 3], h: [f64; 3]) -> Vec<f64> {
555        let mut d = vec![0.0; n[0] * n[1] * n[2]];
556        for k in 0..n[2] {
557            for j in 0..n[1] {
558                for i in 0..n[0] {
559                    let kv = [i, j, k].iter().enumerate()
560                        .map(|(a, &c)| (c as f64 - (n[a] / 2) as f64) / (n[a] as f64 * vs[a]))
561                        .collect::<Vec<_>>();
562                    let k2: f64 = kv.iter().map(|v| v * v).sum();
563                    let kh: f64 = kv.iter().zip(h).map(|(a, b)| a * b).sum();
564                    d[i + j * n[0] + k * n[0] * n[1]] = if k2 == 0.0 { 0.0 } else { 1.0 / 3.0 - kh * kh / k2 };
565                }
566            }
567        }
568        d
569    }
570
571    /// STI's centred unitary transform, `fftshift(fftn(fftshift(x)))/sqrt(N)` (or the inverse),
572    /// as a direct sum.
573    fn fftnc_naive(x: &[Complex64], n: [usize; 3], inverse: bool) -> Vec<Complex64> {
574        let nt = x.len();
575        let sgn = if inverse { 1.0 } else { -1.0 };
576        let mut out = vec![Complex64::new(0.0, 0.0); nt];
577        for (o, ov) in out.iter_mut().enumerate() {
578            let oc = [o % n[0], (o / n[0]) % n[1], o / (n[0] * n[1])];
579            // output index o of the shifted spectrum is frequency index shift(oc)
580            let f = [0, 1, 2].map(|a| shift(oc[a], n[a]));
581            let mut s = Complex64::new(0.0, 0.0);
582            for (l, &xv) in x.iter().enumerate() {
583                let lc = [l % n[0], (l / n[0]) % n[1], l / (n[0] * n[1])];
584                let t = [0, 1, 2].map(|a| shift(lc[a], n[a]));
585                let ph: f64 = (0..3).map(|a| (f[a] * t[a]) as f64 / n[a] as f64).sum();
586                s += xv * Complex64::from_polar(1.0, sgn * 2.0 * PI * ph);
587            }
588            *ov = s / (nt as f64).sqrt();
589        }
590        out
591    }
592
593    #[test]
594    fn kernel_is_stis_centred_kernel_shifted() {
595        // even extents, anisotropic voxels, oblique H: the Nyquist rows (k = −n/2) carry the
596        // sign of the cross terms, so they must sit at −1/(2·vs) as on STI's grid
597        let (n, vs) = ([6, 8, 4], [0.8, 0.9, 1.5]);
598        let h = [0.3, -0.4, 0.5f64.sqrt() * 1.2];
599        let hn = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt();
600        let c = centred_kernel(n, vs, h.map(|v| v / hn));
601        let d = sti_kernel(n, vs, (h[0], h[1], h[2]));
602        let g = dipole_kernel(&Grid::new(n[0], n[1], n[2], vs[0], vs[1], vs[2]), (h[0], h[1], h[2]));
603        for k in 0..n[2] {
604            for j in 0..n[1] {
605                for i in 0..n[0] {
606                    let l = i + j * n[0] + k * n[0] * n[1];
607                    let b = c[shift(i, n[0]) + shift(j, n[1]) * n[0] + shift(k, n[2]) * n[0] * n[1]];
608                    assert_eq!(d[l], b, "({i},{j},{k})");
609                    assert!((g[l] - b).abs() < 1e-15, "the library kernel is the same up to rounding");
610                }
611            }
612        }
613    }
614
615    #[test]
616    fn padding_is_symmetric_and_even() {
617        assert_eq!(padded_dims([48, 56, 40], [12; 3]), [72, 80, 64]);
618        assert_eq!(padded_dims([47, 56, 39], [6; 3]), [60, 68, 52]);
619        assert_eq!(padded_dims([1, 2, 3], [0; 3]), [2, 2, 4]);
620        assert_eq!(padded_dims([64, 64, 48], STAR_GUI_PAD), [80, 80, 88]);
621    }
622
623    #[test]
624    fn inverse_kernel_and_soft_threshold() {
625        assert_eq!(dinv(0.5), 2.0);
626        assert_eq!(dinv(-0.065), -1.0 / 0.065);
627        assert_eq!(dinv(0.01), 1.0 / 0.065);
628        assert_eq!(dinv(-0.01), -1.0 / 0.065);
629        assert_eq!(dinv(0.0), 1.0 / 0.065);
630        let v = Complex64::new(3.0, 4.0);
631        assert_eq!(soft(v, 0.0), v);
632        assert_eq!(soft(v, 5.0), Complex64::new(0.0, 0.0));
633        assert_eq!(soft(v, 7.0), Complex64::new(0.0, 0.0));
634        let s = soft(v, 1.0); // |v| = 5 → 4, same phase
635        assert!((s - Complex64::new(2.4, 3.2)).norm() < 1e-15);
636    }
637
638    /// The initial guess as the spec writes it (centred transforms by direct sums, STI's
639    /// centred kernel) against [`initial_guess`] (plain FFTs on the unshifted grid).
640    #[test]
641    fn initial_guess_matches_stis_centred_formulation() {
642        let (n, vs) = ([4, 6, 4], [1.0, 0.9, 1.2]);
643        let h = [0.0, 0.6, 0.8];
644        let nt = n[0] * n[1] * n[2];
645        let idx = |i: usize, j: usize, k: usize| i + j * n[0] + k * n[0] * n[1];
646        let mut inside = vec![false; nt];
647        let mut y = vec![0.0; nt];
648        for k in 1..3 {
649            for j in 1..5 {
650                for i in 1..3 {
651                    inside[idx(i, j, k)] = true;
652                    y[idx(i, j, k)] = ((i * 7 + j * 3 + k * 5) % 11) as f64 * 0.1 - 0.4;
653                }
654            }
655        }
656        // spec: z = ifftnc(fftnc(y) .* Dinv) .* M, twice z = ifftnc(K .* fftnc(z)) .* M
657        let c = centred_kernel(n, vs, h);
658        let yc: Vec<Complex64> = y.iter().map(|&v| Complex64::new(v, 0.0)).collect();
659        let mut z: Vec<Complex64> = fftnc_naive(&yc, n, false).iter().zip(&c).map(|(v, &d)| v * dinv(d)).collect();
660        z = fftnc_naive(&z, n, true);
661        mask_inplace(&mut z, &inside);
662        for _ in 0..2 {
663            let f: Vec<Complex64> = fftnc_naive(&z, n, false).iter().zip(&c)
664                .map(|(v, &d)| if d.abs() >= KSPACE_THRESHOLD { *v } else { Complex64::new(0.0, 0.0) }).collect();
665            z = fftnc_naive(&f, n, true);
666            mask_inplace(&mut z, &inside);
667        }
668        let peak = z.iter().map(|v| v.norm()).fold(0.0, f64::max);
669        let want: Vec<Complex64> = z.iter().map(|v| v / peak).collect();
670
671        let dk = sti_kernel(n, vs, (h[0], h[1], h[2]));
672        let support: Box3 = [1..3, 1..5, 1..3];
673        let mut ws = Fft3dWorkspace::new(n[0], n[1], n[2]);
674        let got = initial_guess(&y, &inside, &dk, &support, &mut ws).unwrap();
675        let peak_got = got.iter().map(|v| v.norm()).fold(0.0, f64::max);
676        assert!((peak_got - 1.0).abs() < 1e-15);
677        assert!(got.iter().any(|v| v.im.abs() > 1e-6), "the initial guess stays complex");
678        for l in 0..nt {
679            assert!((got[l] - want[l]).norm() < 1e-12, "{l}: {} vs {}", got[l], want[l]);
680            if !inside[l] {
681                assert_eq!(got[l], Complex64::new(0.0, 0.0));
682            }
683        }
684        // a zero field has no initial guess
685        assert!(initial_guess(&vec![0.0; nt], &inside, &dk, &support, &mut ws).is_none());
686    }
687
688    /// SpaRSA.m transcribed line by line (unfused, one array per MATLAB variable) at STI's
689    /// options, for `A v = op(M v)` and `Aᵀ = A`.
690    fn sparsa_reference(
691        y: &[f64],
692        inside: &[bool],
693        op: &dyn Fn(&[Complex64]) -> Vec<Complex64>,
694        tau: f64,
695        max_iter: usize,
696        x0: &[Complex64],
697    ) -> (Vec<Complex64>, usize) {
698        let zero = Complex64::new(0.0, 0.0);
699        let a = |v: &[Complex64]| op(&v.iter().zip(inside).map(|(z, &m)| if m { *z } else { zero }).collect::<Vec<_>>());
700        let yc: Vec<Complex64> = y.iter().map(|&v| Complex64::new(v, 0.0)).collect();
701        let max_tau = a(&yc).iter().map(|v| v.norm()).fold(0.0, f64::max);
702        if tau >= max_tau {
703            return (vec![zero; y.len()], 0);
704        }
705        let mut x = x0.to_vec();
706        let mut nz_x: Vec<bool> = x.iter().map(|v| *v != zero).collect();
707        let mut resid: Vec<Complex64> = a(&x).iter().zip(&yc).map(|(p, q)| p - q).collect();
708        let (mut alpha, mut iter, mut keep_going) = (1.0f64, 1usize, true);
709        while keep_going {
710            let gradq = a(&resid);
711            let prev_x = x.clone();
712            let prev_resid = resid.clone();
713            x = prev_x.iter().zip(&gradq).map(|(p, g)| soft(p - g * (1.0 / alpha), tau / alpha)).collect();
714            let dx: Vec<Complex64> = x.iter().zip(&prev_x).map(|(p, q)| p - q).collect();
715            let adx = a(&dx);
716            resid = prev_resid.iter().zip(&adx).map(|(p, q)| p + q).collect();
717            let dd: f64 = dx.iter().map(|v| nsq(*v)).sum();
718            let dgd: f64 = adx.iter().map(|v| nsq(*v)).sum();
719            alpha = ALPHA_MAX.min(ALPHA_MIN.max(dgd / (f64::MIN_POSITIVE + dd)));
720            iter += 1;
721            let nz_x_prev = std::mem::replace(&mut nz_x, x.iter().map(|v| v.norm() != 0.0).collect());
722            let num_nz_x = nz_x.iter().filter(|&&b| b).count();
723            let num_changes_active = nz_x.iter().zip(&nz_x_prev).filter(|(p, q)| p != q).count();
724            if num_nz_x >= 1 {
725                keep_going = num_changes_active as f64 / num_nz_x as f64 > TOL_A;
726            }
727            if iter <= MIN_ITER {
728                keep_going = true;
729            } else if iter > max_iter {
730                keep_going = false;
731            }
732        }
733        (x, iter - 1)
734    }
735
736    /// Circular convolution along the vector (a non-diagonal, non-symmetric `op`).
737    fn conv(v: &[Complex64]) -> Vec<Complex64> {
738        let h = [1.0, 0.45, -0.3, 0.1];
739        let n = v.len();
740        (0..n).map(|i| h.iter().enumerate().map(|(k, &c)| v[(i + n - k) % n] * c).sum()).collect()
741    }
742
743    fn small_problem(n: usize) -> (Vec<f64>, Vec<bool>, Vec<Complex64>) {
744        let y: Vec<f64> = (0..n).map(|i| if i % 5 == 3 { 0.0 } else { ((i * 37 + 11) % 17) as f64 / 17.0 - 0.45 }).collect();
745        let inside: Vec<bool> = (0..n).map(|i| i % 7 != 6).collect();
746        let x0: Vec<Complex64> = (0..n).map(|i| if inside[i] { Complex64::new(((i * 13) % 7) as f64 * 0.1 - 0.3, ((i * 5) % 3) as f64 * 0.05) } else { Complex64::new(0.0, 0.0) }).collect();
747        (y, inside, x0)
748    }
749
750    /// The fused solver takes the same steps as the transcription, including its stopping
751    /// rule (active-set change ≤ 1 % after at least 5 iterations) and the cap.
752    #[test]
753    fn sparsa_matches_a_transcription_of_sparsa_m() {
754        let mut lens = Vec::new();
755        for (n, tau, cap) in [(40, 1e-3, 1000), (40, 3e-2, 1000), (64, 1e-6, 1000), (64, 2e-2, 1000), (64, 2e-2, 6)] {
756            let (y, inside, x0) = small_problem(n);
757            let (want, want_it) = sparsa_reference(&y, &inside, &conv, tau, cap, &x0);
758            let mut x = x0.clone();
759            let mut seen = Vec::new();
760            let it = sparsa(&mut x, &y, &inside, tau, cap, &mut |v: &mut [Complex64]| { let c = conv(v); v.copy_from_slice(&c); }, |i, m| seen.push((i, m)));
761            assert_eq!(it, want_it, "n {n} tau {tau}");
762            assert_eq!(seen, (1..=it).map(|i| (i, cap)).collect::<Vec<_>>());
763            for l in 0..n {
764                assert!((x[l] - want[l]).norm() <= 1e-14 * (1.0 + want[l].norm()), "n {n} tau {tau} [{l}]: {} vs {}", x[l], want[l]);
765            }
766            lens.push(it);
767        }
768        assert!(lens.iter().all(|&i| i >= MIN_ITER));
769        assert_eq!(lens[4], 6, "the cap ends the run");
770        assert!(lens[3] > 6, "the capped problem runs longer uncapped: {lens:?}");
771    }
772
773    /// SpaRSA applies `MiniterA` before `MaxiterA`, so a cap below 5 still runs 5 iterations.
774    #[test]
775    fn sparsa_minimum_overrides_a_lower_cap() {
776        let y = vec![0.5, 0.0, 1.0];
777        let inside = vec![true; 3];
778        let mut x = vec![Complex64::new(0.3, 0.0); 3];
779        assert_eq!(sparsa(&mut x, &y, &inside, 1e-3, 3, &mut |_| {}, |_, _| {}), MIN_ITER);
780    }
781
782    #[test]
783    fn sparsa_returns_zero_when_tau_dominates() {
784        let y = vec![1e-7, 0.0];
785        let inside = vec![true, true];
786        let mut x = vec![Complex64::new(1.0, 0.0); 2];
787        assert_eq!(sparsa(&mut x, &y, &inside, 1e-6, 1000, &mut |_| {}, |_, _| {}), 0);
788        assert!(x.iter().all(|v| *v == Complex64::new(0.0, 0.0)));
789    }
790
791    #[test]
792    fn empty_mask_zero_field_and_validation() {
793        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
794        let p = StarParams::default();
795        assert_eq!(star_qsm(&[1.0; 64], &[0; 64], &grid, (0.0, 0.0, 1.0), &p, |_, _| {}), vec![0.0; 64]);
796        assert_eq!(star_qsm(&[0.0; 64], &[1; 64], &grid, (0.0, 0.0, 1.0), &p, |_, _| {}), vec![0.0; 64]);
797        assert!(p.validate().is_ok());
798        for bad in [
799            StarParams { te: EchoTime::Seconds(0.0), ..StarParams::default() },
800            StarParams { echo_times: vec![], ..StarParams::default() },
801            StarParams { b0: f64::NAN, ..StarParams::default() },
802        ] {
803            assert!(bad.validate().is_err());
804        }
805        let p = StarParams { echo_times: vec![0.02], te: EchoTime::First, b0: 3.0, pad: [0; 3] };
806        assert!((p.rad_per_ppm() - 2.0 * PI * 42.575 * 0.06).abs() < 1e-12);
807        // STI Suite's default: the phase summed over the echoes, at ΣTE.
808        let p = StarParams { echo_times: vec![0.004, 0.008, 0.012], ..StarParams::default() };
809        assert!((p.rad_per_ppm() - 2.0 * PI * 42.575 * 3.0 * 0.024).abs() < 1e-12);
810    }
811
812    /// A sphere's field on an odd grid: the padded grid grows to even, and χ comes out with
813    /// the sphere's sign and magnitude, zero outside the mask.
814    #[test]
815    fn recovers_a_sphere_on_an_odd_grid() {
816        let (nx, ny, nz) = (15, 16, 13);
817        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
818        let nt = nx * ny * nz;
819        let mut chi0 = vec![0.0; nt];
820        let mut mask = vec![0u8; nt];
821        let c = [7.0, 7.5, 6.0];
822        for k in 0..nz {
823            for j in 0..ny {
824                for i in 0..nx {
825                    let r2 = (i as f64 - c[0]).powi(2) + (j as f64 - c[1]).powi(2) + (k as f64 - c[2]).powi(2);
826                    let l = i + j * nx + k * nx * ny;
827                    if r2 < 2.5f64.powi(2) { chi0[l] = 0.1; }
828                    if r2 < 5.5f64.powi(2) { mask[l] = 1; }
829                }
830            }
831        }
832        // field of chi0 (circular convolution on an even grid big enough not to wrap)
833        let (mx, my, mz) = (32, 32, 32);
834        let big = Grid::new(mx, my, mz, 1.0, 1.0, 1.0);
835        let dk = dipole_kernel(&big, (0.0, 0.0, 1.0));
836        let mut buf = vec![Complex64::new(0.0, 0.0); mx * my * mz];
837        for k in 0..nz { for j in 0..ny { for i in 0..nx { buf[i + j * mx + k * mx * my].re = chi0[i + j * nx + k * nx * ny]; } } }
838        let mut ws = Fft3dWorkspace::new(mx, my, mz);
839        ws.fft3d(&mut buf);
840        buf.iter_mut().zip(&dk).for_each(|(v, &d)| *v *= d);
841        ws.ifft3d(&mut buf);
842        let field: Vec<f64> = (0..nt).map(|l| {
843            let (i, j, k) = (l % nx, (l / nx) % ny, l / (nx * ny));
844            if mask[l] != 0 { buf[i + j * mx + k * mx * my].re } else { 0.0 }
845        }).collect();
846
847        let mut iters = 0;
848        let chi = star_qsm(&field, &mask, &grid, (0.0, 0.0, 1.0), &StarParams { pad: [6; 3], ..StarParams::default() }, |i, _| iters = i);
849        assert!(iters >= MIN_ITER);
850        let centre = chi[7 + 7 * nx + 6 * nx * ny];
851        assert!(centre > 0.05 && centre < 0.2, "centre χ {centre}");
852        for l in 0..nt {
853            if mask[l] == 0 { assert_eq!(chi[l], 0.0); }
854        }
855    }
856}