Skip to main content

qsm_core/inversion/
ilsqr.rs

1//! iLSQR: LSQR dipole inversion with streaking-artefact removal
2//!
3//! [`ilsqr`] implements **STI Suite 3.0's `QSM_iLSQR`** (Li et al. 2015), as called with
4//! `QSM_iLSQR(phase, mask, 'TE', TE, 'B0', B0, 'H', H, 'padsize', [p p p], 'voxelsize', vs)`
5//! with `p` = [`IlsqrParams::pad_mm`]. The defaults are [`STI_DEFAULT_NITER`] = 100 iterations,
6//! `QSM_iLSQR`'s own default, which applies when it is called as a function without `'niter'`
7//! (as UK Biobank's pipeline and STI Suite's `STI_Templates.m` call it), and [`STI_GUI_PAD_MM`]
8//! = 12 mm of padding, what STI Suite 3.0's `QSM_GUI` passes (see [`IlsqrParams`]). The GUI
9//! passes [`STI_GUI_NITER`] = 40 iterations. Called without `'padsize'`, `QSM_iLSQR` pads [`STI_DEFAULT_PAD_MM`] = 6;
10//! UK Biobank and STI Suite's examples pass 64, [`STI_PAD_MM`].
11//! STI Suite ships as protected P-code, so the algorithm was recovered by black-box probing
12//! (call tracing, capturing the operators and right-hand sides it hands to MATLAB's `lsqr`, and
13//! fitting every stage until a MATLAB re-implementation reproduced STI to its own
14//! single-precision noise floor):
15//!
16//! 1. Crop field and mask to the mask's bounding box (odd extents grown by one voxel to make
17//!    them even), then zero-pad each axis to the next multiple of 16 strictly above
18//!    `crop + 2·pad_mm/voxel_size` (default `pad_mm` = 12). Everything below runs on that grid.
19//! 2. **Laplacian weights** `W`: `L = |F⁻¹(k²·F φ)|` with `k²` in (cycles/mm)² and the DC term
20//!    set to 10⁶ (STI's value, which adds `10⁶·mean(φ)` to every voxel, so the `|·|` decides
21//!    which tail of the Laplacian is down-weighted). Ranked inside the mask:
22//!    `W = 1` below the 60th percentile, falling linearly to `0.2` at the 99.9th, `0` outside.
23//!    The field itself is not re-masked.
24//! 3. **Initial solution**: LSQR on `D W D χ = D W φ` (`D` = dipole convolution), tolerance
25//!    0.01, at most `max_iter` iterations (MATLAB `lsqr` stopping rules).
26//! 4. **FastQSM** prior: `sign(D)·Fφ`, blended where `|D|` is small with a copy smoothed *in
27//!    k-space* by a radius-3 ball closed with IPT's `imclose(·, [1 1 1])` (which, on this ball,
28//!    only fills its centre), masked, blended again, rescaled to a TKD (threshold 1/8)
29//!    solution by least squares inside the mask.
30//! 5. **Gradient weights** `G_d` (`d` = x, y, z): forward differences of the FastQSM map (no
31//!    voxel-size scaling), pooled over the three axes inside the mask; `G = 1` below the 50th
32//!    percentile of `|∇|`, `0` above the 70th, linear between.
33//! 6. **Streaking artefacts**: LSQR for `y` in `min ‖G ∇ F⁻¹(M_ic y) − G ∇(M·Re χ₁)‖` with
34//!    `M_ic = |D| < 0.1` (the ill-conditioned cone), tolerance `tol` (default 1e-3), at most
35//!    `max_iter` iterations; artefact `χ_sa = Re F⁻¹(M_ic y)`.
36//! 7. `χ = (Re χ₁ − χ_sa) · mask`, cropped back to the input grid.
37//!
38//! Percentiles are STI's: the sorted value at 1-based index `round(p·N)`, not interpolated.
39//! The field may be in any linear unit (rad, Hz, ppm); `χ` comes out in the unit the field
40//! would have for χ = 1 (QSM.rs passes ppm, so χ is in ppm).
41//!
42//! # Performance and precision
43//!
44//! Nearly all the time goes into the two LSQR solves, each costing two 3-D FFTs on the padded
45//! grid per operator call. The initial solve runs in k-space (2 FFTs per call instead of 4, the
46//! same iterates up to rounding), the transforms are pruned to the box holding the mask and
47//! carry the solves' diagonal products in their first and last passes, and the artefact solve
48//! stores its vectors on the cone and the mask only. On a UK Biobank SWI field (352×400×96
49//! padded) this takes ~15 s on 16 cores with a 1.9 GB peak. Against the image-space
50//! formulation of the initial solve, the k-space one gives the same stop flags and iteration
51//! counts in both solves there, and χ within a relative L2 of 5.8e-9 (the artefact solve
52//! amplifying rounding-level differences of the initial solution, which agree to 2.7e-15).
53//!
54//! By default everything runs in double precision. STI Suite runs its two LSQR solves in single
55//! precision: their operators (single FFTs, `single(D)`, `single(W)`, `single(G)`), and
56//! MATLAB's `lsqr` on single vectors and scalars, with the right-hand sides computed in double
57//! and rounded to single (probed: STI's initial solve is reproduced to 2e-8 that way, against
58//! 7e-4 in double). [`IlsqrPrecision::Single`] does the same, for parity with STI; it runs the
59//! two solves on dense vectors over the padded grid (no pruned transforms), so it is slower
60//! and needs more memory. Double precision stays the default (QSM.rs keeps double where the
61//! reference uses single and it does no harm): on whole heads both agree with STI equally
62//! well (QSM-CI's sim and KKI travelling head: 0.0024 % and 0.017 % NRMSE in either
63//! precision). LSQR stopped after a fixed number of iterations amplifies rounding, though, so
64//! on small noisy volumes only single precision follows STI (below: 0.17 % against 10.4 %).
65//!
66//! # Agreement with STI Suite
67//!
68//! `tests/ilsqr_sti_parity.rs` (measured 2026-10-30; re-measured 2026-11-02 after the dipole
69//! kernel's rounding, below):
70//! - Against the re-implementation of `QSM_iLSQR` the algorithm was recovered with
71//!   (`tests/data/ilsqr_sti_clone.m`, double precision, 5 iterations per solve): max |Δ| 5.7e-14
72//!   ppm on a 20×20×14 phantom (noise, phase outside the mask). This pins the algorithm.
73//! - Against STI's own output on that phantom (licensed MATLAB R2023b, 100 iterations):
74//!   [`IlsqrPrecision::Single`] χ NRMSE 0.17 %, initial LSQR solution 0.010 %; double precision
75//!   χ 10.4 % (r 0.9950), initial 0.36 %.
76//! - Against the verified QSM-CI `ilsqr-sti` container (QSMxT/QSM-CI#304) at its call (100
77//!   iterations, `padsize` 12 and 8) on two noise-free 32×32×24 / 32×28×28 phantoms: double
78//!   NRMSE 0.12 % and 0.071 % (r 0.9999993, 0.9999997); single 0.032 % and 0.0055 % (0.019 %
79//!   before the dipole kernel's rounding), at or below STI's own spread between CPUs (its χ
80//!   moves by 3.6e-4 and 1.2e-4 relative between the workstation that made the fixture and a
81//!   GitHub-hosted runner). That fixture is regenerated in Actions by
82//!   `tests/data/ilsqr_sti_qsmci_small.py`.
83//! - Inside QSMART (`tests/qsmart_qsmci_parity.rs`, 2026-11-02: QSMART's call, 50 iterations,
84//!   STI's default padding, field in rad/s, on the QSMART toolbox's own SDF local fields of a
85//!   36×36×28 vessel phantom): 1.0e-4 % and 1.0e-3 % NRMSE for the two stages, once QSMART's
86//!   own γ mismatch is applied (it makes the field with γ = 2.675e8 rad/s/T and STI turns it
87//!   back with 2π·42.575 MHz/T, so the original's χ is 2.2e-5 low; QSM.rs keeps one γ and
88//!   does not reproduce that: 2.5e-3 % without it). That is STI's single-precision floor (its
89//!   own χ moves by 1.3e-4 % / 1.3e-3 % between CPUs there).
90//!
91//! **Dipole kernel rounding** (astewartau/QSM.rs#396, 2026-11-02). Until then the kernel's
92//! k-space coordinates were [`crate::fft::fftfreq`]'s, `i·(1/(n·vs))`; STI computes
93//! `i/(n·vs)`. The two round differently, and on the lattice points of the magic angle
94//! (`k_x² + k_y² = 2k_z²` for `H` = z) that decides whether `D` is 0 or −5.6e-17. FastQSM
95//! takes `sign(D)`, so the FastQSM prior and from it the gradient weights moved (at ~1,600
96//! voxels per axis on the QSMART phantom), while the initial solution agreed to 1e-10 %.
97//! Inside QSMART (48³ grid, 64 of its 266 magic-angle points differing in sign) χ was 0.15 %
98//! and 0.094 % NRMSE from STI's for the two stages, in either precision; found by dumping the
99//! MATLAB re-implementation's intermediates on QSMART's call (which reproduces STI there to
100//! 2.6e-5 % / 5.7e-4 %; with fftfreq's coordinates it gives the same 0.15 % / 0.094 %).
101//! Neither sign pattern is better than the other (both are rounding), so the kernel follows
102//! STI's arithmetic (`sti_dipole_kernel`).
103//!
104//! Until 2026-10-30 the FastQSM kernel was closed without IPT's padding (−∞ beyond the array
105//! for the dilation, +∞ for the erosion, on the unpadded 7³ ball), which kept 32 dilated
106//! voxels at the ball's ±y caps (179 non-zeros against `imclose`'s 147). FastQSM moved by 2 %,
107//! its gradient weights at ~800 voxels per axis, and χ by 3.4 % / 1.6 % NRMSE on the container
108//! phantoms, 0.99 % on QSM-CI's sim and 1.78 % on the KKI travelling head; with the closing
109//! fixed, 0.0024 % and 0.017 % (QSMxT/QSM-CI parity runs 41019608126 → 41023156365,
110//! astewartau/QSM.rs#329). QSMART, which calls this iLSQR, moved from 16.71 % to 16.64 % on sim
111//! against the original QSMART (4.27 % on KKI). The licensed fixture above had been made with an `imclose` stand-in
112//! of the same kind, which hid it; it was regenerated with MATLAB's own `imclose`.
113//!
114//! # Padding
115//!
116//! [`IlsqrParams::pad_mm`] is STI's `'padsize'`: millimetres of zero padding per side, the same
117//! on every axis. It sets the grid that both LSQR solves run on, so for small or thin volumes it
118//! dominates the cost: an 8³ volume at 1 mm is solved on 144³ with 64 mm, on 32³ with 6 mm.
119//! The default, [`STI_GUI_PAD_MM`] (12), is what STI Suite's `QSM_GUI` passes (and what
120//! `STI_Templates.m` and the χ-separation toolbox's `Chisep_script.m` use).
121//! [`STI_DEFAULT_PAD_MM`] (6) is what `QSM_iLSQR` uses when called without `'padsize'` (as
122//! QSMART calls it). [`STI_PAD_MM`] (64) is what UK Biobank's pipeline and STI Suite's examples
123//! pass (set it to reproduce them exactly). 0 is allowed (the grid is
124//! still rounded up past the crop to a multiple of 16) but wraps thin volumes onto themselves.
125//! The padding changes χ because the dipole convolution is circular on the padded grid and the
126//! Laplacian and gradient weights are ranked over it.
127//!
128//! On 150 UK Biobank subjects × 2 visits, the qsm-forward head phantom and thin slabs, comparing
129//! 0–128 mm (ukb-qsmxt docs/19), no test-retest, age or longitudinal metric differed from 64 mm.
130//! Above 32 mm the result only moves by the floor set by the capped LSQR iterations (32 and
131//! 128 mm are each ~2 ppb RMS from 64 mm). On a thin phantom slab 0–6 mm lose ~1.5 NRMSE points
132//! against the ground truth and 16 mm ~0.5; on whole heads every pad gave the same accuracy.
133//!
134//! QSM.rs ≤ v0.38 shipped a different iLSQR (a port of QSM.m's formulation: no padding, LSMR
135//! for the artefacts, other weights). It was removed: STI Suite's algorithm is the reference
136//! iLSQR, and it is also what the original QSMART calls (see [`crate::pipeline::run_qsmart`]).
137//!
138//! # Reference pipeline
139//!
140//! What STI Suite 3.0 runs around `QSM_iLSQR`. Its GUI and functions are P-code; these facts
141//! were probed in MATLAB R2023b by driving `QSM_GUI` headless with logging wrappers on the
142//! functions it calls (3-echo synthetic head, TE 4/10/16 ms, 1×1×1.5 mm):
143//!
144//! - **Chain** (the GUI's "3D GRE data" then "iLSQR" buttons): `MRPhaseUnwrap` on the raw,
145//!   unmasked phase of every echo (default `padsize`, 12 voxels), the unwrapped echoes
146//!   **summed**, `V_SHARP(Σφ, mask, 'smvsize', 12)` (12 is the GUI's "Radius" field default),
147//!   then `QSM_iLSQR` on V-SHARP's eroded mask. See [`crate::unwrap::laplacian`] and
148//!   [`crate::bgremove::vsharp`](mod@crate::bgremove::vsharp) for those stages.
149//! - **TE**: the GUI passes `TE` = the **sum** of the echo times (its "sum_TE" field: 30 ms for
150//!   4 + 10 + 16), matching the summed phase. For iLSQR this only sets the unit of χ.
151//! - **Parameters the GUI passes** (as a `params` struct): `niter` 40, `padsize` [12 12 12],
152//!   `tol_step1` 0.01, `tol_step2` 0.001, `Kthreshold` 0.1. The struct call is bitwise equal to
153//!   `QSM_iLSQR(..., 'padsize', [12 12 12], 'niter', 40)`. Called without them, `QSM_iLSQR`
154//!   uses `niter` 100 (bitwise equal to an explicit 100) and `padsize` 6 mm (see *Padding*).
155//!   [`IlsqrParams::default`] takes the function's 100 iterations (decided 2026-10-29: UK
156//!   Biobank and `STI_Templates.m` l. 115 call `QSM_iLSQR` without `'niter'`, so 100 is what
157//!   they run; 40 is only what the GUI passes) and the GUI's 12 mm padding. (`STI_Templates.m`
158//!   lists `padsize = [12 12 12]` as the default; the function's own default is 6.) Until
159//!   2026-10-29 the default was the GUI's 40; the QSM-CI parity pair against the verified
160//!   `ilsqr-sti` container (which calls it without `'niter'`) differed by 17–18 % NRMSE at 40
161//!   and 1–2 % at 100 (astewartau/QSM.rs#329); that 1–2 % was the FastQSM closing (see
162//!   *Agreement with STI Suite*).
163//! - **Masking**: the GUI's "Single button for QSM" path builds the mask by thresholding the
164//!   magnitude (`Support_Functions/CreateMask.m`: rescale, threshold, largest connected
165//!   component, fill holes; then the P-coded `Getmasking`/`PolishMask`), not with BET.
166//! - **B0 direction**: the GUI's `H` field defaults to `[0 0 1]` and is not filled from the
167//!   data; the v3.0 manual says to compute it from the DICOM for oblique scans.
168//! - **Other callers**: QSMART (`QSMART.m` l. 32–39), like Hongfu Sun's `qsm_r2s_prisma.m`
169//!   whose call it copies, passes the field in rad/s with `'niter', 50, 'TE', 1000` and no
170//!   `'padsize'`. QSMART used STI Suite v2.2 (paper §3.4); v2.2's `QSM_iLSQR` agrees with
171//!   v3.0's to 8e-5 relative on two synthetic cases. UK Biobank passes `'padsize', [64 64 64]`.
172//!
173//! References:
174//! Li, W., Wang, N., Yu, F., Han, H., Cao, W., Romero, R., Tantiwongkosi, B.,
175//! Duong, T.Q., Liu, C. (2015). "A method for estimating and removing streaking
176//! artifacts in quantitative susceptibility mapping."
177//! NeuroImage, 108:111-122. <https://doi.org/10.1016/j.neuroimage.2014.12.043>
178//!
179//! Paige, C.C., Saunders, M.A. (1982). "LSQR: An algorithm for sparse linear equations and
180//! sparse least squares." ACM Trans. Math. Softw. 8(1):43-71.
181//!
182//! # Pipeline rules
183//!
184//! The facts above that a configuration can depart from are published as rules in
185//! [`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`, `sti.ilsqr-iterations`, `sti.ilsqr-pad`.
186
187/// Parameters for the iLSQR algorithm.
188///
189/// The iteration cap defaults to `QSM_iLSQR`'s own default, 100, which is what runs when it is
190/// called as a function without `'niter'`: UK Biobank's pipeline and STI Suite's own
191/// `STI_Templates.m` example call it that way (decided 2026-10-29; until then the default was
192/// the GUI's 40, which remains available as `max_iter: STI_GUI_NITER`). The padding defaults
193/// to what STI Suite 3.0's `QSM_GUI` passes (as for STAR-QSM's padding,
194/// [`crate::inversion::StarParams::pad`]). Probed in MATLAB R2023b by driving `QSM_GUI` headless ("3D GRE data", then "iLSQR") with a
195/// logging wrapper around `QSM_iLSQR`: the GUI passes a `params` struct with `niter` 40,
196/// `padsize` [12 12 12], `tol_step1` 0.01, `tol_step2` 0.001 and `Kthreshold` 0.1, bitwise
197/// equal to `QSM_iLSQR(..., 'padsize', [12 12 12], 'niter', 40)`. Called without them,
198/// `QSM_iLSQR` uses `niter` 100 and `padsize` 6. The initial LSQR solve always uses STI's
199/// tolerance of 0.01. `max_iter` is STI's `'niter'` argument (it caps both solves).
200/// `pad_mm` is STI's `'padsize'` (see the module docs, *Padding*).
201///
202/// Measured, the GUI's settings (40, 12 mm) against the function's defaults (100, 6 mm): on
203/// the qsm-forward phantom's true local field χ XSIM 0.686 → 0.671, NRMSE 40.3 → 42.0 %,
204/// r 0.916 → 0.908 (the fewer iterations; 12 mm alone changes little), at under half the run
205/// time; behind the
206/// pipeline's V-SHARP the differences are ≤ 0.01 XSIM (phantom 0.308 → 0.301). On an
207/// in-vivo 3T scan behind the default V-SHARP, agreement with the UK Biobank pipeline's χ
208/// is XSIM 0.772 → 0.764, NRMSE 75.5 → 73.5 %, r 0.814 → 0.820.
209#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
210#[derive(Clone, Debug)]
211pub struct IlsqrParams {
212    /// Tolerance of the streaking-artefact solve (default: 1e-3)
213    pub tol: f64,
214    /// Maximum iterations of each LSQR solve: STI's `'niter'` (default [`STI_DEFAULT_NITER`] =
215    /// 100, `QSM_iLSQR`'s own default, which UK Biobank, `STI_Templates.m` and the χ-separation
216    /// toolbox's `Chisep_script.m` leave in place; `QSM_GUI` passes [`STI_GUI_NITER`] = 40, and
217    /// QSMART 50)
218    pub max_iter: usize,
219    /// Zero padding per side in mm, on every axis: STI's `'padsize'` (default
220    /// [`STI_GUI_PAD_MM`] = 12, what `QSM_GUI` passes; [`STI_DEFAULT_PAD_MM`] = 6 is
221    /// `QSM_iLSQR`'s own default and [`STI_PAD_MM`] reproduces UK Biobank / STI Suite's
222    /// examples). Must be finite and ≥ 0.
223    pub pad_mm: f64,
224    /// Arithmetic of the two LSQR solves (default [`IlsqrPrecision::Double`]; `Single` is
225    /// STI Suite's own, for parity with it)
226    pub precision: IlsqrPrecision,
227}
228
229/// Arithmetic of iLSQR's two LSQR solves (see the module docs, *Performance and precision*).
230#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
232pub enum IlsqrPrecision {
233    /// Double precision throughout (the default).
234    #[default]
235    Double,
236    /// STI Suite's arithmetic: both solves' operators, LSQR vectors and scalars in single
237    /// precision, their right-hand sides computed in double and rounded to single.
238    Single,
239}
240
241impl Default for IlsqrParams {
242    fn default() -> Self {
243        Self {
244            tol: 1e-3,
245            max_iter: STI_DEFAULT_NITER,
246            pad_mm: STI_GUI_PAD_MM,
247            precision: IlsqrPrecision::Double,
248        }
249    }
250}
251
252impl IlsqrParams {
253    /// Checks the parameters: `pad_mm` must be finite and ≥ 0.
254    pub fn validate(&self) -> Result<(), String> {
255        if !(self.pad_mm.is_finite() && self.pad_mm >= 0.0) {
256            return Err(format!("iLSQR padding must be finite and >= 0 mm, got {}", self.pad_mm));
257        }
258        Ok(())
259    }
260}
261
262/// Zero padding STI's `QSM_iLSQR` is given by UK Biobank and in STI Suite's examples, in mm.
263pub const STI_PAD_MM: f64 = 64.0;
264
265/// Zero padding STI Suite 3.0's `QSM_GUI` passes to `QSM_iLSQR` (`padsize` [12 12 12]), in mm,
266/// and the default of [`IlsqrParams::pad_mm`]. `STI_Templates.m` and the χ-separation
267/// toolbox's `Chisep_script.m` use the same value.
268pub const STI_GUI_PAD_MM: f64 = 12.0;
269
270/// Iteration cap (`niter`) STI Suite 3.0's `QSM_GUI` passes to `QSM_iLSQR`. Not the default of
271/// [`IlsqrParams::max_iter`] (that is [`STI_DEFAULT_NITER`]); set it to reproduce the GUI.
272pub const STI_GUI_NITER: usize = 40;
273
274/// `QSM_iLSQR`'s own iteration cap, used when it is called without `'niter'` (output bitwise
275/// equal to an explicit 100), as UK Biobank's pipeline and STI Suite's `STI_Templates.m` call
276/// it, and the default of [`IlsqrParams::max_iter`].
277pub const STI_DEFAULT_NITER: usize = 100;
278
279/// Zero padding STI's `QSM_iLSQR` uses when called without `'padsize'` (as QSMART calls it),
280/// in mm. Probed in STI Suite 3.0: output bitwise equal to an explicit `padsize` of 6 mm
281/// (and different at 5.5 and 6.5) on five crop / voxel-size combinations.
282pub const STI_DEFAULT_PAD_MM: f64 = 6.0;
283
284use std::cell::RefCell;
285use num_complex::{Complex32, Complex64};
286#[cfg(feature = "parallel")]
287use rayon::prelude::*;
288use crate::fft::{Box3, Fft3dWorkspace, Fft3dWorkspaceF32, FftOpts};
289use crate::Grid;
290
291// ============================================================================
292// LSQR Solver
293// ============================================================================
294
295/// LSQR iterative solver for Ax = b
296///
297/// Solves the least squares problem min ||Ax - b||² using the LSQR algorithm.
298/// Based on Paige & Saunders (1982).
299///
300/// # Arguments
301/// * `apply_a` - Function that computes A*x
302/// * `apply_at` - Function that computes A^T*x
303/// * `b` - Right-hand side vector
304/// * `tol` - Convergence tolerance
305/// * `max_iter` - Maximum iterations
306///
307/// # Returns
308/// Solution vector x
309pub fn lsqr<F, G>(
310    apply_a: F,
311    apply_at: G,
312    b: &[f64],
313    tol: f64,
314    max_iter: usize,
315) -> Vec<f64>
316where
317    F: Fn(&[f64]) -> Vec<f64>,
318    G: Fn(&[f64]) -> Vec<f64>,
319{
320    // Initialize
321    let mut u = b.to_vec();
322    let mut beta = norm(&u);
323
324    if beta > 0.0 {
325        scale_inplace(&mut u, 1.0 / beta);
326    }
327
328    let mut v = apply_at(&u);
329    let n = v.len();
330    let mut alpha = norm(&v);
331
332    if alpha > 0.0 {
333        scale_inplace(&mut v, 1.0 / alpha);
334    }
335
336    let mut w = v.clone();
337    let mut x = vec![0.0; n];
338
339    let mut phi_bar = beta;
340    let mut rho_bar = alpha;
341
342    let bnorm = beta;
343
344    // A zero right-hand side has x = 0 as its exact solution, and the plane
345    // rotation below would divide 0 by 0 and hand back NaN.
346    if bnorm == 0.0 {
347        return x;
348    }
349
350    for _iter in 0..max_iter {
351        // Bidiagonalization
352        let mut u_new = apply_a(&v);
353        axpy(&mut u_new, -alpha, &u);
354        beta = norm(&u_new);
355
356        if beta > 0.0 {
357            scale_inplace(&mut u_new, 1.0 / beta);
358        }
359        u = u_new;
360
361        let mut v_new = apply_at(&u);
362        axpy(&mut v_new, -beta, &v);
363        alpha = norm(&v_new);
364
365        if alpha > 0.0 {
366            scale_inplace(&mut v_new, 1.0 / alpha);
367        }
368        v = v_new;
369
370        // Construct and apply rotation
371        let rho = (rho_bar * rho_bar + beta * beta).sqrt();
372        let c = rho_bar / rho;
373        let s = beta / rho;
374        let theta = s * alpha;
375        rho_bar = -c * alpha;
376        let phi = c * phi_bar;
377        phi_bar *= s;
378
379        // Update x and w
380        let t1 = phi / rho;
381        let t2 = -theta / rho;
382
383        for i in 0..n {
384            x[i] += t1 * w[i];
385            w[i] = v[i] + t2 * w[i];
386        }
387
388        // Check convergence
389        let rel_residual = phi_bar / (bnorm + 1e-20);
390
391        if rel_residual < tol {
392            break;
393        }
394    }
395
396    x
397}
398
399fn norm(x: &[f64]) -> f64 {
400    x.iter().map(|&v| v * v).sum::<f64>().sqrt()
401}
402
403fn scale_inplace(x: &mut [f64], s: f64) {
404    for v in x.iter_mut() {
405        *v *= s;
406    }
407}
408
409fn axpy(y: &mut [f64], a: f64, x: &[f64]) {
410    for (yi, &xi) in y.iter_mut().zip(x.iter()) {
411        *yi += a * xi;
412    }
413}
414
415// ============================================================================
416// STI Suite 3.0 QSM_iLSQR (default `ilsqr`)
417// ============================================================================
418
419/// How an [`lsqr_matlab`] solve ended (MATLAB `lsqr` flags).
420#[derive(Clone, Copy, Debug, PartialEq)]
421pub struct LsqrOutcome {
422    /// 0 converged, 1 hit `max_iter`, 3 stagnated, 4 breakdown (a scalar became zero or non-finite)
423    pub flag: u8,
424    /// Iterations whose update is included in the returned solution
425    pub iter: usize,
426    /// ‖b − A x‖ / ‖b‖ (LSQR's running estimate)
427    pub relres: f64,
428}
429
430/// Chunk length of the LSQR norms: fixed, so every sum is independent of the thread count.
431const NORM_CHUNK: usize = 1 << 16;
432
433/// How an LSQR vector is split for its norms: lengths of consecutive parts, each summed
434/// sequentially, the partial sums then added in order.
435///
436/// A vector stored densely uses [`dense_parts`] (`NORM_CHUNK`-long parts). A
437/// vector stored *compactly*, as only the entries at a sorted list of positions in a longer
438/// vector that is exactly zero elsewhere, uses [`compact_parts`]: one part per `NORM_CHUNK`
439/// of the long vector. Adding the omitted zeros changes no partial sum, so the norm of the
440/// compact vector equals the dense norm of the long one.
441fn dense_parts(len: usize) -> Vec<usize> {
442    (0..len.div_ceil(NORM_CHUNK)).map(|c| NORM_CHUNK.min(len - c * NORM_CHUNK)).collect()
443}
444
445/// See [`dense_parts`]. `pos` are the (strictly increasing) positions in the long vector.
446fn compact_parts(pos: impl Iterator<Item = usize>) -> Vec<usize> {
447    let mut parts: Vec<usize> = Vec::new();
448    let mut last = usize::MAX;
449    for g in pos {
450        let c = g / NORM_CHUNK;
451        if c == last {
452            *parts.last_mut().unwrap() += 1;
453        } else {
454            parts.push(1);
455            last = c;
456        }
457    }
458    parts
459}
460
461/// One of [`Fft3dWorkspace`]'s pruned / fused transforms (forward, or inverse if `inverse`).
462fn transform(ws: &mut Fft3dWorkspace, a: &mut [Complex64], inverse: bool, o: &FftOpts) {
463    if inverse { ws.ifft3d_with(a, o) } else { ws.fft3d_with(a, o) }
464}
465
466/// `|z|²` (exactly `z.norm_sqr()`)
467#[inline(always)]
468fn nsq(z: Complex64) -> f64 {
469    z.re * z.re + z.im * z.im
470}
471
472fn split_parts<'a, T>(mut x: &'a [T], parts: &[usize]) -> Vec<&'a [T]> {
473    parts.iter().map(|&len| { let (a, b) = x.split_at(len); x = b; a }).collect()
474}
475
476fn split_parts_mut<'a, T>(mut x: &'a mut [T], parts: &[usize]) -> Vec<&'a mut [T]> {
477    parts.iter().map(|&len| { let (a, b) = std::mem::take(&mut x).split_at_mut(len); x = b; a }).collect()
478}
479
480fn cnorm_parts(x: &[Complex64], parts: &[usize]) -> f64 {
481    let xs = split_parts(x, parts);
482    let sums: Vec<f64> = crate::maybe_par_iter!(xs).map(|c| c.iter().map(|&v| nsq(v)).sum::<f64>()).collect();
483    sums.iter().sum::<f64>().sqrt()
484}
485
486/// `f` applied elementwise to `y` (paired with `x`), then [`cnorm_parts`]`(y)`, in one pass:
487/// the same values as the two passes, part by part.
488fn update_and_norm(
489    y: &mut [Complex64],
490    x: &[Complex64],
491    parts: &[usize],
492    f: impl Fn(&mut Complex64, Complex64) + Sync + Send,
493) -> f64 {
494    let mut ys = split_parts_mut(y, parts);
495    let xs = split_parts(x, parts);
496    let sums: Vec<f64> = crate::maybe_par_iter_mut!(ys)
497        .zip(crate::maybe_par_iter!(xs))
498        .map(|(yc, xc)| {
499            yc.iter_mut().zip(xc.iter()).for_each(|(a, &b)| f(a, b));
500            yc.iter().map(|&v| nsq(v)).sum::<f64>()
501        })
502        .collect();
503    sums.iter().sum::<f64>().sqrt()
504}
505
506/// LSQR's direction and solution updates `d ← (v − θ d)/ρ`, `x ← x + φ d` in one pass,
507/// returning `(‖d‖, ‖x‖)`: the same values as two [`update_and_norm`] passes, part by part.
508#[allow(clippy::too_many_arguments)]
509fn update_d_x_and_norms(d: &mut [Complex64], x: &mut [Complex64], v: &[Complex64], parts: &[usize], thet: f64, rho: f64, phi: f64) -> (f64, f64) {
510    let mut ds = split_parts_mut(d, parts);
511    let mut xs = split_parts_mut(x, parts);
512    let vs = split_parts(v, parts);
513    let sums: Vec<(f64, f64)> = crate::maybe_par_iter_mut!(ds)
514        .zip(crate::maybe_par_iter_mut!(xs))
515        .zip(crate::maybe_par_iter!(vs))
516        .map(|((dc, xc), vc)| {
517            let (mut sd, mut sx) = (0.0f64, 0.0f64);
518            for ((di, xi), &vi) in dc.iter_mut().zip(xc.iter_mut()).zip(vc.iter()) {
519                *di = (vi - *di * thet) / rho;
520                *xi += *di * phi;
521                sd += nsq(*di);
522                sx += nsq(*xi);
523            }
524            (sd, sx)
525        })
526        .collect();
527    (sums.iter().map(|p| p.0).sum::<f64>().sqrt(), sums.iter().map(|p| p.1).sum::<f64>().sqrt())
528}
529
530fn div_inplace(x: &mut [Complex64], s: f64) {
531    crate::maybe_par_iter_mut!(x).for_each(|z| *z /= s);
532}
533
534/// LSQR (Paige & Saunders 1982) with MATLAB `lsqr`'s stopping rules, zero initial guess, no
535/// preconditioner: stops when `‖r‖ ≤ tol·‖b‖` or `‖Aᴴr‖ / (‖A‖_F·‖r‖) ≤ tol` (both tested on
536/// the previous iterate, before it is updated), after three stagnant steps, or on breakdown.
537/// `apply_a` computes `A x`, `apply_ah` computes `Aᴴ u`.
538pub fn lsqr_matlab<F, G>(
539    mut apply_a: F,
540    mut apply_ah: G,
541    b: &[Complex64],
542    tol: f64,
543    max_iter: usize,
544) -> (Vec<Complex64>, LsqrOutcome)
545where
546    F: FnMut(&[Complex64]) -> Vec<Complex64>,
547    G: FnMut(&[Complex64]) -> Vec<Complex64>,
548{
549    let nx = apply_ah(b).len();
550    lsqr_matlab_into(
551        |v, out| out.copy_from_slice(&apply_a(v)),
552        |u, out| out.copy_from_slice(&apply_ah(u)),
553        b.to_vec(),
554        (&dense_parts(b.len()), &dense_parts(nx)),
555        tol,
556        max_iter,
557    )
558}
559
560/// [`lsqr_matlab`] with operators that write into a caller-owned buffer (`out` holds stale
561/// data on entry and must be overwritten), taking `b` by value as LSQR's first `u`, so a solve
562/// allocates its six vectors once. `parts` = (range parts, domain parts): how the norms of `u`
563/// and `v` vectors are split (see [`dense_parts`]); the domain parts also give the length of
564/// the unknown. Performs exactly the same floating-point operations as the textbook loop (the
565/// norms of updated vectors are fused into the update passes, with the same parts).
566fn lsqr_matlab_into<F, G>(
567    mut apply_a: F,
568    mut apply_ah: G,
569    b: Vec<Complex64>,
570    parts: (&[usize], &[usize]),
571    tol: f64,
572    max_iter: usize,
573) -> (Vec<Complex64>, LsqrOutcome)
574where
575    F: FnMut(&[Complex64], &mut [Complex64]),
576    G: FnMut(&[Complex64], &mut [Complex64]),
577{
578    let zero = Complex64::new(0.0, 0.0);
579    let (up, vp) = parts;
580    let nx: usize = vp.iter().sum();
581    let n2b = cnorm_parts(&b, up);
582    let tolb = tol * n2b;
583    let mut u = b;
584    let mut beta = n2b;
585    let mut normr = beta;
586    if beta != 0.0 {
587        div_inplace(&mut u, beta);
588    }
589    let (mut c, mut s) = (1.0f64, 0.0f64);
590    let mut phibar = beta;
591    let mut v = vec![zero; nx];
592    apply_ah(&u, &mut v);
593    let mut x = vec![zero; nx];
594    let mut alpha = cnorm_parts(&v, vp);
595    if alpha != 0.0 {
596        div_inplace(&mut v, alpha);
597    }
598    let mut normar = alpha * beta;
599    let relres = |nr: f64| if n2b > 0.0 { nr / n2b } else { 0.0 };
600    if normar == 0.0 {
601        return (x, LsqrOutcome { flag: 0, iter: 0, relres: relres(normr) });
602    }
603    let mut un = vec![zero; u.len()];
604    let mut vt = vec![zero; nx];
605    let mut d = vec![zero; nx];
606    let mut normx = 0.0f64; // cnorm(x), kept up to date by the x update
607    let mut norma = 0.0f64;
608    let mut stag = 0usize;
609    let mut flag = 1u8;
610    let mut iter = max_iter;
611    for ii in 1..=max_iter {
612        apply_a(&v, &mut un);
613        beta = update_and_norm(&mut un, &u, up, |a, b| *a -= b * alpha);
614        if beta != 0.0 {
615            div_inplace(&mut un, beta);
616        }
617        std::mem::swap(&mut u, &mut un);
618        norma = (norma * norma + alpha * alpha + beta * beta).sqrt();
619        let thet = -s * alpha;
620        let rhot = c * alpha;
621        let rho = (rhot * rhot + beta * beta).sqrt();
622        c = rhot / rho;
623        s = -beta / rho;
624        let phi = c * phibar;
625        if phi == 0.0 {
626            stag = 1;
627        }
628        phibar *= s;
629        let converged = normar / (norma * normr) <= tol || normr <= tolb;
630        let breakdown = !phi.is_finite() || rho == 0.0 || !rho.is_finite();
631        let normx_prev = normx;
632        // The x update needs ‖d‖ only through the stagnation test, which cannot stop this
633        // iteration while stag < 2: then d and x are updated in one pass.
634        let fused = !converged && !breakdown && stag < 2;
635        let normd = if fused {
636            let (nd, nxn) = update_d_x_and_norms(&mut d, &mut x, &v, vp, thet, rho, phi);
637            normx = nxn;
638            nd
639        } else {
640            update_and_norm(&mut d, &v, vp, |di, vi| *di = (vi - *di * thet) / rho)
641        };
642        if phi.abs() * normd < f64::EPSILON * normx_prev {
643            stag += 1;
644        } else {
645            stag = 0;
646        }
647        if converged {
648            flag = 0;
649            iter = ii - 1;
650            break;
651        }
652        if stag >= 3 {
653            flag = 3;
654            iter = ii - 1;
655            break;
656        }
657        if breakdown {
658            flag = 4;
659            iter = ii - 1;
660            break;
661        }
662        if !fused {
663            normx = update_and_norm(&mut x, &d, vp, |xi, di| *xi += di * phi);
664        }
665        normr *= s.abs();
666        apply_ah(&u, &mut vt);
667        alpha = update_and_norm(&mut vt, &v, vp, |a, b| *a -= b * beta);
668        std::mem::swap(&mut v, &mut vt);
669        if (alpha == 0.0 || !alpha.is_finite()) && ii < max_iter {
670            flag = 4;
671            iter = ii;
672            break;
673        }
674        if alpha != 0.0 {
675            div_inplace(&mut v, alpha);
676        }
677        normar = alpha * (s * phi).abs();
678    }
679    if flag == 1 && (normar / (norma * normr) <= tol || normr <= tolb) {
680        flag = 0;
681    }
682    (x, LsqrOutcome { flag, iter, relres: relres(normr) })
683}
684
685/// 0-based index of STI's percentile: the sorted value at 1-based index `round(p·N)`.
686fn sti_percentile_index(n: usize, p: f64) -> usize {
687    ((p * n as f64).round() as usize).clamp(1, n) - 1
688}
689
690/// Sorted value at 1-based index `round(p·N)` (STI's percentile; `p` in [0, 1]).
691#[cfg(test)]
692fn sti_percentile(sorted: &[f64], p: f64) -> f64 {
693    sorted[sti_percentile_index(sorted.len(), p)]
694}
695
696/// STI's percentiles `p_lo ≤ p_hi` of `v` (which is reordered), by selection instead of a full
697/// sort: the k-th element in `total_cmp` order is unique, so this is exactly the sorted lookup.
698fn sti_percentiles(v: &mut [f64], p_lo: f64, p_hi: f64) -> (f64, f64) {
699    let (il, ih) = (sti_percentile_index(v.len(), p_lo), sti_percentile_index(v.len(), p_hi));
700    debug_assert!(il <= ih);
701    let hi = *v.select_nth_unstable_by(ih, |a, b| a.total_cmp(b)).1;
702    let lo = if il == ih { hi } else { *v[..ih].select_nth_unstable_by(il, |a, b| a.total_cmp(b)).1 };
703    (lo, hi)
704}
705
706/// STI's `MaskBoundingBox`: tight bounding box (0-based, inclusive); an odd extent grows by one
707/// voxel at the high end, or at the low end when the high end is the array edge (and shrinks
708/// by one at the high end when both ends are edges).
709fn sti_bounding_box(mask: &[u8], dims: (usize, usize, usize)) -> Option<([usize; 3], [usize; 3])> {
710    let (nx, ny, nz) = dims;
711    let n = [nx, ny, nz];
712    let mut lo = n;
713    let mut hi = [0usize; 3];
714    let mut any = false;
715    for k in 0..nz {
716        for j in 0..ny {
717            for i in 0..nx {
718                if mask[i + j * nx + k * nx * ny] != 0 {
719                    any = true;
720                    for (d, c) in [i, j, k].into_iter().enumerate() {
721                        lo[d] = lo[d].min(c);
722                        hi[d] = hi[d].max(c);
723                    }
724                }
725            }
726        }
727    }
728    if !any {
729        return None;
730    }
731    for d in 0..3 {
732        if (hi[d] - lo[d] + 1) % 2 == 1 {
733            if hi[d] + 1 < n[d] {
734                hi[d] += 1;
735            } else if lo[d] > 0 {
736                lo[d] -= 1;
737            } else {
738                hi[d] -= 1;
739            }
740        }
741    }
742    Some((lo, hi))
743}
744
745/// STI's padded grid: per axis the next multiple of 16 strictly above `c + 2·pad_mm/vs`.
746fn sti_padded_dims(c: [usize; 3], vs: [f64; 3], pad_mm: f64) -> [usize; 3] {
747    [0, 1, 2].map(|d| 16 * ((c[d] as f64 + 2.0 * pad_mm / vs[d] + 1.0) / 16.0).ceil() as usize)
748}
749
750/// Sentinel of the compact-index maps in the artefact solve.
751const NONE: u32 = u32::MAX;
752
753/// Circular neighbour `l + e_axis` on the grid `n`.
754#[inline]
755fn circ_next(l: usize, axis: usize, n: [usize; 3]) -> usize {
756    let step = [1, n[0], n[0] * n[1]][axis];
757    if (l / step) % n[axis] + 1 == n[axis] { l - (n[axis] - 1) * step } else { l + step }
758}
759
760/// Weighted circular forward differences (no voxel-size scaling), as STI's iLSQR uses, at the
761/// mask voxels only: `r[a·nm + i] = gw[a][i] · (val(l + e_a) − val(l))` with `l = inside[i]`,
762/// `nm = inside.len()`, `gw` the weights compacted to the mask.
763fn grad_masked(val: &(dyn Fn(usize) -> Complex64 + Sync), inside: &[usize], gw: &[Vec<f64>], n: [usize; 3], r: &mut [Complex64]) {
764    let nm = inside.len();
765    for (a, ra) in r.chunks_mut(nm).enumerate() {
766        let g = &gw[a];
767        crate::maybe_par_iter_mut!(ra).enumerate().for_each(|(i, o)| {
768            let l = inside[i];
769            *o = (val(circ_next(l, a, n)) - val(l)) * g[i];
770        });
771    }
772}
773
774/// Adjoint of [`grad_masked`] onto the box `bx` of the grid: `acc[l] = Σ_a (q_a[l − e_a] − q_a[l])`
775/// for `l` in `bx` (the rest of `acc` is left as it is), summed in axis order from zero, with
776/// `q_a = gw[a]·r_a` on the mask and zero elsewhere (`mask_of[l]` = position of `l` in
777/// `inside`, or [`NONE`]). The result is zero outside the mask grown by one voxel along +e_a.
778fn grad_masked_adj(r: &[Complex64], mask_of: &[u32], gw: &[Vec<f64>], n: [usize; 3], bx: &Box3, acc: &mut [Complex64]) {
779    let (nxy, nt) = (n[0] * n[1], n[0] * n[1] * n[2]);
780    let nm = r.len() / 3;
781    let zero = Complex64::new(0.0, 0.0);
782    let q = |a: usize, l: usize| match mask_of[l] {
783        NONE => zero,
784        i => r[a * nm + i as usize] * gw[a][i as usize],
785    };
786    crate::maybe_par_chunks_mut!(acc, nxy).enumerate().filter(|(k, _)| bx[2].contains(k)).for_each(|(k, pa)| {
787        let uz = if k == 0 { nt } else { 0 }; // circular: l − e_z = l + uz − nxy
788        for j in bx[1].clone() {
789            let uy = if j == 0 { nxy } else { 0 };
790            let row = k * nxy + j * n[0];
791            for i in bx[0].clone() {
792                let l = row + i;
793                let xp = if i == 0 { l + n[0] - 1 } else { l - 1 };
794                let mut o = zero;
795                o += q(0, xp) - q(0, l);
796                o += q(1, l + uy - n[0]) - q(1, l);
797                o += q(2, l + uz - nxy) - q(2, l);
798                pa[j * n[0] + i] = o;
799            }
800        }
801    });
802}
803
804/// iLSQR as in STI Suite 3.0 (`QSM_iLSQR`), padded by `params.pad_mm`.
805///
806/// See the module docs for the algorithm. `field` is the local field (any linear unit; QSM.rs
807/// passes ppm), `bdir` the B0 direction in voxel axes (normalised here).
808///
809/// # Returns
810/// `(chi, streaking_artifacts, fast_qsm, initial_lsqr)` on the input grid, all masked.
811/// `initial_lsqr` is STI's second output (the step-3 LSQR solution).
812///
813/// # Panics
814/// If `params` fails [`IlsqrParams::validate`] (the pipeline checks this first and returns
815/// `InvalidConfig`).
816pub fn ilsqr(
817    field: &[f64],
818    mask: &[u8],
819    grid: &Grid,
820    bdir: (f64, f64, f64),
821    params: &IlsqrParams,
822    progress: impl FnMut(usize, usize),
823) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
824    ilsqr_with(field, mask, grid, bdir, params, progress, &())
825}
826
827/// Instrumentation hooks for [`ilsqr_with`]. The library path ([`ilsqr`]) uses `()`, whose hooks
828/// do nothing, so it never reads a clock (`std::time::Instant` panics on
829/// `wasm32-unknown-unknown`). The hooks only observe; they cannot change the result.
830trait Tracer {
831    /// Run one 3-D transform on the padded grid.
832    fn fft<R>(&self, f: impl FnOnce() -> R) -> R {
833        f()
834    }
835    /// A stage has just ended.
836    fn stage(&self, _name: &'static str) {}
837    /// The padded grid.
838    fn dims(&self, _n: [usize; 3]) {}
839    /// An LSQR solve has ended.
840    fn solve(&self, _out: &LsqrOutcome) {}
841}
842
843impl Tracer for () {}
844
845#[cfg(not(target_arch = "wasm32"))]
846pub use timing::{ilsqr_traced, IlsqrTrace};
847
848/// Wall-clock tracing of iLSQR for native benchmarks and parity checks. Not compiled for
849/// `wasm32`, which has no clock in `std`.
850#[cfg(not(target_arch = "wasm32"))]
851mod timing {
852    use super::{ilsqr_with, IlsqrParams, LsqrOutcome, Tracer};
853    use crate::Grid;
854    use std::cell::{Cell, RefCell};
855    use std::time::Instant;
856
857    /// `(chi, streaking_artifacts, fast_qsm, initial_lsqr)`, as [`super::ilsqr`] returns them.
858    type Maps = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
859
860    /// Where an [`ilsqr_traced`] run spent its time, and how its two LSQR solves ended.
861    #[derive(Clone, Debug, Default)]
862    pub struct IlsqrTrace {
863        /// Padded grid
864        pub dims: [usize; 3],
865        /// (stage, wall seconds, 3-D FFTs, FFT wall seconds) in execution order
866        pub stages: Vec<(&'static str, f64, usize, f64)>,
867        /// Initial solve and artefact solve
868        pub solves: Vec<LsqrOutcome>,
869        /// 3-D FFTs (forward + inverse) on the padded grid, and their total wall time
870        pub n_fft: usize,
871        pub fft_secs: f64,
872    }
873
874    struct Timer {
875        trace: RefCell<IlsqrTrace>,
876        n_fft: Cell<usize>,
877        fft_secs: Cell<f64>,
878        /// start of the current stage, and the FFT count and time at that point
879        stage_start: Cell<(Instant, usize, f64)>,
880    }
881
882    impl Tracer for Timer {
883        fn fft<R>(&self, f: impl FnOnce() -> R) -> R {
884            let t = Instant::now();
885            let r = f();
886            self.n_fft.set(self.n_fft.get() + 1);
887            self.fft_secs.set(self.fft_secs.get() + t.elapsed().as_secs_f64());
888            r
889        }
890        fn stage(&self, name: &'static str) {
891            let (t0, n0, s0) = self.stage_start.get();
892            let (n, s) = (self.n_fft.get(), self.fft_secs.get());
893            self.trace.borrow_mut().stages.push((name, t0.elapsed().as_secs_f64(), n - n0, s - s0));
894            self.stage_start.set((Instant::now(), n, s));
895        }
896        fn dims(&self, n: [usize; 3]) {
897            self.trace.borrow_mut().dims = n;
898        }
899        fn solve(&self, out: &LsqrOutcome) {
900            self.trace.borrow_mut().solves.push(*out);
901        }
902    }
903
904    /// [`super::ilsqr`], also returning an [`IlsqrTrace`] (wall time per stage and per FFT, and
905    /// how both LSQR solves ended). Native targets only; the result is identical to `ilsqr`'s.
906    pub fn ilsqr_traced(
907        field: &[f64],
908        mask: &[u8],
909        grid: &Grid,
910        bdir: (f64, f64, f64),
911        params: &IlsqrParams,
912        progress: impl FnMut(usize, usize),
913    ) -> (Maps, IlsqrTrace) {
914        let timer = Timer {
915            trace: RefCell::default(),
916            n_fft: Cell::new(0),
917            fft_secs: Cell::new(0.0),
918            stage_start: Cell::new((Instant::now(), 0, 0.0)),
919        };
920        let out = ilsqr_with(field, mask, grid, bdir, params, progress, &timer);
921        let mut trace = timer.trace.into_inner();
922        trace.n_fft = timer.n_fft.get();
923        trace.fft_secs = timer.fft_secs.get();
924        (out, trace)
925    }
926}
927
928/// [`ilsqr`] with instrumentation hooks.
929fn ilsqr_with(
930    field: &[f64],
931    mask: &[u8],
932    grid: &Grid,
933    bdir: (f64, f64, f64),
934    params: &IlsqrParams,
935    mut progress: impl FnMut(usize, usize),
936    tracer: &impl Tracer,
937) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
938    if let Err(e) = params.validate() {
939        panic!("{e}");
940    }
941    let (nx, ny, nz) = grid.dims;
942    let n_in = nx * ny * nz;
943    let vs = [grid.voxel_size.0, grid.voxel_size.1, grid.voxel_size.2];
944    let zeros = || vec![0.0; n_in];
945    let Some((lo, hi)) = sti_bounding_box(mask, grid.dims) else {
946        return (zeros(), zeros(), zeros(), zeros());
947    };
948    progress(1, 4);
949
950    // 1. crop to the (even) bounding box and pad to the next multiple of 16 above c + 2 pad/vs
951    let c = [0, 1, 2].map(|d| hi[d] - lo[d] + 1);
952    let n = sti_padded_dims(c, vs, params.pad_mm);
953    let pd = [0, 1, 2].map(|d| (n[d] - c[d]) / 2);
954    let nt = n[0] * n[1] * n[2];
955    let mut p = vec![0.0; nt];
956    let mut m = vec![0.0; nt];
957    for k in 0..c[2] {
958        for j in 0..c[1] {
959            for i in 0..c[0] {
960                let src = (i + lo[0]) + (j + lo[1]) * nx + (k + lo[2]) * nx * ny;
961                let dst = (i + pd[0]) + (j + pd[1]) * n[0] + (k + pd[2]) * n[0] * n[1];
962                p[dst] = field[src];
963                m[dst] = if mask[src] != 0 { 1.0 } else { 0.0 };
964            }
965        }
966    }
967    let inside: Vec<usize> = (0..nt).filter(|&l| m[l] > 0.0).collect();
968    let [kx, ky, kz] = [0, 1, 2].map(|d| sti_freqs(n[d], vs[d]));
969    let dk = sti_dipole_kernel(n, [&kx, &ky, &kz], bdir);
970    tracer.dims(n);
971    let ws = RefCell::new(Fft3dWorkspace::new(n[0], n[1], n[2]));
972    let fft = |a: &mut [Complex64]| tracer.fft(|| ws.borrow_mut().fft3d(a));
973    let ifft = |a: &mut [Complex64]| tracer.fft(|| ws.borrow_mut().ifft3d(a));
974    // the mask lies in the crop box; its forward-difference neighbours in the box grown by one
975    let crop_box: Box3 = [0, 1, 2].map(|d| pd[d]..pd[d] + c[d]);
976    let crop_box1: Box3 = [0, 1, 2].map(|d| pd[d]..(pd[d] + c[d] + 1).min(n[d]));
977    tracer.stage("crop/pad, dipole kernel, plans");
978    let mut p_k: Vec<Complex64> = crate::maybe_par_iter!(p).map(|&v| Complex64::new(v, 0.0)).collect();
979    fft(&mut p_k[..]);
980
981    // 2. Laplacian weights: |F⁻¹(k² F φ)| with STI's 1e6 at DC, ranked in the mask
982    let mut lap: Vec<Complex64> = p_k.clone();
983    crate::maybe_par_iter_mut!(lap).enumerate().for_each(|(l, z)| {
984        let (i, j, k) = (l % n[0], (l / n[0]) % n[1], l / (n[0] * n[1]));
985        let k2 = if l == 0 { 1e6 } else { kx[i] * kx[i] + ky[j] * ky[j] + kz[k] * kz[k] };
986        *z *= k2;
987    });
988    ifft(&mut lap[..]);
989    let mut w: Vec<f64> = crate::maybe_par_iter!(lap).map(|z| z.re.abs()).collect(); // |L|, then W
990    drop(lap);
991    let mut ranked: Vec<f64> = inside.iter().map(|&l| w[l]).collect();
992    let (t60, t999) = sti_percentiles(&mut ranked, 0.6, 0.999);
993    drop(ranked);
994    crate::maybe_par_iter_mut!(w).zip(crate::maybe_par_iter!(m)).for_each(|(v, &mv)| {
995        *v = if mv == 0.0 {
996            0.0
997        } else if t999 > t60 {
998            (1.0 - 0.8 * (*v - t60) / (t999 - t60)).clamp(0.2, 1.0)
999        } else if *v <= t60 {
1000            1.0
1001        } else {
1002            0.2
1003        };
1004    });
1005    tracer.stage("Laplacian weights (incl. F phi)");
1006
1007    // 3. initial solution: LSQR on D W D χ = D W φ, with D = F⁻¹ diag(dk) F (initial_solve)
1008    let (x1r, out1) = match params.precision {
1009        IlsqrPrecision::Double => initial_solve(n, &p, &w, &dk, &crop_box, params.max_iter, tracer),
1010        IlsqrPrecision::Single => initial_solve_single(n, &p, &w, &dk, params.max_iter),
1011    };
1012    tracer.solve(&out1);
1013    tracer.stage("solve 1 (initial LSQR)");
1014    progress(2, 4);
1015
1016    // 4. FastQSM prior (sign(D) inverse blended with a k-space-smoothed copy where |D| is small)
1017    let xfs = sti_fastqsm(&p_k, &m, &inside, &dk, n, &fft, &ifft);
1018    drop(p_k);
1019    tracer.stage("FastQSM");
1020    progress(3, 4);
1021
1022    // 5. gradient weights from the FastQSM map, pooled over the three axes in the mask
1023    let mut gw: Vec<Vec<f64>> = (0..3).map(|_| vec![0.0; nt]).collect();
1024    let mut pool = Vec::with_capacity(3 * inside.len());
1025    for (axis, a) in gw.iter_mut().enumerate() {
1026        // |∇_axis xfs| (circular forward difference), held in gw[axis] until the weights replace it
1027        let xfs = &xfs;
1028        crate::maybe_par_iter_mut!(a).enumerate().for_each(|(l, o)| *o = (xfs[circ_next(l, axis, n)] - xfs[l]).abs());
1029        pool.extend(inside.iter().map(|&l| a[l]));
1030    }
1031    let (t50, t70) = sti_percentiles(&mut pool, 0.5, 0.7);
1032    drop(pool);
1033    for a in gw.iter_mut() {
1034        crate::maybe_par_iter_mut!(a).zip(crate::maybe_par_iter!(m)).for_each(|(g, &mv)| {
1035            *g = if mv == 0.0 {
1036                0.0
1037            } else if t70 > t50 {
1038                ((t70 - *g) / (t70 - t50)).clamp(0.0, 1.0)
1039            } else if *g <= t50 {
1040                1.0
1041            } else {
1042                0.0
1043            };
1044        });
1045    }
1046    tracer.stage("gradient weights");
1047    progress(4, 4);
1048
1049    // 6. streaking artefacts (artefact_solve), subtracted from the initial solution
1050    let sq = (nt as f64).sqrt();
1051    let (xsa, out2) = match params.precision {
1052        IlsqrPrecision::Double => artefact_solve(n, &x1r, &m, &inside, gw, &dk, &crop_box1, params.tol, params.max_iter, tracer),
1053        IlsqrPrecision::Single => artefact_solve_single(n, &x1r, &m, &gw, &dk, params.tol, params.max_iter),
1054    };
1055    tracer.solve(&out2);
1056
1057    // 7. subtract, mask, crop back
1058    let mut chi = zeros();
1059    let mut sa = zeros();
1060    let mut fs = zeros();
1061    let mut x0 = zeros();
1062    for k in 0..c[2] {
1063        for j in 0..c[1] {
1064            for i in 0..c[0] {
1065                let dst = (i + lo[0]) + (j + lo[1]) * nx + (k + lo[2]) * nx * ny;
1066                let src = (i + pd[0]) + (j + pd[1]) * n[0] + (k + pd[2]) * n[0] * n[1];
1067                if m[src] > 0.0 {
1068                    let a = xsa[src] * sq;
1069                    chi[dst] = x1r[src] - a;
1070                    sa[dst] = a;
1071                    fs[dst] = xfs[src];
1072                    x0[dst] = x1r[src];
1073                }
1074            }
1075        }
1076    }
1077    tracer.stage("solve 2 (artefact LSQR, incl. setup) + output");
1078    (chi, sa, fs, x0)
1079}
1080
1081/// Step 3 of [`ilsqr`]: LSQR (tol 0.01) on D W D χ = D W φ with
1082/// D = F⁻¹ diag(dk) F. Returns χ and how LSQR ended.
1083///
1084/// Solved in k-space, for x̃ = F χ: with the unitary U = F/√N, D W D = U* (dk·U W U*·dk) U,
1085/// so the LSQR iterates for (U A U*, U b) are U times those for (A, b) and the norms are equal
1086/// (exact arithmetic). The √N factors cancel in dk·F W F⁻¹·dk, and taking F b = √N·U b as the
1087/// right-hand side (LSQR is linear in b and its stopping tests are scale-free) leaves
1088/// χ = F⁻¹ x̃. That is 2 FFTs per operator call instead of 4; rounding differs only at the
1089/// level of the transforms' own rounding. The products with dk and W are fused into the
1090/// transforms' first and last passes, which only cover the crop box (W = 0 outside the mask).
1091#[allow(clippy::too_many_arguments)]
1092fn initial_solve(
1093    n: [usize; 3],
1094    p: &[f64],
1095    w: &[f64],
1096    dk: &[f64],
1097    crop_box: &Box3,
1098    max_iter: usize,
1099    tracer: &impl Tracer,
1100) -> (Vec<f64>, LsqrOutcome) {
1101    let ws = RefCell::new(Fft3dWorkspace::new(n[0], n[1], n[2]));
1102    let tr = |a: &mut [Complex64], inverse: bool, o: &FftOpts| tracer.fft(|| transform(&mut ws.borrow_mut(), a, inverse, o));
1103    let apply1 = |v: &[Complex64], t: &mut [Complex64]| {
1104        let load = |l: usize, row: &mut [Complex64]| {
1105            row.iter_mut().zip(&v[l..]).zip(&dk[l..]).for_each(|((o, &z), &dv)| *o = z * dv);
1106        };
1107        tr(t, true, &FftOpts { needed: Some(crop_box), load: Some(&load), post: Some(w), ..Default::default() });
1108        tr(t, false, &FftOpts { support: Some(crop_box), post: Some(dk), ..Default::default() });
1109    };
1110    let mut b1: Vec<Complex64> = crate::maybe_par_iter!(p).zip(crate::maybe_par_iter!(w))
1111        .map(|(&pv, &wv)| Complex64::new(pv * wv, 0.0)).collect();
1112    tr(&mut b1, false, &FftOpts::default());
1113    crate::maybe_par_iter_mut!(b1).zip(crate::maybe_par_iter!(dk)).for_each(|(z, &dv)| *z *= dv);
1114    let dense = dense_parts(p.len());
1115    let (mut x1, out) = lsqr_matlab_into(apply1, apply1, b1, (&dense, &dense), 0.01, max_iter);
1116    tr(&mut x1, true, &FftOpts::default());
1117    (crate::maybe_par_iter!(x1).map(|z| z.re).collect(), out)
1118}
1119
1120/// Step 6 of [`ilsqr`]: the streaking artefacts, by LSQR (`tol`) over
1121/// the k-space cone |D| < 0.1 against the weighted gradient (weights `gw`, full grid,
1122/// zero off the mask) of the initial solution `x1`. Returns the real part of F⁻¹ of the
1123/// solution (the artefacts divided by √N) and how LSQR ended.
1124///
1125/// A y = G ∇ (√N F⁻¹(M_ic y)), Aᴴ r = M_ic F(∇ᴴ(G r)) / √N. Every vector LSQR forms in the
1126/// domain is exactly zero off the cone, and every one in the range is exactly zero off the
1127/// mask (G = 0 there), so both are stored compactly: the unknown on the cone positions, the
1128/// residual on the mask positions of each axis. With norms split as for the dense vectors
1129/// (`compact_parts`) the iterates have the same values as a dense solve. The image-space data
1130/// lives in `crop_box1` (the mask and its +e_a neighbours), so the transforms are pruned to it.
1131#[allow(clippy::too_many_arguments)]
1132fn artefact_solve(
1133    n: [usize; 3],
1134    x1: &[f64],
1135    m: &[f64],
1136    inside: &[usize],
1137    gw: Vec<Vec<f64>>,
1138    dk: &[f64],
1139    crop_box1: &Box3,
1140    tol: f64,
1141    max_iter: usize,
1142    tracer: &impl Tracer,
1143) -> (Vec<f64>, LsqrOutcome) {
1144    let nt = dk.len();
1145    let sq = (nt as f64).sqrt();
1146    let cone: Vec<usize> = (0..nt).filter(|&l| dk[l].abs() < 0.1).collect();
1147    let nm = inside.len();
1148    assert!(nt < NONE as usize, "grid too large for 32-bit voxel indices");
1149    let index_of = |list: &[usize]| {
1150        let mut map = vec![NONE; nt];
1151        for (i, &l) in list.iter().enumerate() {
1152            map[l] = i as u32;
1153        }
1154        map
1155    };
1156    let cone_of = index_of(&cone);
1157    let mask_of = index_of(inside);
1158    let gwc: Vec<Vec<f64>> = gw.iter().map(|g| inside.iter().map(|&l| g[l]).collect()).collect();
1159    drop(gw);
1160    let u_parts = compact_parts((0..3).flat_map(|a| inside.iter().map(move |&l| a * nt + l)));
1161    let v_parts = compact_parts(cone.iter().copied());
1162    let zero = Complex64::new(0.0, 0.0);
1163    let ws = RefCell::new(Fft3dWorkspace::new(n[0], n[1], n[2]));
1164    let tr = |a: &mut [Complex64], inverse: bool, o: &FftOpts| tracer.fft(|| transform(&mut ws.borrow_mut(), a, inverse, o));
1165    let isq = 1.0 / sq;
1166    let t_cell = RefCell::new(vec![zero; nt]);
1167    let apply2 = |y: &[Complex64], out: &mut [Complex64]| {
1168        let mut t = t_cell.borrow_mut();
1169        // scatter y onto the cone in the transform's first pass; read on the mask and its
1170        // +e_a neighbours only
1171        let load = |l: usize, row: &mut [Complex64]| {
1172            row.iter_mut().zip(&cone_of[l..]).for_each(|(o, &ci)| *o = if ci == NONE { zero } else { y[ci as usize] });
1173        };
1174        tr(&mut t[..], true, &FftOpts { needed: Some(crop_box1), load: Some(&load), ..Default::default() });
1175        let t = &t[..];
1176        grad_masked(&|l| t[l] * sq, inside, &gwc, n, out);
1177    };
1178    let apply2h = |r: &[Complex64], out: &mut [Complex64]| {
1179        let mut acc = t_cell.borrow_mut();
1180        grad_masked_adj(r, &mask_of, &gwc, n, crop_box1, &mut acc);
1181        tr(&mut acc[..], false, &FftOpts { support: Some(crop_box1), ..Default::default() });
1182        let acc = &acc[..];
1183        crate::maybe_par_iter_mut!(out).zip(crate::maybe_par_iter!(cone)).for_each(|(o, &l)| *o = acc[l] * isq);
1184    };
1185    let mut b2 = vec![zero; 3 * nm];
1186    grad_masked(&|l| Complex64::new(x1[l] * m[l], 0.0), inside, &gwc, n, &mut b2);
1187    let (y, out) = lsqr_matlab_into(apply2, apply2h, b2, (&u_parts, &v_parts), tol, max_iter);
1188    drop(t_cell);
1189    let mut xsa = vec![zero; nt];
1190    for (&l, &z) in cone.iter().zip(&y) {
1191        xsa[l] = z;
1192    }
1193    drop(y);
1194    tr(&mut xsa, true, &FftOpts::default());
1195    (crate::maybe_par_iter!(xsa).map(|z| z.re).collect(), out)
1196}
1197
1198/// STI's `FastQSM` on the padded grid, given `F φ` (unshifted k-space). Returns the masked
1199/// estimate, rescaled to the TKD (threshold 1/8) solution by least squares inside the mask.
1200fn sti_fastqsm(
1201    p_k: &[Complex64],
1202    m: &[f64],
1203    inside: &[usize],
1204    dk: &[f64],
1205    n: [usize; 3],
1206    fft: &dyn Fn(&mut [Complex64]),
1207    ifft: &dyn Fn(&mut [Complex64]),
1208) -> Vec<f64> {
1209    let nt = p_k.len();
1210    // kernel: ball3D(3) (centre excluded) / its count, closed along the 2nd axis with [1 1 1]
1211    // as IPT's `imclose` does it (below), not renormalised
1212    let r = 3i64;
1213    let s = 7usize;
1214    let mut ball = vec![0.0f64; s * s * s];
1215    let mut cnt = 0.0;
1216    for (l, bv) in ball.iter_mut().enumerate() {
1217        let (i, j, k) = ((l % s) as i64 - r, ((l / s) % s) as i64 - r, (l / (s * s)) as i64 - r);
1218        let d2 = (i * i + j * j + k * k) as f64;
1219        if d2 > 0.0 && d2 <= (r as f64 + 1.0 / 6.0).powi(2) {
1220            *bv = 1.0;
1221            cnt += 1.0;
1222        }
1223    }
1224    ball.iter_mut().for_each(|v| *v /= cnt);
1225    // IPT's `imclose(b, [1 1 1])`: a greyscale dilation then erosion along the 2nd axis, on
1226    // the image padded by one voxel of −∞ at both ends of that axis (out-of-array neighbours
1227    // are skipped, i.e. −∞ for the dilation and +∞ for the erosion), cropped back. On this
1228    // ball it only fills the centre, as `imclose` does in MATLAB R2023b (probed on Bunya,
1229    // astewartau/QSM.rs#329); without the padding the erosion keeps 32 dilated voxels at the
1230    // ball's ±y caps, which moved FastQSM by 2 % and iLSQR's χ by 1.6–3.4 % NRMSE.
1231    let sp = s + 2;
1232    let at = |i: usize, j: usize, k: usize| i + j * s + k * s * sp;
1233    let mut padded = vec![f64::NEG_INFINITY; s * sp * s];
1234    for k in 0..s {
1235        for j in 0..s {
1236            for i in 0..s {
1237                padded[at(i, j + 1, k)] = ball[i + j * s + k * s * s];
1238            }
1239        }
1240    }
1241    let line = |a: &[f64], dil: bool| -> Vec<f64> {
1242        let mut o = vec![0.0; a.len()];
1243        for k in 0..s {
1244            for j in 0..sp {
1245                for i in 0..s {
1246                    let mut v = a[at(i, j, k)];
1247                    for jj in [j.wrapping_sub(1), j + 1] {
1248                        if jj < sp {
1249                            let q = a[at(i, jj, k)];
1250                            v = if dil { v.max(q) } else { v.min(q) };
1251                        }
1252                    }
1253                    o[at(i, j, k)] = v;
1254                }
1255            }
1256        }
1257        o
1258    };
1259    let closed_p = line(&line(&padded, true), false);
1260    let closed: Vec<f64> = (0..s * s * s).map(|l| closed_p[at(l % s, (l / s) % s + 1, l / (s * s))]).collect();
1261    debug_assert_eq!(closed.iter().filter(|&&v| v != 0.0).count(), 147);
1262    // STI smooths the k-space of the *centred* image (fftnc: origin at the array centre); on
1263    // the k-space of the uncentred image that is the same convolution with the kernel
1264    // modulated by (−1)^(i+j+k) (all padded dimensions are even).
1265    let mut kern = vec![Complex64::new(0.0, 0.0); nt];
1266    for (l, &bv) in closed.iter().enumerate() {
1267        if bv != 0.0 {
1268            let off = [(l % s) as i64 - r, ((l / s) % s) as i64 - r, (l / (s * s)) as i64 - r];
1269            let idx = [0, 1, 2].map(|d| off[d].rem_euclid(n[d] as i64) as usize);
1270            let sign = if (off[0] + off[1] + off[2]).rem_euclid(2) == 0 { 1.0 } else { -1.0 };
1271            kern[idx[0] + idx[1] * n[0] + idx[2] * n[0] * n[1]] = Complex64::new(sign * bv, 0.0);
1272        }
1273    }
1274    fft(&mut kern[..]);
1275    let kh: Vec<f64> = kern.iter().map(|z| z.re).collect();
1276    drop(kern);
1277    // k-space blending weights from |D|^0.001, ranked over the whole grid (1st..30th pct)
1278    let mut wf: Vec<f64> = crate::maybe_par_iter!(dk).map(|&dv| dv.abs().powf(0.001)).collect();
1279    let mut zs = wf.clone();
1280    let (zlo, zhi) = sti_percentiles(&mut zs, 0.01, 0.3);
1281    drop(zs);
1282    crate::maybe_par_iter_mut!(wf).for_each(|v| *v = if zhi > zlo { ((*v - zlo) / (zhi - zlo)).clamp(0.0, 1.0) } else { 1.0 });
1283    // blend X with its k-space smoothing (a convolution over k-space indices)
1284    let blend = |x: &mut [Complex64]| {
1285        let mut sm = x.to_vec();
1286        fft(&mut sm[..]);
1287        crate::maybe_par_iter_mut!(sm).zip(crate::maybe_par_iter!(kh)).for_each(|(v, &h)| *v *= h);
1288        ifft(&mut sm[..]);
1289        crate::maybe_par_iter_mut!(x).zip(crate::maybe_par_iter!(sm)).zip(crate::maybe_par_iter!(wf))
1290            .for_each(|((xv, &sv), &w)| *xv = *xv * w + sv * (1.0 - w));
1291    };
1292    let mut x: Vec<Complex64> = crate::maybe_par_iter!(p_k).zip(crate::maybe_par_iter!(dk)).map(|(&pv, &dv)| pv * sign0(dv)).collect();
1293    blend(&mut x[..]);
1294    ifft(&mut x[..]);
1295    crate::maybe_par_iter_mut!(x).zip(crate::maybe_par_iter!(m)).for_each(|(v, &mv)| *v *= mv);
1296    fft(&mut x[..]);
1297    blend(&mut x[..]);
1298    ifft(&mut x[..]);
1299    let mut xfs: Vec<f64> = crate::maybe_par_iter!(x).zip(crate::maybe_par_iter!(m)).map(|(v, &mv)| v.re * mv).collect();
1300    drop(x);
1301    // TKD: 1/D clamped to ±8 (D = 0 → +8)
1302    let mut t: Vec<Complex64> = crate::maybe_par_iter!(p_k).zip(crate::maybe_par_iter!(dk)).map(|(&pv, &dv)| {
1303        let r = 1.0 / dv;
1304        pv * if r.abs() > 8.0 { 8.0f64.copysign(r) } else { r }
1305    }).collect();
1306    ifft(&mut t[..]);
1307    let (mut sxy, mut sxx) = (0.0, 0.0);
1308    for &l in inside {
1309        sxy += xfs[l] * t[l].re;
1310        sxx += xfs[l] * xfs[l];
1311    }
1312    let a = if sxx > 0.0 { sxy / sxx } else { 1.0 };
1313    crate::maybe_par_iter_mut!(xfs).for_each(|v| *v *= a);
1314    xfs
1315}
1316
1317// ============================================================================
1318// Single precision: STI Suite's own arithmetic in the two LSQR solves
1319// ============================================================================
1320
1321/// `‖x‖₂` of a single-precision vector, accumulated in f64 over [`dense_parts`] and rounded to
1322/// f32 (the value MATLAB's `norm` of a `single` vector rounds to, up to its BLAS's order).
1323fn cnorm32(x: &[Complex32]) -> f32 {
1324    let parts = dense_parts(x.len());
1325    let xs = split_parts(x, &parts);
1326    let sums: Vec<f64> = crate::maybe_par_iter!(xs)
1327        .map(|c| c.iter().map(|v| (v.re as f64) * (v.re as f64) + (v.im as f64) * (v.im as f64)).sum::<f64>())
1328        .collect();
1329    sums.iter().sum::<f64>().sqrt() as f32
1330}
1331
1332/// MATLAB `lsqr` (zero initial guess, no preconditioner) on `single` data: the iteration and
1333/// stopping rules of [`lsqr_matlab`], with every vector and scalar in single precision, as
1334/// MATLAB runs it when `b` and the operator's results are `single` (a `double` scalar such as
1335/// `tol` or `eps` meeting a `single` one is rounded to single first).
1336fn lsqr_matlab_f32<F, G>(mut apply_a: F, mut apply_ah: G, b: Vec<Complex32>, nx: usize, tol: f64, max_iter: usize) -> (Vec<Complex32>, LsqrOutcome)
1337where
1338    F: FnMut(&[Complex32], &mut [Complex32]),
1339    G: FnMut(&[Complex32], &mut [Complex32]),
1340{
1341    let zero = Complex32::new(0.0, 0.0);
1342    let tol = tol as f32;
1343    let n2b = cnorm32(&b);
1344    let tolb = tol * n2b;
1345    let mut u = b;
1346    let mut beta = n2b;
1347    let mut normr = beta;
1348    let scale = |x: &mut [Complex32], s: f32| crate::maybe_par_iter_mut!(x).for_each(|z| *z /= s);
1349    if beta != 0.0 {
1350        scale(&mut u, beta);
1351    }
1352    let (mut c, mut s) = (1.0f32, 0.0f32);
1353    let mut phibar = beta;
1354    let mut v = vec![zero; nx];
1355    apply_ah(&u, &mut v);
1356    let mut x = vec![zero; nx];
1357    let mut alpha = cnorm32(&v);
1358    if alpha != 0.0 {
1359        scale(&mut v, alpha);
1360    }
1361    let mut normar = alpha * beta;
1362    let relres = |nr: f32| if n2b > 0.0 { (nr / n2b) as f64 } else { 0.0 };
1363    if normar == 0.0 {
1364        return (x, LsqrOutcome { flag: 0, iter: 0, relres: relres(normr) });
1365    }
1366    let mut un = vec![zero; u.len()];
1367    let mut vt = vec![zero; nx];
1368    let mut d = vec![zero; nx];
1369    let mut normx = 0.0f32;
1370    let mut norma = 0.0f32;
1371    let mut stag = 0usize;
1372    let mut flag = 1u8;
1373    let mut iter = max_iter;
1374    let eps = f64::EPSILON as f32;
1375    for ii in 1..=max_iter {
1376        apply_a(&v, &mut un);
1377        crate::maybe_par_iter_mut!(un).zip(crate::maybe_par_iter!(u)).for_each(|(a, &b)| *a -= b * alpha);
1378        beta = cnorm32(&un);
1379        if beta != 0.0 {
1380            scale(&mut un, beta);
1381        }
1382        std::mem::swap(&mut u, &mut un);
1383        norma = (norma * norma + alpha * alpha + beta * beta).sqrt();
1384        let thet = -s * alpha;
1385        let rhot = c * alpha;
1386        let rho = (rhot * rhot + beta * beta).sqrt();
1387        c = rhot / rho;
1388        s = -beta / rho;
1389        let phi = c * phibar;
1390        if phi == 0.0 {
1391            stag = 1;
1392        }
1393        phibar *= s;
1394        let converged = normar / (norma * normr) <= tol || normr <= tolb;
1395        let breakdown = !phi.is_finite() || rho == 0.0 || !rho.is_finite();
1396        crate::maybe_par_iter_mut!(d).zip(crate::maybe_par_iter!(v)).for_each(|(di, &vi)| *di = (vi - *di * thet) / rho);
1397        if phi.abs() * cnorm32(&d) < eps * normx {
1398            stag += 1;
1399        } else {
1400            stag = 0;
1401        }
1402        if converged {
1403            flag = 0;
1404            iter = ii - 1;
1405            break;
1406        }
1407        if stag >= 3 {
1408            flag = 3;
1409            iter = ii - 1;
1410            break;
1411        }
1412        if breakdown {
1413            flag = 4;
1414            iter = ii - 1;
1415            break;
1416        }
1417        crate::maybe_par_iter_mut!(x).zip(crate::maybe_par_iter!(d)).for_each(|(xi, &di)| *xi += di * phi);
1418        normx = cnorm32(&x);
1419        normr *= s.abs();
1420        apply_ah(&u, &mut vt);
1421        crate::maybe_par_iter_mut!(vt).zip(crate::maybe_par_iter!(v)).for_each(|(a, &b)| *a -= b * beta);
1422        std::mem::swap(&mut v, &mut vt);
1423        alpha = cnorm32(&v);
1424        if (alpha == 0.0 || !alpha.is_finite()) && ii < max_iter {
1425            flag = 4;
1426            iter = ii;
1427            break;
1428        }
1429        if alpha != 0.0 {
1430            scale(&mut v, alpha);
1431        }
1432        normar = alpha * (s * phi).abs();
1433    }
1434    if flag == 1 && (normar / (norma * normr) <= tol || normr <= tolb) {
1435        flag = 0;
1436    }
1437    (x, LsqrOutcome { flag, iter, relres: relres(normr) })
1438}
1439
1440/// Step 3 of [`ilsqr`] as STI computes it ([`IlsqrPrecision::Single`]): the right-hand side
1441/// `D W φ` in double, rounded to single; the operator `D W D` evaluated in single precision
1442/// (single 3-D FFTs, `single(D)`, `single(W)`) and MATLAB's `lsqr` on single data. Returns the
1443/// real part of the solution in double, as STI's `double(x1)`.
1444fn initial_solve_single(n: [usize; 3], p: &[f64], w: &[f64], dk: &[f64], max_iter: usize) -> (Vec<f64>, LsqrOutcome) {
1445    let nt = p.len();
1446    let mut b: Vec<Complex64> = p.iter().zip(w).map(|(&pv, &wv)| Complex64::new(wv * pv, 0.0)).collect();
1447    let mut ws64 = Fft3dWorkspace::new(n[0], n[1], n[2]);
1448    ws64.fft3d(&mut b);
1449    b.iter_mut().zip(dk).for_each(|(z, &dv)| *z *= dv);
1450    ws64.ifft3d(&mut b);
1451    drop(ws64);
1452    let b1: Vec<Complex32> = b.iter().map(|z| Complex32::new(z.re as f32, z.im as f32)).collect();
1453    drop(b);
1454    let dks: Vec<f32> = dk.iter().map(|&v| v as f32).collect();
1455    let wss: Vec<f32> = w.iter().map(|&v| v as f32).collect();
1456    let ws = RefCell::new(Fft3dWorkspaceF32::new(n[0], n[1], n[2]));
1457    let apply1 = |x: &[Complex32], out: &mut [Complex32]| {
1458        let mut ws = ws.borrow_mut();
1459        out.copy_from_slice(x);
1460        ws.fft3d(out);
1461        crate::maybe_par_iter_mut!(out).zip(crate::maybe_par_iter!(dks)).for_each(|(z, &dv)| *z *= dv);
1462        ws.ifft3d(out);
1463        crate::maybe_par_iter_mut!(out).zip(crate::maybe_par_iter!(wss)).for_each(|(z, &wv)| *z *= wv);
1464        ws.fft3d(out);
1465        crate::maybe_par_iter_mut!(out).zip(crate::maybe_par_iter!(dks)).for_each(|(z, &dv)| *z *= dv);
1466        ws.ifft3d(out);
1467    };
1468    let (x1, out) = lsqr_matlab_f32(apply1, apply1, b1, nt, 0.01, max_iter);
1469    (x1.iter().map(|z| z.re as f64).collect(), out)
1470}
1471
1472/// Step 6 of [`ilsqr`] as STI computes it ([`IlsqrPrecision::Single`]): the right-hand side
1473/// `G ∇(M·Re χ₁)` in double, rounded to single; the operator `y ↦ G ∇(√N F⁻¹(M_ic y))` and its
1474/// adjoint `r ↦ M_ic F(∇ᴴ(G r)) / √N` in single precision, on dense vectors (the unknown over
1475/// the whole k-space grid, the residual over three whole image grids); MATLAB's `lsqr` on
1476/// single data. Returns `Re F⁻¹(M_ic y)` in double (the artefacts divided by √N), as
1477/// [`artefact_solve`] does.
1478#[allow(clippy::too_many_arguments)]
1479fn artefact_solve_single(n: [usize; 3], x1: &[f64], m: &[f64], gw: &[Vec<f64>], dk: &[f64], tol: f64, max_iter: usize) -> (Vec<f64>, LsqrOutcome) {
1480    let nt = dk.len();
1481    let mic: Vec<f32> = dk.iter().map(|&v| if v.abs() < 0.1 { 1.0 } else { 0.0 }).collect();
1482    let gws: Vec<Vec<f32>> = gw.iter().map(|g| g.iter().map(|&v| v as f32).collect()).collect();
1483    let next = |l: usize, a: usize| circ_next(l, a, n);
1484    let prev = |l: usize, a: usize| {
1485        let step = [1, n[0], n[0] * n[1]][a];
1486        if (l / step).is_multiple_of(n[a]) { l + (n[a] - 1) * step } else { l - step }
1487    };
1488    // b2 = [Wg_a .* fd(x1 .* M, a)] in double, then single
1489    let x1m: Vec<f64> = x1.iter().zip(m).map(|(&x, &mv)| x * mv).collect();
1490    let mut b2 = vec![Complex32::new(0.0, 0.0); 3 * nt];
1491    for (a, ba) in b2.chunks_mut(nt).enumerate() {
1492        let (g, x1m) = (&gw[a], &x1m);
1493        crate::maybe_par_iter_mut!(ba).enumerate().for_each(|(l, o)| *o = Complex32::new((g[l] * (x1m[next(l, a)] - x1m[l])) as f32, 0.0));
1494    }
1495    drop(x1m);
1496    let s = (nt as f64).sqrt() as f32;
1497    let ws = RefCell::new(Fft3dWorkspaceF32::new(n[0], n[1], n[2]));
1498    let t_cell = RefCell::new(vec![Complex32::new(0.0, 0.0); nt]);
1499    let apply2 = |y: &[Complex32], out: &mut [Complex32]| {
1500        let mut t = t_cell.borrow_mut();
1501        crate::maybe_par_iter_mut!(t).zip(crate::maybe_par_iter!(y)).zip(crate::maybe_par_iter!(mic)).for_each(|((o, &yv), &mv)| *o = yv * mv);
1502        ws.borrow_mut().ifft3d(&mut t);
1503        crate::maybe_par_iter_mut!(t).for_each(|z| *z *= s);
1504        let t = &t[..];
1505        for (a, oa) in out.chunks_mut(nt).enumerate() {
1506            let g = &gws[a];
1507            crate::maybe_par_iter_mut!(oa).enumerate().for_each(|(l, o)| *o = (t[next(l, a)] - t[l]) * g[l]);
1508        }
1509    };
1510    let apply2h = |r: &[Complex32], out: &mut [Complex32]| {
1511        // u = Σ_a fdT(r_a .* Wg_a), fdT(q)[l] = q[l − e_a] − q[l], summed from zero in axis order
1512        crate::maybe_par_iter_mut!(out).enumerate().for_each(|(l, o)| {
1513            let mut u = Complex32::new(0.0, 0.0);
1514            for a in 0..3 {
1515                let (ra, g) = (&r[a * nt..(a + 1) * nt], &gws[a]);
1516                let lp = prev(l, a);
1517                u += ra[lp] * g[lp] - ra[l] * g[l];
1518            }
1519            *o = u;
1520        });
1521        ws.borrow_mut().fft3d(out);
1522        crate::maybe_par_iter_mut!(out).zip(crate::maybe_par_iter!(mic)).for_each(|(z, &mv)| *z = *z * mv / s);
1523    };
1524    let (y, out) = lsqr_matlab_f32(apply2, apply2h, b2, nt, tol, max_iter);
1525    drop(t_cell);
1526    let mut xsa: Vec<Complex64> = y.iter().zip(&mic).map(|(z, &mv)| Complex64::new(z.re as f64, z.im as f64) * mv as f64).collect();
1527    drop(y);
1528    Fft3dWorkspace::new(n[0], n[1], n[2]).ifft3d(&mut xsa);
1529    (xsa.iter().map(|z| z.re).collect(), out)
1530}
1531
1532/// STI's k-space coordinates (cycles/mm) on the unshifted layout of an even axis of `n` voxels:
1533/// the signed index *divided* by `n·vs`, as STI computes `(-n/2:n/2-1)/(n*vs)`.
1534///
1535/// [`crate::fft::fftfreq`] multiplies by `1/(n·vs)` instead (numpy's arithmetic), which rounds
1536/// differently. That decides the dipole kernel at the lattice points on the magic angle
1537/// (k_x² + k_y² = 2k_z² for H = z, e.g. (1, 1, 1)): there `D` is 0 or ±5.6e-17 by rounding
1538/// alone, and FastQSM takes `sign(D)` of it. On QSMART's call (48³ grid, 64 of 266 such points
1539/// differing in sign) the multiplied coordinates put iLSQR's χ 0.15 % / 0.094 % NRMSE from
1540/// STI's, while STI's own division reproduces it (astewartau/QSM.rs#396). Neither sign pattern
1541/// is better reasoned than the other (both are rounding), so this follows STI.
1542fn sti_freqs(n: usize, vs: f64) -> Vec<f64> {
1543    let nv = n as f64 * vs;
1544    (0..n).map(|i| if 2 * i < n { i as f64 / nv } else { (i as f64 - n as f64) / nv }).collect()
1545}
1546
1547/// STI's dipole kernel `D = 1/3 − (k·H)² / |k|²` (0 at DC) on the unshifted padded grid, in
1548/// STI's arithmetic: `k` from [`sti_freqs`], `k·H = k_x H_x + k_y H_y + k_z H_z` and
1549/// `|k|² = k_x² + k_y² + k_z²` in that order, and `H` used as given when it is a unit vector
1550/// (normalised otherwise). See [`sti_freqs`] for why the rounding matters.
1551fn sti_dipole_kernel(n: [usize; 3], k: [&[f64]; 3], bdir: (f64, f64, f64)) -> Vec<f64> {
1552    let (mut hx, mut hy, mut hz) = bdir;
1553    let norm = (hx * hx + hy * hy + hz * hz).sqrt();
1554    if (norm - 1.0).abs() > 1e-12 {
1555        (hx, hy, hz) = (hx / norm, hy / norm, hz / norm);
1556    }
1557    let mut d = vec![0.0; n[0] * n[1] * n[2]];
1558    crate::maybe_par_iter_mut!(d).enumerate().for_each(|(l, o)| {
1559        let (i, j, kk) = (l % n[0], (l / n[0]) % n[1], l / (n[0] * n[1]));
1560        let (a, b, c) = (k[0][i], k[1][j], k[2][kk]);
1561        let k2 = a * a + b * b + c * c;
1562        if k2 != 0.0 {
1563            let kh = a * hx + b * hy + c * hz;
1564            *o = 1.0 / 3.0 - kh * kh / k2;
1565        }
1566    });
1567    d
1568}
1569
1570fn sign0(v: f64) -> f64 {
1571    if v > 0.0 { 1.0 } else if v < 0.0 { -1.0 } else { 0.0 }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577    use crate::kernels::dipole::dipole_kernel;
1578
1579    #[test]
1580    fn test_lsqr_simple() {
1581        // Test LSQR on a simple diagonal system
1582        let n = 10;
1583        let diag: Vec<f64> = (1..=n).map(|i| i as f64).collect();
1584        let b: Vec<f64> = diag.iter().map(|&d| d * 2.0).collect();  // x = [2, 2, 2, ...]
1585
1586        let apply_a = |x: &[f64]| -> Vec<f64> {
1587            x.iter().zip(diag.iter()).map(|(&xi, &di)| xi * di).collect()
1588        };
1589
1590        let x = lsqr(apply_a, apply_a, &b, 1e-10, 100);
1591
1592        for (i, &xi) in x.iter().enumerate() {
1593            assert!((xi - 2.0).abs() < 1e-6, "x[{}] = {}, expected 2.0", i, xi);
1594        }
1595    }
1596
1597    // ---------------- STI Suite iLSQR ----------------
1598
1599    fn c64(re: f64, im: f64) -> Complex64 { Complex64::new(re, im) }
1600
1601    type Op = Box<dyn Fn(&[Complex64]) -> Vec<Complex64>>;
1602
1603    /// Dense column-major matrix-vector products (A x, Aᴴ y) for the LSQR tests.
1604    fn dense(a: Vec<Complex64>, m: usize, n: usize) -> (Op, Op) {
1605        let a2 = a.clone();
1606        let fwd = move |x: &[Complex64]| (0..m).map(|i| (0..n).map(|j| a[i + j * m] * x[j]).sum()).collect();
1607        let adj = move |y: &[Complex64]| (0..n).map(|j| (0..m).map(|i| a2[i + j * m].conj() * y[i]).sum()).collect();
1608        (Box::new(fwd), Box::new(adj))
1609    }
1610
1611    #[test]
1612    fn test_lsqr_matlab_matches_matlab_lsqr() {
1613        // Reference values from MATLAB R2023b `[x,flag,relres,iter] = lsqr(A, b, tol, maxit)`.
1614        // Inconsistent 3x2 system: stops on the normal-equations test after 2 iterations.
1615        let a: Vec<Complex64> = [1.0, 3.0, 5.0, 2.0, 4.0, 6.0].iter().map(|&v| c64(v, 0.0)).collect();
1616        let (f, g) = dense(a, 3, 2);
1617        let b = [c64(1.0, 0.0), c64(2.0, 0.0), c64(4.0, 0.0)];
1618        let (x, o) = lsqr_matlab(f, g, &b, 1e-8, 10);
1619        assert_eq!((o.flag, o.iter), (0, 2));
1620        assert!((o.relres - 0.0890870806374748).abs() < 1e-12);
1621        assert!((x[0].re - 0.666666666666665).abs() < 1e-12 && (x[1].re - 0.083333333333331).abs() < 1e-12);
1622
1623        // Ill-conditioned diagonal, iteration cap reached: x after exactly 5 iterations.
1624        let d: Vec<f64> = (0..8).map(|i| 10f64.powf(-3.0 * i as f64 / 7.0)).collect(); // logspace(0, -3, 8)
1625        let mut a = vec![c64(0.0, 0.0); 64];
1626        for i in 0..8 { a[i + 8 * i] = c64(d[i], 0.0); }
1627        let (f, g) = dense(a, 8, 8);
1628        let b = vec![c64(1.0, 0.0); 8];
1629        let (x, o) = lsqr_matlab(f, g, &b, 1e-10, 5);
1630        assert_eq!((o.flag, o.iter), (1, 5));
1631        assert!((o.relres - 0.573674508817809).abs() < 1e-12);
1632        let want = [0.999999943834766, 2.68269578689076, 7.1969177940897, 19.2460743622976,
1633                    59.9361124086567, 25.5070666956926, 9.67511787758301, 3.61516968204123];
1634        // (x(1) moves by ~1e-9 for a 1-ulp change in A here, so x is only checked to 1e-6)
1635        for (xi, wi) in x.iter().zip(want) { assert!((xi.re - wi).abs() < 1e-6 * wi, "{} vs {}", xi.re, wi); }
1636
1637        // Complex 3x2 system.
1638        let a = vec![c64(1.0, 0.0), c64(2.0, 0.0), c64(0.0, 0.5), c64(0.0, 1.0), c64(-1.0, 0.0), c64(3.0, 0.0)];
1639        let (f, g) = dense(a, 3, 2);
1640        let b = [c64(1.0, 0.0), c64(0.0, 1.0), c64(2.0, 0.0)];
1641        let (x, o) = lsqr_matlab(f, g, &b, 1e-12, 20);
1642        assert_eq!((o.flag, o.iter), (0, 2));
1643        assert!((o.relres - 0.449991346403468).abs() < 1e-12);
1644        assert!((x[0] - c64(0.448598130841121, 0.186915887850467)).norm() < 1e-12);
1645        assert!((x[1] - c64(0.635514018691589, -0.168224299065421)).norm() < 1e-12);
1646    }
1647
1648    #[test]
1649    fn test_lsqr_matlab_zero_rhs() {
1650        let (x, o) = lsqr_matlab(|v: &[Complex64]| v.to_vec(), |v: &[Complex64]| v.to_vec(), &[c64(0.0, 0.0); 4], 1e-6, 10);
1651        assert_eq!((o.flag, o.iter), (0, 0));
1652        assert!(x.iter().all(|z| z.norm() == 0.0));
1653    }
1654
1655    #[test]
1656    fn test_sti_bounding_box() {
1657        // STI MaskBoundingBox outputs (1-based) from probing, here 0-based.
1658        type Case = ((usize, usize, usize), [usize; 3], [usize; 3], [[usize; 2]; 3]);
1659        let cases: [Case; 5] = [
1660            ((20, 20, 20), [4, 5, 6], [10, 11, 12], [[4, 11], [5, 12], [6, 13]]),
1661            ((20, 20, 20), [1, 1, 1], [5, 6, 7], [[1, 6], [1, 6], [1, 8]]),
1662            ((20, 20, 20), [14, 15, 16], [20, 20, 20], [[13, 20], [15, 20], [15, 20]]),
1663            ((21, 21, 21), [1, 2, 3], [21, 21, 21], [[1, 20], [2, 21], [2, 21]]),
1664            ((20, 20, 20), [7, 7, 7], [7, 8, 7], [[7, 8], [7, 8], [7, 8]]),
1665        ];
1666        for (dims, lo1, hi1, want) in cases {
1667            let mut m = vec![0u8; dims.0 * dims.1 * dims.2];
1668            for k in lo1[2] - 1..hi1[2] { for j in lo1[1] - 1..hi1[1] { for i in lo1[0] - 1..hi1[0] {
1669                m[i + j * dims.0 + k * dims.0 * dims.1] = 1;
1670            } } }
1671            let (lo, hi) = sti_bounding_box(&m, dims).unwrap();
1672            for d in 0..3 { assert_eq!([lo[d] + 1, hi[d] + 1], want[d], "dims {:?} box {:?}..{:?}", dims, lo1, hi1); }
1673        }
1674        assert!(sti_bounding_box(&[0u8; 8], (2, 2, 2)).is_none());
1675    }
1676
1677    #[test]
1678    fn test_sti_dipole_kernel_rounds_as_sti() {
1679        // On the magic angle D is 0 or ±5.6e-17 by rounding alone, and FastQSM takes its sign.
1680        // STI divides the index by n·vs: (5, 5, 5) and (7, 7, 7) on 48³ give −5.6e-17, (7, 23, 17)
1681        // and (17, 17, 17) give 0 (multiplying by 1/(n·vs), as fftfreq does, swaps them).
1682        let n = [48; 3];
1683        let k = sti_freqs(48, 1.0);
1684        let d = sti_dipole_kernel(n, [&k, &k, &k], (0.0, 0.0, 1.0));
1685        let at = |i: usize, j: usize, l: usize| d[i + 48 * (j + 48 * l)];
1686        assert_eq!(at(5, 5, 5), -5.551115123125783e-17);
1687        assert_eq!(at(7, 7, 7), -5.551115123125783e-17);
1688        assert_eq!(at(7, 23, 17), 0.0);
1689        assert_eq!(at(17, 17, 17), 0.0);
1690        assert_eq!(at(0, 0, 0), 0.0);
1691        assert_eq!(k[47], -1.0 / 48.0);
1692        // away from the cone it is the usual kernel
1693        let usual = dipole_kernel(&Grid::new(48, 48, 48, 1.0, 1.0, 1.0), (0.0, 0.0, 1.0));
1694        assert!(d.iter().zip(&usual).all(|(a, b)| (a - b).abs() < 1e-15));
1695    }
1696
1697    #[test]
1698    fn test_sti_padded_dims() {
1699        // (crop, voxel size, padsize) -> STI's padded size, from probing QSM_iLSQR
1700        type Case = ([usize; 3], [f64; 3], [f64; 3], [usize; 3]);
1701        let cases: [Case; 6] = [
1702            ([32, 30, 8], [0.8, 0.8, 3.0], [64.0; 3], [208, 192, 64]),
1703            ([48, 44, 12], [0.8, 0.8, 3.0], [0.0; 3], [64, 48, 16]),
1704            ([32, 34, 32], [1.3; 3], [10.0; 3], [64, 64, 64]),
1705            ([32, 30, 18], [1.3, 0.7, 1.1], [10.0, 3.0, 7.0], [64, 48, 32]),
1706            ([40, 42, 10], [1.05, 2.2, 3.3], [20.0; 3], [80, 64, 32]),
1707            ([46, 42, 10], [0.5, 0.5, 2.0], [64.0; 3], [304, 304, 80]),
1708        ];
1709        // axes are padded independently, so the per-axis probes check each axis on its own
1710        for (c, vs, pad, want) in cases {
1711            assert_eq!([0, 1, 2].map(|d| sti_padded_dims(c, vs, pad[d])[d]), want);
1712        }
1713    }
1714
1715    #[test]
1716    fn test_sti_percentile_is_rounded_index() {
1717        let v: Vec<f64> = (1..=10).map(|i| i as f64).collect();
1718        assert_eq!(sti_percentile(&v, 0.25), 3.0); // round(2.5) = 3, no interpolation
1719        assert_eq!(sti_percentile(&v, 0.999), 10.0);
1720        assert_eq!(sti_percentile(&v, 0.0), 1.0);
1721    }
1722
1723    /// `grad_masked` is the weighted circular forward difference on the mask voxels, and
1724    /// `grad_masked_adj` its adjoint.
1725    #[test]
1726    fn test_grad_masked_and_adjoint() {
1727        let n = [6, 5, 4];
1728        let nt = 120;
1729        let inside: Vec<usize> = (0..nt).filter(|&l| (l * 7) % 5 != 0).collect();
1730        let nm = inside.len();
1731        let mut mask_of = vec![NONE; nt];
1732        for (i, &l) in inside.iter().enumerate() { mask_of[l] = i as u32; }
1733        let u: Vec<Complex64> = (0..nt).map(|i| c64((i as f64 * 0.37).sin(), (i as f64 * 0.11).cos())).collect();
1734        let r: Vec<Complex64> = (0..3 * nm).map(|i| c64((i as f64 * 0.23).cos(), (i as f64 * 0.71).sin())).collect();
1735        let gw: Vec<Vec<f64>> = (0..3).map(|a| (0..nm).map(|i| ((i * 7 + a * 3) % 11) as f64 / 10.0).collect()).collect();
1736        let mut g = vec![c64(0.0, 0.0); 3 * nm];
1737        grad_masked(&|l| u[l], &inside, &gw, n, &mut g);
1738        for a in 0..3 {
1739            let (s, len) = ([1, 6, 30][a], n[a]);
1740            for (i, &l) in inside.iter().enumerate() {
1741                let c = (l / s) % len;
1742                let nb = if c + 1 == len { l - (len - 1) * s } else { l + s };
1743                assert_eq!(g[a * nm + i], (u[nb] - u[l]) * gw[a][i]);
1744            }
1745        }
1746        let mut h = vec![c64(9.0, 9.0); nt];
1747        grad_masked_adj(&r, &mask_of, &gw, n, &[0..6, 0..5, 0..4], &mut h);
1748        let lhs: Complex64 = g.iter().zip(&r).map(|(a, b)| a.conj() * b).sum();
1749        let rhs: Complex64 = u.iter().zip(&h).map(|(a, b)| a.conj() * b).sum();
1750        assert!((lhs - rhs).norm() < 1e-12);
1751    }
1752
1753    /// LSQR on vectors stored compactly (`compact_parts`) gives exactly the iterates of the
1754    /// dense solve whose vectors are zero off the support.
1755    #[test]
1756    fn test_lsqr_compact_equals_dense() {
1757        let nt = 3 * NORM_CHUNK + 1234;
1758        let supp: Vec<usize> = (0..nt).filter(|&l| (l / 1000) % 3 != 1 && l % 7 != 3).collect();
1759        let w: Vec<f64> = (0..nt).map(|l| 0.5 + ((l * 13) % 17) as f64 / 7.0).collect();
1760        let b: Vec<Complex64> = (0..nt).map(|l| if supp.binary_search(&l).is_ok() { c64((l as f64 * 0.01).sin(), (l as f64 * 0.003).cos()) } else { c64(0.0, 0.0) }).collect();
1761        // diagonal operator restricted to the support
1762        let dense = |v: &[Complex64]| -> Vec<Complex64> { v.iter().zip(&w).map(|(&z, &wv)| z * wv).collect() };
1763        let (xd, od) = lsqr_matlab(dense, dense, &b, 1e-14, 15);
1764        let wc: Vec<f64> = supp.iter().map(|&l| w[l]).collect();
1765        let bc: Vec<Complex64> = supp.iter().map(|&l| b[l]).collect();
1766        let parts = compact_parts(supp.iter().copied());
1767        let op = |v: &[Complex64], out: &mut [Complex64]| out.iter_mut().zip(v).zip(&wc).for_each(|((o, &z), &wv)| *o = z * wv);
1768        let (xc, oc) = lsqr_matlab_into(op, op, bc, (&parts, &parts), 1e-14, 15);
1769        assert_eq!(od, oc);
1770        for (i, &l) in supp.iter().enumerate() { assert_eq!(xd[l], xc[i]); }
1771    }
1772
1773    /// Sphere of susceptibility forward-modelled through the dipole kernel on a 2x grid.
1774    fn sphere_phantom(n: usize, vs: (f64, f64, f64), bdir: (f64, f64, f64)) -> (Vec<f64>, Vec<u8>, Vec<f64>) {
1775        let nt = n * n * n;
1776        let c = (n as f64 - 1.0) / 2.0;
1777        let mut chi = vec![0.0; nt];
1778        let mut mask = vec![0u8; nt];
1779        for k in 0..n { for j in 0..n { for i in 0..n {
1780            let (x, y, z) = (i as f64 - c, j as f64 - c, k as f64 - c);
1781            let r2 = x * x + y * y + z * z;
1782            let l = i + j * n + k * n * n;
1783            if r2 < (0.4 * n as f64).powi(2) { mask[l] = 1; }
1784            if (x - 2.0).powi(2) + y * y + z * z < 9.0 { chi[l] = 0.1; }
1785            if (x + 3.0).powi(2) + (y - 1.0).powi(2) + z * z < 4.0 { chi[l] = -0.05; }
1786        } } }
1787        let m2 = 2 * n;
1788        let g2 = Grid::new(m2, m2, m2, vs.0, vs.1, vs.2);
1789        let d = dipole_kernel(&g2, bdir);
1790        let mut big = vec![Complex64::new(0.0, 0.0); m2 * m2 * m2];
1791        for k in 0..n { for j in 0..n { for i in 0..n { big[i + j * m2 + k * m2 * m2].re = chi[i + j * n + k * n * n]; } } }
1792        let mut ws = Fft3dWorkspace::new(m2, m2, m2);
1793        ws.fft3d(&mut big);
1794        for (z, &dv) in big.iter_mut().zip(&d) { *z *= dv; }
1795        ws.ifft3d(&mut big);
1796        let mut field = vec![0.0; nt];
1797        for k in 0..n { for j in 0..n { for i in 0..n {
1798            let l = i + j * n + k * n * n;
1799            if mask[l] != 0 { field[l] = big[i + j * m2 + k * m2 * m2].re; }
1800        } } }
1801        (field, mask, chi)
1802    }
1803
1804    fn corr_in(a: &[f64], b: &[f64], m: &[u8]) -> f64 {
1805        let idx: Vec<usize> = (0..a.len()).filter(|&i| m[i] != 0).collect();
1806        let n = idx.len() as f64;
1807        let (ma, mb) = (idx.iter().map(|&i| a[i]).sum::<f64>() / n, idx.iter().map(|&i| b[i]).sum::<f64>() / n);
1808        let (mut sab, mut saa, mut sbb) = (0.0, 0.0, 0.0);
1809        for &i in &idx { sab += (a[i] - ma) * (b[i] - mb); saa += (a[i] - ma).powi(2); sbb += (b[i] - mb).powi(2); }
1810        sab / (saa * sbb).sqrt()
1811    }
1812
1813    #[test]
1814    fn test_ilsqr_sti_recovers_spheres() {
1815        let n = 16;
1816        let bdir = (0.1, -0.2, 0.95f64.sqrt());
1817        let (field, mask, chi) = sphere_phantom(n, (1.0, 1.0, 1.0), bdir);
1818        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1819        let mut steps = Vec::new();
1820        let params = IlsqrParams { max_iter: 40, pad_mm: 4.0, ..IlsqrParams::default() };
1821        let (x, xsa, xfs, x1) = ilsqr(&field, &mask, &grid, bdir, &params, |s, t| steps.push((s, t)));
1822        assert_eq!(steps, vec![(1, 4), (2, 4), (3, 4), (4, 4)]);
1823        for v in [&x, &xsa, &xfs, &x1] {
1824            assert!(v.iter().all(|z| z.is_finite()));
1825            assert!(v.iter().zip(&mask).all(|(&z, &m)| m != 0 || z == 0.0), "output must be masked");
1826        }
1827        let (r, r1) = (corr_in(&x, &chi, &mask), corr_in(&x1, &chi, &mask));
1828        println!("corr with truth: chi {:.4}, initial LSQR {:.4}", r, r1);
1829        assert!(r > 0.9 && r1 > 0.8, "corr with truth {} / {}", r, r1);
1830        // chi = initial LSQR minus artefacts
1831        for i in 0..x.len() { assert!((x[i] - (x1[i] - xsa[i])).abs() < 1e-12); }
1832        // linear in the field (up to LSQR rounding: the artefact solve runs 100 unconverged steps)
1833        let f2: Vec<f64> = field.iter().map(|v| 3.0 * v).collect();
1834        let (y, _, _, _) = ilsqr(&f2, &mask, &grid, bdir, &params, |_, _| {});
1835        let x3: Vec<f64> = x.iter().map(|v| 3.0 * v).collect();
1836        let worst = y.iter().zip(&x3).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
1837        println!("scaling: max|chi(3f) - 3 chi(f)| = {:.2e}", worst);
1838        assert!(corr_in(&y, &x3, &mask) > 0.9999 && worst < 0.01 * 0.3, "not linear: {}", worst);
1839    }
1840
1841    #[test]
1842    fn test_ilsqr_sti_empty_mask_and_zero_field() {
1843        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
1844        let (x, _, _, _) = ilsqr(&[1.0; 512], &[0u8; 512], &grid, (0.0, 0.0, 1.0), &IlsqrParams::default(), |_, _| {});
1845        assert!(x.iter().all(|&v| v == 0.0));
1846        let mut mask = vec![0u8; 512];
1847        for k in 2..6 { for j in 2..6 { for i in 2..6 { mask[i + 8 * j + 64 * k] = 1; } } }
1848        let p4 = IlsqrParams { pad_mm: 4.0, ..IlsqrParams::default() };
1849        let (x, _, _, _) = ilsqr(&[0.0; 512], &mask, &grid, (0.0, 0.0, 1.0), &p4, |_, _| {});
1850        assert!(x.iter().all(|&v| v == 0.0));
1851    }
1852
1853    #[test]
1854    fn test_ilsqr_params_defaults() {
1855        let p = IlsqrParams::default();
1856        // QSM_iLSQR's own niter (100, as UK Biobank and STI_Templates.m run it); the GUI's padsize 12
1857        assert_eq!((p.tol, p.max_iter, p.pad_mm), (1e-3, STI_DEFAULT_NITER, STI_GUI_PAD_MM));
1858        assert_eq!((STI_DEFAULT_NITER, STI_GUI_NITER, STI_GUI_PAD_MM), (100, 40, 12.0));
1859        assert_eq!((STI_PAD_MM, STI_DEFAULT_PAD_MM), (64.0, 6.0));
1860        assert!(p.validate().is_ok());
1861    }
1862
1863    #[test]
1864    fn test_ilsqr_pad_sets_the_grid() {
1865        // 8^3 at 2 mm, mask = the whole volume (crop 8): next multiple of 16 above 8 + 2 pad/2 + 1
1866        let n = 8;
1867        let bdir = (0.0, 0.0, 1.0);
1868        let (field, _, _) = sphere_phantom(n, (2.0, 2.0, 2.0), bdir);
1869        let mask = vec![1u8; n * n * n];
1870        let run = |vs: (f64, f64, f64), p: &IlsqrParams| {
1871            let grid = Grid::new(n, n, n, vs.0, vs.1, vs.2);
1872            let ((x, _, _, _), tr) = ilsqr_traced(&field, &mask, &grid, bdir, p, |_, _| {});
1873            (tr.dims, x)
1874        };
1875        let iso = (2.0, 2.0, 2.0);
1876        let with_pad = |pad_mm: f64| IlsqrParams { max_iter: 2, pad_mm, ..IlsqrParams::default() };
1877        let (d_default, x_default) = run(iso, &IlsqrParams { max_iter: 2, ..IlsqrParams::default() });
1878        let (d12, x12) = run(iso, &with_pad(STI_GUI_PAD_MM));
1879        assert_eq!((d_default, d12), ([32; 3], [32; 3]));
1880        // the default is exactly an explicit 12 mm (STI Suite GUI's padding)
1881        assert!(x_default.iter().zip(&x12).all(|(a, b)| a.to_bits() == b.to_bits()));
1882        assert_eq!(run(iso, &with_pad(STI_DEFAULT_PAD_MM)).0, [16; 3]);
1883        let (d16, x16) = run(iso, &with_pad(16.0));
1884        assert_eq!(d16, [32; 3]);
1885        let (d64, x64) = run(iso, &with_pad(STI_PAD_MM));
1886        assert_eq!(d64, [80; 3]);
1887        assert!(x16.iter().zip(&x64).any(|(a, b)| a != b), "padding should change chi");
1888        assert_eq!(run(iso, &with_pad(0.0)).0, [16; 3]);
1889        // the padding is in mm, so fewer voxels along a coarser axis
1890        assert_eq!(run((2.0, 2.0, 4.0), &with_pad(STI_PAD_MM)).0, [80, 80, 48]);
1891    }
1892
1893    #[test]
1894    fn test_ilsqr_pad_validation() {
1895        for bad in [-1.0, f64::NAN, f64::INFINITY] {
1896            let p = IlsqrParams { pad_mm: bad, ..IlsqrParams::default() };
1897            assert!(p.validate().is_err(), "pad {bad} accepted");
1898        }
1899        assert!(IlsqrParams { pad_mm: 0.0, ..IlsqrParams::default() }.validate().is_ok());
1900    }
1901
1902    #[test]
1903    #[should_panic(expected = "iLSQR padding must be finite")]
1904    fn test_ilsqr_rejects_negative_pad() {
1905        let grid = Grid::new(8, 8, 8, 1.0, 1.0, 1.0);
1906        let p = IlsqrParams { pad_mm: -1.0, ..IlsqrParams::default() };
1907        ilsqr(&[0.0; 512], &[1u8; 512], &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
1908    }
1909
1910}