Skip to main content

qsm_core/separation/
chi_sep_ilsqr.rs

1//! χ-separation (iLSQR): the SNU χ-separation toolbox's `chi_sep_iLSQR`
2//!
3//! [`chi_sep_ilsqr`] implements **`chi_sep_iLSQR` of the χ-separation toolbox v1.1.3** (SNU-LIST,
4//! Jongho Lee's lab; `functions/corefunc/chi_sep_iLSQR.p` and its `chi_sep_iLSQR_step1_func.p`,
5//! `step2_func.p`, `step3_func.p`), as its script `Chisep_script.m` calls it (l. 167-172):
6//! `chi_sep_iLSQR(mag, local_field_hz, r2prime, mask, params, option_data)` with `params.b0_dir`,
7//! `params.CF`, `params.voxel_size`, `option_data.qsm` and `option_data.N_std`. The toolbox ships as
8//! protected P-code; with the authors' permission the algorithm was recovered by black-box probing
9//! in licensed MATLAB R2023b, as for [`super::chi_sep_medi`]: the profiler (which lists the bodies
10//! of the P-code's anonymous functions verbatim), logging wrappers on the functions it calls that
11//! save the caller's whole workspace (`evalin('caller', 'save ...')`), replaying the function
12//! handles captured in those workspaces, and step-by-step comparison of each step function's
13//! inputs and outputs. A MATLAB re-implementation built from that reproduces `chi_sep_iLSQR` to
14//! rounding with the same iteration counts (see *Agreement with the toolbox*).
15//!
16//! Earlier QSM.rs versions ported Shin et al. 2021's Supplementary Methods instead: an
17//! edge-masked-TV Gauss-Newton solve with an SNR weight from the magnitude. The toolbox runs a
18//! different algorithm, an iLSQR-style three-step solve (Li et al. 2015) on the two sources; the
19//! old port's output differed from the toolbox's by relative L2 0.51 (χ+) / 0.54 (|χ−|) in vivo
20//! and 1.06 / 1.30 on QSM-CI's `chisep-mc` phantom (the cases below).
21//!
22//! # The algorithm
23//!
24//! The unknowns are `x+ = χ+` and `x− = |χ−|` in ppm. Everything is converted to ppm of the
25//! central frequency first: the local field `f`, R2′ as `r = max(R2′/CF·10⁶, 0)` (negative R2′ is
26//! clamped, after the conversion) and `d± = D_r,±/CF·10⁶`. With `Dc(x) = real(ifftn(D.*fftn(x)))`:
27//!
28//! 1. **Defaults** (`chi_sep_iLSQR`): `B0 = CF/42.58·10⁶`; `D_r,pos = D_r,neg = 137·(B0/2.9)`
29//!    ([`toolbox_default_dr`]); `pad_size` [16 16 16]; `cg_max_iter_LSQR` 50, `cg_tol_LSQR` 0.02,
30//!    `cg_max_iter_SA` 50, `cg_tol_SA` 0.02, `D2_thr` 0.11, `prc_W_I_min`/`max` 60/99.8,
31//!    `WG_prc_min`/`max` 50/70 (see [`ChiSepIlsqrParams`]); not parameters: the NNLS tolerance
32//!    0.01 and `iter_max` 10 ([`NNLS_TOL`], [`NNLS_ITER_MAX`]).
33//! 2. **Padding**: every volume is zero-padded by `pad_size` voxels at both ends of every axis;
34//!    the steps below run on that grid and crop their output back.
35//! 3. **Dipole kernel**: MEDI's `dipole_kernel(N, vs, B0_dir, 'kspace')`, the same as
36//!    `chi_sep_MEDI`'s, including its half-bin offset on odd axes.
37//! 4. **Step 1, LSQR** (`chi_sep_iLSQR_step1_func`). Weights: `W_I = clamp((hi − L)/(hi − lo),
38//!    0, 1)` with `L = |del2(f, vs(1), vs(2), vs(3))|` and `lo`, `hi` its `prctile`s 60 and 99.8
39//!    inside the mask. (`del2` takes its first spacing argument for the second array axis, so on a
40//!    grid with `vs(1) ≠ vs(2)` the first two spacings are swapped; kept.) `w_mag = W_I·m`,
41//!    `w_phs = 2π·W_I·m` (`m` the mask). With `Ar(x, d) = Dc(w_mag.*(d.*x))`, `ArH(y, d) =
42//!    d.*(w_mag.*Dc(y))` and `Af = AfH = Dc(w_phs.*Dc(·))`, MATLAB's `pcg` solves
43//!    `[ArH(Ar(x+,d+) + Ar(x−,d−), d+) + AfH(Af(x+ − x−)); ArH(…, d−) − AfH(Af(x+ − x−))] =
44//!    [ArH(Dc(w_mag.*r), d+) + AfH(Dc(w_phs.*f)); ArH(Dc(w_mag.*r), d−) − AfH(Dc(w_phs.*f))]` for
45//!    the concatenated `[x+ x−]` (tolerance `cg_tol_LSQR`, at most `cg_max_iter_LSQR` iterations).
46//!    The start is the voxelwise solution from the QSM `χ`: `x+ = max((r + d−·χ)/(d+ + d−), 0)`,
47//!    `x− = max((r − d+·χ)/(d+ + d−), 0)`, masked. (Without a QSM the toolbox uses `χ = 0`.)
48//! 5. **Step 2, streaking artefacts** (`chi_sep_iLSQR_step2_func`), per source:
49//!    - a fast QSM `χ_fs` of the field: `W = clamp((|D|^0.001 − a)/(b − a), 0, 1)` with `a`, `b`
50//!      the `prctile`s 1 and 30 of `|D|^0.001` over the whole grid; `K` the k-space smoothing by a
51//!      2.5 mm partial-volume ball (`pv_ball` below); `k1 = ifftshift(fftn(f).*sign(D))`, then
52//!      `χ2 = real(ifftn(fftshift(W.*k1 + (1 − W).*K(k1))))`. That multiplies the weight, laid out
53//!      for an unshifted spectrum, with the *shifted* spectrum (a quirk of the toolbox, kept).
54//!      Then `k = fftn(χ2.*m)`, `χ3 = real(ifftn(W.*k + (1 − W).*K(k))).*m`, and `χ_fs = c₁ + c₂·χ3`
55//!      with `(c₁, c₂)` the least-squares fit of `χ3` to the TKD solution (threshold 1/8, masked)
56//!      over the whole grid;
57//!    - R2′ smoothed in k-space by a Tukey window (`tukeywin(N, 0.5)` per axis, applied as
58//!      `fftshift(ifftshift(fftn(r)).*w)`);
59//!    - gradient weights `W_G± = clamp((hi − |g±|)/(hi − lo), 0, 1)·m` per axis, `lo`/`hi` the
60//!      `prctile`s 50/70 of `|g±|` inside the mask, `g+ = (∇r_s + d−·∇χ_fs)/(d+ + d−)`,
61//!      `g− = (∇r_s − d+·∇χ_fs)/(d+ + d−)` (∇: MEDI's `fgrad` with the voxel size, on the masked
62//!      volumes);
63//!    - the streaking component, by MATLAB's `pcg` on a **complex k-space unknown** `y` restricted
64//!      to the cone `M_ic = |D| < D2_thr`: `M_ic.*fftn(∇ᵀ(W_G²·∇ real(ifftn(M_ic.*y)))) =
65//!      M_ic.*fftn(∇ᵀ(W_G²·∇x))` (∇ here with unit spacing, ∇ᵀ its exact adjoint; tolerance
66//!      `cg_tol_SA`, at most `cg_max_iter_SA` iterations, zero start), and the output is
67//!      `(x − real(ifftn(M_ic.*y)).*m).*m`.
68//! 6. **Step 3, NNLS** (`chi_sep_iLSQR_step3_func`): repeat step 1 with one `pcg` iteration
69//!    starting from the current iterate (not clamped), then step 2, then project both sources onto
70//!    `x ≥ 0`. `res = (‖Δx+‖ + ‖Δx−‖)/(‖x+‖ + ‖x−‖)` of the projected iterates; the loop runs while
71//!    `res > 0.01` and at most `iter_max + 1` = 11 times (its counter starts at 1 and is tested
72//!    with `≤ iter_max + 1`). The output is the iterate with the smallest `res`.
73//! 7. **Output**: `x_para = x+`, `x_dia = x−` (≥ 0), `x_tot = x+ − x−`, zero outside the mask.
74//!    QSM.rs returns `(χ+, χ− = −x−, χ+ + χ−)`.
75//!
76//! **MATLAB `pcg`.** The solver is R2023b's `pcg.m` without a preconditioner, line for line: the
77//! residual recomputed when the recurrence says converged or stagnates (up to `min(n/50, 5,
78//! n − maxit)` extra steps), and, when it does not converge, the iterate with the smallest
79//! residual returned instead of the last one (so a one-iteration solve that does not lower the
80//! residual returns its start). For the complex unknown of step 2 it uses the conjugate inner
81//! product and compares `p'·q ≤ 0` by its real part, as MATLAB does.
82//!
83//! # Inputs
84//!
85//! `Chisep_script.m` passes the magnitude, the local field in Hz, R2′, the mask and
86//! `option_data.qsm` (STI Suite `QSM_iLSQR`) and `option_data.N_std`. **`chi_sep_iLSQR` never
87//! uses `mag` or `N_std`**: neither reaches its step functions, and the re-implementation that
88//! ignores them matches the toolbox (and its output is unchanged when they change, see the
89//! fixture generator). [`ChiSepIlsqrInputs`] therefore takes the local field, R2′, the QSM and the
90//! mask. The QSM is required, as the script passes it. The toolbox's GUI (`WrapperChisepiLSQR.p`,
91//! probed with a stand-in app object) passes **no QSM** (`option_data` holds only `N_std` and
92//! zero `mask_Unrel_Mag`/`mask_Unrel_Phs`), so its runs start step 1 from zero; QSM.rs does not
93//! offer that path.
94//!
95//! # Units
96//!
97//! The toolbox takes the local field in Hz and converts it to ppm; [`chi_sep_ilsqr`] takes it in
98//! ppm directly. R2′ is in Hz, the QSM and the output in ppm. [`ChiSepIlsqrParams::cf`] sets the
99//! ppm scale of R2′ and `D_r` and the default `D_r`.
100//!
101//! # Agreement with the toolbox
102//!
103//! Compared with `chi_sep_iLSQR` itself (toolbox v1.1.3 in licensed MATLAB R2023b; QSM.rs in
104//! double precision, as the toolbox runs):
105//!
106//! - **Phantoms** (`tests/chi_sep_ilsqr_snu_parity.rs`, fixture from
107//!   `tests/data/chi_sep_ilsqr_snu_small.m`): 40×36×28 at 3 T; 33×29×21 at 7 T, 0.8×0.9×1.5 mm,
108//!   oblique B0; negative R2′ with `D_r` 120/150. Same NNLS iteration counts (2, 4, 4); χ+ and
109//!   |χ−| to relative L2 ≤ 4·10⁻¹⁰ (3·10⁻¹⁴ on the second). The toolbox itself moves by up to
110//!   1.5·10⁻⁹ on these phantoms between 8 threads and 1.
111//! - **In vivo** (the EMTP travelling-head scan of QSM-CI's fidelity check, 3 T, 184×256×128;
112//!   its local field, R2′ and χ, 1.39 M mask voxels): 4 NNLS iterations each; χ+ relative L2
113//!   3.1·10⁻¹¹ (max |Δ| 2.5·10⁻¹⁰ ppm), |χ−| 3.5·10⁻¹¹ (3.2·10⁻¹⁰ ppm).
114//! - **QSM-CI's χ-separation phantom** (`chisep-mc`, 7 T, 164×205×205, same inputs):
115//!   4 NNLS iterations each; χ+ relative L2 2.5·10⁻¹² (max |Δ| 8.8·10⁻¹² ppm), |χ−| 3.2·10⁻¹²
116//!   (7.2·10⁻¹² ppm). The toolbox run with 4 instead of 8 threads moves by 1.1·10⁻¹² / 1.5·10⁻¹²
117//!   here and 2.6·10⁻¹² / 2.9·10⁻¹² in vivo.
118//! - **The verified QSM-CI `chi-sep-ilsqr` container** (QSMxT/QSM-CI#306; its own STI Suite QSM,
119//!   `tests/chi_sep_ilsqr_qsmci_parity.rs`, regenerated in Actions by
120//!   `tests/data/chi_sep_ilsqr_qsmci_small.py`): χ+ and |χ−| to relative L2 3·10⁻⁸ on a 24×22×18
121//!   phantom, the container's float32 output (measured 2026-10-30). On `chisep-mc` and the
122//!   in-vivo scan above, given the container's own QSM (its intermediate) and its `CF`
123//!   (42.5774 MHz/T·B0): χ+ and |χ−| to relative L2 2.6·10⁻⁸ and 2.5·10⁻⁸, max |Δ| 7·10⁻⁹ and
124//!   3·10⁻⁸ ppm, its float32 rounding (`examples/chi_sep_file.rs`, measured 2026-11-01). QSM-CI's
125//!   `chisep-ilsqr` / `chisep-ilsqr-qsmrs` parity pair checks this on `chisep-mc`.
126//!
127//! The residual is rounding inside the iterations. QSM.rs evaluates the same expressions with its
128//! own FFT and, where that saves work, in a different order (step 1's operator applies `Dc∘Dc`
129//! once to the sum of the two sources' R2′ terms instead of to each; the fast QSM's fit uses
130//! centred sums instead of a QR solve), so every operation rounds differently, where the
131//! toolbox's thread count changes only its reductions; in vivo that puts QSM.rs at about ten
132//! times the toolbox's thread-count spread. The solve does not amplify noise in its input: a
133//! ±10⁻¹⁵ relative perturbation of the field moves QSM.rs's in-vivo result by 4·10⁻¹⁵.
134//!
135//! # Performance
136//!
137//! Each step-1 `pcg` iteration costs six dipole convolutions (12 complex 3-D FFTs) on the padded
138//! grid; each step-2 iteration two FFTs; an NNLS iteration is one step-1 iteration (three operator
139//! applications) and two step-2 solves. The fixed parts (weights, fast QSM, smoothed R2′) are
140//! computed once, where the toolbox recomputes them in every NNLS iteration. In vivo
141//! (216×288×160 padded, 4 NNLS iterations) the toolbox takes 433 s on 8 threads and on
142//! `chisep-mc` (196×237×237) 1113 s (743 s on 4); QSM.rs took 280 s on 6 threads and 398 s with
143//! the memory layout before the one below, which is faster again (155 s → 91 s on 12 threads on
144//! a phantom of the in-vivo size).
145//!
146//! **Memory.** The arithmetic is the toolbox's, in the order above; what is stored is cut to what
147//! each step needs, without changing a single operation:
148//!
149//! - the dipole kernel is evaluated where it is used, from per-axis tables, by the same
150//!   expressions as `chi_sep_medi`'s kernel (bit for bit, tested);
151//! - step 1's operator chains its convolutions through one complex buffer (each convolution's
152//!   real part is the next one's input, as before) and keeps its R2′ term in its output;
153//! - `W_I` and the NNLS iterates are stored at the mask's voxels only (they are zero elsewhere);
154//!   step 2's weights `W_G²` are recomputed where used from the masked fast QSM and smoothed R2′
155//!   (kept on the mask's bounding box) and their percentiles, by the same expressions; the
156//!   gradient of step 2's regulariser is fused into its adjoint;
157//! - step 2's complex `pcg` stores only the cone's voxels (19 % of k-space at the default
158//!   `D2_thr`): its vectors are zero outside the cone, and its sums add the same terms in the
159//!   same chunks;
160//! - `pcg` copies its best iterate (`xmin`) only once it differs from the current one, and the
161//!   NNLS iterations' start is rebuilt from the iterates rather than stored;
162//! - the k-space shifts run in place.
163//!
164//! The peak is step 1's first solve: `pcg`'s `x`, `xmin`, `q`, `r`, `p` (10 `f64` per padded
165//! voxel), the right-hand side (2) and the FFT buffer (2), plus what the NNLS iterations keep
166//! (the mask, `W_I`, the two volumes `W_G²` is computed from, the cone): about **14.25 `f64` per padded voxel +
167//! 1.5 per mask voxel + 2 per voxel of the mask's bounding box**. Later, with the current and best
168//! iterates held (4 per mask voxel), it is 12.25 + 5.5 per mask voxel + 2 per box voxel, which is
169//! more only if the mask fills over half the padded grid; a bound for any mask is 14.25 per padded
170//! voxel + 7.5 per input voxel. A larger `D2_thr` widens the cone and step 2's share (it exceeds
171//! step 1's above about 0.25). Measured on the phantom of `examples/chi_sep_ilsqr_memory.rs` at
172//! the in-vivo size (184×256×128, 16 % of the padded grid in the mask): a 1.20 GB heap peak, 15.1
173//! `f64` per padded voxel (1.34 GB resident), where the earlier layout needed 3.43 GB (43 per padded
174//! voxel). The results are the same numbers: χ+, χ− and χ_total equal the earlier layout's bit for
175//! bit inside the mask (on the parity fixtures and that phantom, with and without threads); outside
176//! it both are zero, now +0 throughout where the earlier one left some −0.
177//!
178//! # Deviations
179//!
180//! None in the algorithm. Options of `chi_sep_iLSQR` that `Chisep_script.m` does not use are not
181//! exposed: `option_data.mask_Unrel_Mag` / `mask_Unrel_Phs` (masks of unreliable magnitude and
182//! phase voxels whose data weights drop; both default to zero), and `params.flag_strong_x_correction`
183//! / `strong_x_thresh` (off by default). The start without a QSM (the GUI's) is not offered.
184//!
185//! # Reference pipeline
186//!
187//! What `Chisep_script.m` (toolbox v1.1.3) runs before `chi_sep_iLSQR` is what it runs before
188//! `chi_sep_MEDI` (see [`super::chi_sep_medi`]'s *Reference pipeline*): Tukey windowing, MEDI's
189//! `BET`, ROMEO with weighted echo averaging (`W = TE·exp(−TE/40 ms)`), V-SHARP `smvsize` 12, the
190//! local field in Hz (l. 139), and the QSM from STI Suite `QSM_iLSQR(..., 'padsize', [12 12 12])`
191//! (l. 145, [`chisep_toolbox_ilsqr`]) passed as `option_data.qsm` (l. 170). R2′ is the user's.
192//! `N_std` (l. 76) is passed (l. 171) but not used.
193//!
194//! Reference: Shin, H.-G., Lee, J., Yun, Y. H., Yoo, S. H., Jang, J., Oh, S.-H., Nam, Y., Jung,
195//! S., Kim, S., Fukunaga, M., Kim, W., Choi, H. J., Lee, J. (2021). "χ-separation: Magnetic
196//! susceptibility source separation toward iron and myelin mapping in the brain." NeuroImage,
197//! 240:118371. <https://doi.org/10.1016/j.neuroimage.2021.118371>. The three steps follow Li, W.,
198//! Wang, N., Yu, F., et al. (2015). "A method for estimating and removing streaking artifacts in
199//! quantitative susceptibility mapping." NeuroImage, 108:111-122.
200//!
201//! # Pipeline rules
202//!
203//! The facts above that a configuration can depart from are published as rules in
204//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source):
205//! `chi-sep.mask-bet`, `chi-sep.unwrap-romeo`, `chi-sep.echo-combination`, `chi-sep.echo-weights`,
206//! `chi-sep.bg-vsharp`, `chi-sep-ilsqr.qsm-ilsqr`, and by manufacturer `chi-sep.tukey-siemens-ge`
207//! and `chi-sep.tukey-philips` (`Chisep_script.m` Tukey-windows the echoes before this method as
208//! before χ-sepnet; [`crate::utils::tukey_window`]). All are the authors' setting
209//! (`Kind::Reference`).
210
211use crate::error::{check_len, InputError};
212use crate::fft::Fft3dWorkspace;
213use crate::inversion::medi::{par_fill, par_update};
214use crate::inversion::{IlsqrParams, STI_DEFAULT_NITER, STI_GUI_PAD_MM};
215use crate::Grid;
216use num_complex::Complex64;
217#[cfg(feature = "parallel")]
218use rayon::prelude::*;
219
220use super::chi_sep_medi::toolbox_default_dr;
221use super::ChiSepMaps;
222
223/// NNLS stopping tolerance of `chi_sep_iLSQR_step3_func` (its `tol_norm_ratio`, not a parameter).
224pub const NNLS_TOL: f64 = 0.01;
225/// `iter_max` of `chi_sep_iLSQR_step3_func` (not a parameter). The NNLS loop runs at most
226/// `NNLS_ITER_MAX + 1` = 11 times: its counter starts at 1 and is tested with `≤ iter_max + 1`.
227pub const NNLS_ITER_MAX: usize = 10;
228/// Radius, in mm, of the ball the fast QSM smooths k-space with.
229const BALL_RADIUS_MM: f64 = 2.5;
230/// TKD threshold of the fast QSM's scaling reference.
231const TKD_THRESHOLD: f64 = 0.125;
232/// Tukey window ratio of the R2′ smoothing.
233const TUKEY_RATIO: f64 = 0.5;
234
235/// The iLSQR call of the χ-separation toolbox (`Chisep_script.m` l. 142-145) for the QSM that
236/// [`chi_sep_ilsqr`] and [`super::chi_sep_medi`] start from: [`STI_DEFAULT_NITER`] iterations
237/// (`QSM_iLSQR`'s own default, as the toolbox calls it without `'niter'`),
238/// [`STI_GUI_PAD_MM`] padding, STI's tolerances.
239pub fn chisep_toolbox_ilsqr() -> IlsqrParams {
240    IlsqrParams { max_iter: STI_DEFAULT_NITER, pad_mm: STI_GUI_PAD_MM, ..IlsqrParams::default() }
241}
242
243/// Parameters for [`chi_sep_ilsqr`]. The defaults are the toolbox's (see the module docs); the
244/// field names after the toolbox's `params` fields are given with each.
245#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
246#[derive(Clone, Debug)]
247pub struct ChiSepIlsqrParams {
248    /// Central frequency in Hz (`params.CF`; default 123.2 MHz). The field strength the
249    /// defaults of [`Self::dr_pos`] and [`Self::dr_neg`] scale with is `cf / 42.58e6`.
250    pub cf: f64,
251    /// Paramagnetic relaxometric constant in Hz/ppm (`params.Dr_pos`). `None` (the default):
252    /// the toolbox's `137·(B0/2.9)` ([`toolbox_default_dr`]).
253    pub dr_pos: Option<f64>,
254    /// Diamagnetic relaxometric constant in Hz/ppm (`params.Dr_neg`). `None` (the default): the
255    /// same as [`Self::dr_pos`]'s default.
256    pub dr_neg: Option<f64>,
257    /// Zero padding, in voxels, added at both ends of each axis (`params.pad_size`; default
258    /// [16, 16, 16]).
259    pub pad: [usize; 3],
260    /// Step 1's `pcg` iterations at most (`params.cg_max_iter_LSQR`; default 50). The NNLS
261    /// iterations run one.
262    pub cg_max_iter_lsqr: usize,
263    /// Step 1's `pcg` relative tolerance (`params.cg_tol_LSQR`; default 0.02).
264    pub cg_tol_lsqr: f64,
265    /// Step 2's `pcg` iterations at most, per source (`params.cg_max_iter_SA`; default 50).
266    pub cg_max_iter_sa: usize,
267    /// Step 2's `pcg` relative tolerance (`params.cg_tol_SA`; default 0.02).
268    pub cg_tol_sa: f64,
269    /// The streaking cone is `|D| < d2_thr` (`params.D2_thr`; default 0.11).
270    pub d2_thr: f64,
271    /// Percentiles (0-100) of the field's `|del2|` inside the mask at which step 1's weight is 1
272    /// and 0 (`params.prc_W_I_min`, `prc_W_I_max`; default 60, 99.8).
273    pub prc_w_i: (f64, f64),
274    /// Percentiles (0-100) of each gradient component's magnitude inside the mask at which step
275    /// 2's weights are 1 and 0 (`params.WG_prc_min`, `WG_prc_max`; default 50, 70).
276    pub prc_w_g: (f64, f64),
277    /// iLSQR settings for the conventional QSM (`option_data.qsm`) that χ-separation is
278    /// initialised with, for hosts that reconstruct it (the pipeline takes the QSM as an input).
279    /// Default [`chisep_toolbox_ilsqr`]: 100 iterations, 12 mm padding, as `Chisep_script.m`
280    /// (l. 142-145) calls STI Suite's `QSM_iLSQR`: `'padsize', [12 12 12]` and no `'niter'`, so
281    /// the function's default of 100. This differs from [`IlsqrParams::default`] (the STI Suite
282    /// GUI's 40 iterations).
283    pub qsm_ilsqr: IlsqrParams,
284}
285
286impl Default for ChiSepIlsqrParams {
287    fn default() -> Self {
288        Self {
289            cf: 123.2e6,
290            dr_pos: None,
291            dr_neg: None,
292            pad: [16; 3],
293            cg_max_iter_lsqr: 50,
294            cg_tol_lsqr: 0.02,
295            cg_max_iter_sa: 50,
296            cg_tol_sa: 0.02,
297            d2_thr: 0.11,
298            prc_w_i: (60.0, 99.8),
299            prc_w_g: (50.0, 70.0),
300            qsm_ilsqr: chisep_toolbox_ilsqr(),
301        }
302    }
303}
304
305impl ChiSepIlsqrParams {
306    /// The relaxometric constants used, `(D_r,pos, D_r,neg)` in Hz/ppm: the explicit values, or
307    /// the toolbox's [`toolbox_default_dr`].
308    pub fn dr(&self) -> (f64, f64) {
309        let default = toolbox_default_dr(self.cf);
310        (self.dr_pos.unwrap_or(default), self.dr_neg.unwrap_or(default))
311    }
312
313    /// Checks the parameters [`chi_sep_ilsqr`] checks.
314    pub fn validate(&self) -> Result<(), InputError> {
315        let pos = |name: &str, v: f64| {
316            if v.is_finite() && v > 0.0 {
317                Ok(())
318            } else {
319                Err(InputError::param(name, v, "finite and > 0"))
320            }
321        };
322        pos("χ-separation iLSQR cf", self.cf)?;
323        let (dp, dn) = self.dr();
324        pos("χ-separation iLSQR dr_pos", dp)?;
325        pos("χ-separation iLSQR dr_neg", dn)?;
326        pos("χ-separation iLSQR d2_thr", self.d2_thr)?;
327        for (name, v) in [("χ-separation iLSQR cg_tol_lsqr", self.cg_tol_lsqr), ("χ-separation iLSQR cg_tol_sa", self.cg_tol_sa)] {
328            if !(v.is_finite() && v > 0.0 && v < 1.0) {
329                return Err(InputError::param(name, v, "in (0, 1)"));
330            }
331        }
332        for (name, (lo, hi)) in [("χ-separation iLSQR prc_w_i", self.prc_w_i), ("χ-separation iLSQR prc_w_g", self.prc_w_g)] {
333            if !(lo.is_finite() && hi.is_finite() && (0.0..=100.0).contains(&lo) && (0.0..=100.0).contains(&hi) && lo < hi) {
334                return Err(InputError::param(name, format!("({lo}, {hi})"), "0 ≤ min < max ≤ 100"));
335            }
336        }
337        Ok(())
338    }
339}
340
341/// The volumes [`chi_sep_ilsqr`] takes. `chi_sep_iLSQR(mag, local_field_hz, r2prime, mask, params,
342/// option_data)` also takes the magnitude and `option_data.N_std`, but never uses them (see the
343/// module docs). All are on the grid, column-major.
344#[derive(Clone, Copy)]
345pub struct ChiSepIlsqrInputs<'a> {
346    /// Local field in ppm (the toolbox takes it in Hz and converts it to ppm).
347    pub local_field: &'a [f64],
348    /// R2′ in Hz (`r2prime`); negative values are clamped at 0, as the toolbox does.
349    pub r2prime: &'a [f64],
350    /// Conventional QSM in ppm (`option_data.qsm`), step 1's start; required, as
351    /// `Chisep_script.m` passes it.
352    pub qsm: &'a [f64],
353    /// Brain mask (`mask`), nonzero inside.
354    pub mask: &'a [u8],
355}
356
357/// χ-separation by the SNU χ-separation toolbox's `chi_sep_iLSQR` (see the module docs).
358///
359/// Takes the volumes of [`ChiSepIlsqrInputs`] on `grid` and the B0 direction `bdir` (used as
360/// given, as the toolbox uses `params.b0_dir`; pass a unit vector), and returns `(χ+, χ−,
361/// χ_total)` in ppm, χ+ ≥ 0, χ− ≤ 0, zero outside the mask. `progress` receives
362/// `(nnls_iteration, NNLS_ITER_MAX + 1)` after each NNLS iteration.
363///
364/// # Errors
365/// An [`InputError`] for parameters [`ChiSepIlsqrParams::validate`] rejects, for a volume whose
366/// length is not the grid's, for an empty mask, and for a padded grid of more than 2³² voxels.
367pub fn chi_sep_ilsqr(
368    inputs: &ChiSepIlsqrInputs,
369    grid: &Grid,
370    bdir: (f64, f64, f64),
371    params: &ChiSepIlsqrParams,
372    mut progress: impl FnMut(usize, usize),
373) -> Result<ChiSepMaps, InputError> {
374    params.validate()?;
375    let n0 = grid.n_total();
376    for (name, len) in [
377        ("χ-separation iLSQR local_field", inputs.local_field.len()),
378        ("χ-separation iLSQR r2prime", inputs.r2prime.len()),
379        ("χ-separation iLSQR qsm", inputs.qsm.len()),
380        ("χ-separation iLSQR mask", inputs.mask.len()),
381    ] {
382        check_len(name, len, n0)?;
383    }
384    if !inputs.mask.iter().any(|&m| m != 0) {
385        return Err(InputError("χ-separation iLSQR mask is empty".into()));
386    }
387    let padded: usize = (0..3).map(|a| [grid.dims.0, grid.dims.1, grid.dims.2][a] + 2 * params.pad[a]).product();
388    if padded > u32::MAX as usize {
389        return Err(InputError(format!("χ-separation iLSQR padded grid has {padded} voxels, more than 2^32")));
390    }
391    let (pb, mut ws, r) = Problem::new(inputs, grid, bdir, params);
392    let n = pb.n;
393
394    // Step 1 from the QSM, then step 2
395    let (drp, drn) = (pb.drp, pb.drn);
396    let mut x0 = vec![0.0; 2 * n];
397    {
398        let q = pb.pad_input(inputs.qsm);
399        let (a, b) = x0.split_at_mut(n);
400        let m = &pb.m;
401        par_fill(a, |i| ((r[i] + drn * q[i]) / (drp + drn)).max(0.0) * f64::from(m[i]));
402        par_fill(b, |i| ((r[i] - drp * q[i]) / (drp + drn)).max(0.0) * f64::from(m[i]));
403    }
404    drop(r);
405    let mut x = pb.step1(&mut ws, Start::Owned(x0), params.cg_tol_lsqr, params.cg_max_iter_lsqr);
406    pb.step2(&mut ws, &mut x, params);
407    // The iterates live on the mask's voxels only (they are zero elsewhere): `[x+; x−]` there.
408    let mut cur = pb.on_mask(&x, false);
409    drop(x);
410
411    // Step 3: NNLS
412    let ni = pb.inside.len();
413    let max_it = NNLS_ITER_MAX + 1;
414    let mut res = f64::INFINITY;
415    let mut res_min = f64::INFINITY;
416    let mut best = cur.clone();
417    let mut it = 1;
418    while res > NNLS_TOL && it <= max_it {
419        // the start [x+.*m; x−.*m], rebuilt by `pcg` when it needs it again
420        let start = |x: &mut [f64]| pb.padded_from_mask(&cur, x);
421        let mut x = pb.step1(&mut ws, Start::Fill(&start), params.cg_tol_lsqr, 1);
422        pb.step2(&mut ws, &mut x, params);
423        // a.*(a > 0)
424        let new = pb.on_mask(&x, true);
425        drop(x);
426        let sums = |f: &(dyn Fn(usize) -> f64 + Sync)| pb.inside.sum(f).sqrt();
427        let (np, nn) = (&new[..ni], &new[ni..]);
428        let (cp, cn) = (&cur[..ni], &cur[ni..]);
429        res = (sums(&|j| (np[j] - cp[j]) * (np[j] - cp[j])) + sums(&|j| (nn[j] - cn[j]) * (nn[j] - cn[j])))
430            / (sums(&|j| np[j] * np[j]) + sums(&|j| nn[j] * nn[j]));
431        cur = new;
432        if res < res_min {
433            res_min = res;
434            best.copy_from_slice(&cur);
435        }
436        progress(it, max_it);
437        it += 1;
438    }
439    drop(cur);
440
441    let (chi_pos, chi_neg) = pb.crop_from_mask(&best);
442    let chi_total = chi_pos.iter().zip(&chi_neg).map(|(p, q)| p + q).collect();
443    Ok((chi_pos, chi_neg, chi_total))
444}
445
446/// Chunk length of [`det_sum`]'s partial sums.
447const CHUNK: usize = 1 << 15;
448
449/// A set of voxels of the padded grid, for vectors stored on those voxels only (the voxels of
450/// the full vector outside it are zero): their indices, ascending, and for each [`CHUNK`] of the
451/// full grid where its entries start. Sums over such a vector ([`Self::sum`]) add the same terms
452/// in the same chunks as [`det_sum`] over the full vector, less the zeros, so they are equal.
453struct Support {
454    idx: Vec<u32>,
455    bounds: Vec<usize>,
456}
457
458impl Support {
459    /// The voxels of `0..n` where `keep`
460    fn new(n: usize, keep: impl Fn(usize) -> bool) -> Self {
461        let mut idx = Vec::new();
462        let mut bounds = Vec::with_capacity(n.div_ceil(CHUNK) + 1);
463        for l in 0..n {
464            if l.is_multiple_of(CHUNK) {
465                bounds.push(idx.len());
466            }
467            if keep(l) {
468                idx.push(l as u32);
469            }
470        }
471        bounds.push(idx.len());
472        idx.shrink_to_fit();
473        Self { idx, bounds }
474    }
475
476    fn len(&self) -> usize {
477        self.idx.len()
478    }
479
480    /// [`det_sum`] of `f` over the full vector, `f(j)` the term of the set's `j`-th voxel
481    fn sum<T>(&self, f: impl Fn(usize) -> T + Sync) -> T
482    where
483        T: Copy + Send + Sync + std::ops::Add<Output = T> + Default,
484    {
485        chunked_sum(self.bounds.len() - 1, |c| self.bounds[c]..self.bounds[c + 1], f)
486    }
487
488    /// `f(l, Some(j))` for the set's `j`-th voxel `l` and `f(l, None)` for the others, over the
489    /// voxels of chunk `c` of a grid of `n`, in order
490    fn walk(&self, c: usize, n: usize, mut f: impl FnMut(usize, Option<usize>)) {
491        let (mut j, end) = (self.bounds[c], self.bounds[c + 1]);
492        for l in c * CHUNK..((c + 1) * CHUNK).min(n) {
493            if j < end && self.idx[j] as usize == l {
494                f(l, Some(j));
495                j += 1;
496            } else {
497                f(l, None);
498            }
499        }
500    }
501}
502
503/// `f(c, chunk)` for each [`CHUNK`] of `a` (over threads with the `parallel` feature)
504fn par_chunked<T: Send>(a: &mut [T], f: impl Fn(usize, &mut [T]) + Sync + Send) {
505    #[cfg(feature = "parallel")]
506    a.par_chunks_mut(CHUNK).enumerate().for_each(|(c, s)| f(c, s));
507    #[cfg(not(feature = "parallel"))]
508    a.chunks_mut(CHUNK).enumerate().for_each(|(c, s)| f(c, s));
509}
510
511/// [`par_chunked`] over two volumes at once
512fn par_chunked2<A: Send, B: Send>(a: &mut [A], b: &mut [B], f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) {
513    #[cfg(feature = "parallel")]
514    a.par_chunks_mut(CHUNK).zip(b.par_chunks_mut(CHUNK)).enumerate().for_each(|(c, (s, t))| f(c, s, t));
515    #[cfg(not(feature = "parallel"))]
516    a.chunks_mut(CHUNK).zip(b.chunks_mut(CHUNK)).enumerate().for_each(|(c, (s, t))| f(c, s, t));
517}
518
519/// `f(j, k, l0, row)` for each x-row of a volume (`l0` its first index; over threads with the
520/// `parallel` feature)
521fn par_rows<T: Send>(a: &mut [T], (nx, ny, _): (usize, usize, usize), f: impl Fn(usize, usize, usize, &mut [T]) + Sync + Send) {
522    #[cfg(feature = "parallel")]
523    a.par_chunks_mut(nx).with_min_len(64).enumerate().for_each(|(r, row)| f(r % ny, r / ny, r * nx, row));
524    #[cfg(not(feature = "parallel"))]
525    a.chunks_mut(nx).enumerate().for_each(|(r, row)| f(r % ny, r / ny, r * nx, row));
526}
527
528/// The toolbox's dipole kernel (`chi_sep_medi::toolbox_dipole_kernel`) evaluated where it is used, from its
529/// per-axis k coordinates: the same expressions on the same values, so the same numbers, without
530/// a volume of it.
531struct Kernel {
532    /// k coordinate of each array index, per axis, after the kernel's `fftshift`
533    k: [Vec<f64>; 3],
534    b0: (f64, f64, f64),
535}
536
537impl Kernel {
538    fn new(dims: (usize, usize, usize), vs: [f64; 3], b0: (f64, f64, f64)) -> Self {
539        let axis = |n: usize, v: f64| {
540            let mut t = vec![0.0; n];
541            for i in 0..n {
542                t[(i + n / 2) % n] = (-(n as f64) / 2.0 + i as f64) / (n as f64 * v);
543            }
544            t
545        };
546        Self { k: [axis(dims.0, vs[0]), axis(dims.1, vs[1]), axis(dims.2, vs[2])], b0 }
547    }
548
549    /// `D` at array index `(i, j, k)`
550    #[inline]
551    fn at(&self, i: usize, j: usize, k: usize) -> f64 {
552        let (x, y, z) = (self.k[0][i], self.k[1][j], self.k[2][k]);
553        let s = x * self.b0.0 + y * self.b0.1 + z * self.b0.2;
554        let den = x * x + y * y + z * z;
555        let v = 1.0 / 3.0 - (s * s) / den;
556        if v.is_nan() {
557            0.0
558        } else {
559            v
560        }
561    }
562}
563
564/// A volume stored on a box of the padded grid, zero outside it.
565struct BoxVol {
566    lo: [usize; 3],
567    len: [usize; 3],
568    data: Vec<f64>,
569}
570
571impl BoxVol {
572    fn new(a: &[f64], (nx, ny, _): (usize, usize, usize), lo: [usize; 3], hi: [usize; 3]) -> Self {
573        let len = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
574        let mut data = Vec::with_capacity(len[0] * len[1] * len[2]);
575        for k in lo[2]..hi[2] {
576            for j in lo[1]..hi[1] {
577                let s = lo[0] + nx * (j + ny * k);
578                data.extend_from_slice(&a[s..s + len[0]]);
579            }
580        }
581        Self { lo, len, data }
582    }
583
584    #[inline]
585    fn get(&self, p: [usize; 3]) -> f64 {
586        let q = [p[0].wrapping_sub(self.lo[0]), p[1].wrapping_sub(self.lo[1]), p[2].wrapping_sub(self.lo[2])];
587        if q[0] < self.len[0] && q[1] < self.len[1] && q[2] < self.len[2] {
588            self.data[q[0] + self.len[0] * (q[1] + self.len[1] * q[2])]
589        } else {
590            0.0
591        }
592    }
593}
594
595/// The arrays of one run, on the padded grid. What is fixed across the NNLS iterations is kept
596/// in the least memory that gives the same numbers (see *Performance* in the module docs).
597struct Problem {
598    dims: (usize, usize, usize),
599    n: usize,
600    /// input grid and padding
601    dims0: (usize, usize, usize),
602    pad: [usize; 3],
603    vs: [f64; 3],
604    /// mask (0/1)
605    m: Vec<u8>,
606    /// the mask's voxels
607    inside: Support,
608    /// `D_r,±` in ppm
609    drp: f64,
610    drn: f64,
611    /// dipole kernel (`dipole_kernel(..., 'kspace')`)
612    d: Kernel,
613    /// step 1's data weight `W_I` at the mask's voxels: `w_mag = W_I.*m` there, 0 elsewhere,
614    /// and `w_phs = 2π·w_mag`
615    w_i: Vec<f64>,
616    /// step 1's right-hand side, `[b+; b−]`
617    b1: Vec<f64>,
618    /// step 2's gradient weights are recomputed where used from the masked fast QSM and smoothed
619    /// R2′ (zero outside the mask, so kept on its bounding box) and their percentiles `(lo, hi)`
620    /// per source and axis
621    xm: BoxVol,
622    rm: BoxVol,
623    wg_thr: [[(f64, f64); 3]; 2],
624    /// step 2's cone `|D| < D2_thr`
625    cone: Support,
626}
627
628/// FFT buffers reused across operator applications.
629struct Workspace {
630    fft: Fft3dWorkspace,
631    c: Vec<Complex64>,
632}
633
634impl Problem {
635    /// The problem, the FFT workspace, and R2′ (ppm, clamped) on the padded grid for the start.
636    fn new(inputs: &ChiSepIlsqrInputs, grid: &Grid, bdir: (f64, f64, f64), params: &ChiSepIlsqrParams) -> (Self, Workspace, Vec<f64>) {
637        let dims0 = grid.dims;
638        let pad = params.pad;
639        let dims = (dims0.0 + 2 * pad[0], dims0.1 + 2 * pad[1], dims0.2 + 2 * pad[2]);
640        let n = dims.0 * dims.1 * dims.2;
641        let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
642        let mut pb = Self {
643            dims,
644            n,
645            dims0,
646            pad,
647            vs,
648            m: Vec::new(),
649            inside: Support { idx: Vec::new(), bounds: Vec::new() },
650            drp: 0.0,
651            drn: 0.0,
652            d: Kernel::new(dims, vs, bdir),
653            w_i: Vec::new(),
654            b1: Vec::new(),
655            xm: BoxVol { lo: [0; 3], len: [0; 3], data: Vec::new() },
656            rm: BoxVol { lo: [0; 3], len: [0; 3], data: Vec::new() },
657            wg_thr: [[(0.0, 0.0); 3]; 2],
658            cone: Support { idx: Vec::new(), bounds: Vec::new() },
659        };
660        let mut ws = Workspace { fft: Fft3dWorkspace::new(dims.0, dims.1, dims.2), c: vec![Complex64::new(0.0, 0.0); n] };
661        let cf = params.cf;
662        let mask: Vec<u8> = inputs.mask.iter().map(|&v| u8::from(v != 0)).collect();
663        pb.m = pb.pad_input(&mask);
664        drop(mask);
665        pb.inside = Support::new(n, |l| pb.m[l] != 0);
666        let mut r = pb.pad_input(inputs.r2prime);
667        par_update(&mut r, |_, v| *v = (*v / cf * 1e6).max(0.0));
668        let f = pb.pad_input(inputs.local_field);
669        let (dp, dn) = params.dr();
670        pb.drp = dp / cf * 1e6;
671        pb.drn = dn / cf * 1e6;
672
673        // Step 1: weights and right-hand side
674        let mut lap = del2(&f, dims, [vs[1], vs[0], vs[2]]);
675        par_update(&mut lap, |_, v| *v = v.abs());
676        let (lo, hi) = prc_on(&pb.inside, |l| lap[l], params.prc_w_i);
677        pb.w_i = pb.inside.idx.iter().map(|&l| clamp01((hi - lap[l as usize]) / (hi - lo))).collect();
678        drop(lap);
679        let mut b1 = vec![0.0; 2 * n];
680        {
681            let (bp, bn) = b1.split_at_mut(n);
682            // Dc(Dc(w_mag.*r)), shared by ArH(·, d±)
683            pb.load_w(&mut ws.c, |l, w| w * r[l]);
684            pb.dc_c(&mut ws);
685            pb.dc_out(&mut ws, bp);
686            // Af(Dc(w_phs.*f))
687            pb.load_w(&mut ws.c, |l, w| 2.0 * std::f64::consts::PI * w * f[l]);
688            pb.dc_c(&mut ws);
689            pb.af_c(&mut ws);
690            let c = &ws.c;
691            par_fill(bn, |i| c[i].re);
692            let (drp, drn) = (pb.drp, pb.drn);
693            let (w_i, inside) = (&pb.w_i, &pb.inside);
694            par_chunked2(bp, bn, |ch, sp, sn| {
695                let o = ch * CHUNK;
696                inside.walk(ch, n, |l, j| {
697                    let w = j.map_or(0.0, |j| w_i[j]);
698                    let (t, af) = (sp[l - o], sn[l - o]);
699                    sp[l - o] = dp_mul(drp, w * t) + af;
700                    sn[l - o] = dp_mul(drn, w * t) - af;
701                });
702            });
703        }
704        pb.b1 = b1;
705
706        // Step 2: fast QSM, smoothed R2′, gradient weights, cone
707        let mut xm = pb.fast_qsm(&mut ws, f, vs);
708        let mut rm = pb.tukey_lpf(&mut ws, &r);
709        let m = &pb.m;
710        par_update(&mut xm, |i, v| *v *= f64::from(m[i]));
711        par_update(&mut rm, |i, v| *v *= f64::from(m[i]));
712        let (mut lo, mut hi) = ([usize::MAX; 3], [0; 3]);
713        for &l in &pb.inside.idx {
714            let l = l as usize;
715            let p = [l % dims.0, (l / dims.0) % dims.1, l / (dims.0 * dims.1)];
716            for a in 0..3 {
717                lo[a] = lo[a].min(p[a]);
718                hi[a] = hi[a].max(p[a] + 1);
719            }
720        }
721        pb.xm = BoxVol::new(&xm, dims, lo, hi);
722        drop(xm);
723        pb.rm = BoxVol::new(&rm, dims, lo, hi);
724        drop(rm);
725        for s in 0..2 {
726            for c in 0..3 {
727                let at = |l: usize| {
728                    let p = [l % dims.0, (l / dims.0) % dims.1, l / (dims.0 * dims.1)];
729                    pb.g_pm(s, c, p)
730                };
731                pb.wg_thr[s][c] = prc_on(&pb.inside, at, params.prc_w_g);
732            }
733        }
734        let (d, (nx, ny, _)) = (&pb.d, dims);
735        pb.cone = Support::new(n, |l| d.at(l % nx, (l / nx) % ny, l / (nx * ny)).abs() < params.d2_thr);
736        (pb, ws, r)
737    }
738
739    /// `padarray(a, pad, 0, 'both')` of an input-grid volume
740    fn pad_input<T: Copy + Default>(&self, a: &[T]) -> Vec<T> {
741        let (nx, ny, nz) = self.dims;
742        let (mx, my, _) = self.dims0;
743        let [px, py, pz] = self.pad;
744        let mut out = vec![T::default(); nx * ny * nz];
745        for k in 0..self.dims0.2 {
746            for j in 0..my {
747                let src = mx * (j + my * k);
748                let dst = px + nx * ((j + py) + ny * (k + pz));
749                out[dst..dst + mx].copy_from_slice(&a[src..src + mx]);
750            }
751        }
752        out
753    }
754
755    /// `[x+; x−]` of a padded `[x+; x−]` at the mask's voxels, with `a.*(a > 0)` if `clamp`
756    fn on_mask(&self, x: &[f64], clamp: bool) -> Vec<f64> {
757        let ni = self.inside.len();
758        let mut out = vec![0.0; 2 * ni];
759        let (n, idx) = (self.n, &self.inside.idx);
760        par_fill(&mut out, |j| {
761            let v = if j < ni { x[idx[j] as usize] } else { x[n + idx[j - ni] as usize] };
762            if clamp {
763                v * f64::from(u8::from(v > 0.0))
764            } else {
765                v
766            }
767        });
768        out
769    }
770
771    /// The padded `[x+.*m; x−.*m]` of `[x+; x−]` at the mask's voxels, into `x`
772    fn padded_from_mask(&self, v: &[f64], x: &mut [f64]) {
773        let (n, ni) = (self.n, self.inside.len());
774        let (a, b) = x.split_at_mut(n);
775        let inside = &self.inside;
776        par_chunked2(a, b, |ch, sa, sb| {
777            let o = ch * CHUNK;
778            inside.walk(ch, n, |l, j| {
779                (sa[l - o], sb[l - o]) = j.map_or((0.0, 0.0), |j| (v[j], v[ni + j]));
780            });
781        });
782    }
783
784    /// `(χ+, χ−)` on the input grid from `[x+; x−]` at the mask's voxels (`χ− = −x−`)
785    fn crop_from_mask(&self, v: &[f64]) -> (Vec<f64>, Vec<f64>) {
786        let (nx, ny, _) = self.dims;
787        let (mx, my, mz) = self.dims0;
788        let [px, py, pz] = self.pad;
789        let ni = self.inside.len();
790        let (mut p, mut q) = (vec![0.0; mx * my * mz], vec![0.0; mx * my * mz]);
791        for (j, &l) in self.inside.idx.iter().enumerate() {
792            let l = l as usize;
793            let (i, jj, k) = (l % nx, (l / nx) % ny, l / (nx * ny));
794            let o = (i - px) + mx * ((jj - py) + my * (k - pz));
795            p[o] = v[j];
796            q[o] = -v[ni + j];
797        }
798        (p, q)
799    }
800
801    /// Zero the padding ring (the toolbox crops each step's output and pads the next step's input)
802    fn zero_ring(&self, a: &mut [f64]) {
803        let (nx, ny, _) = self.dims;
804        let (mx, my, mz) = self.dims0;
805        let [px, py, pz] = self.pad;
806        par_update(a, |l, v| {
807            let (i, j, k) = (l % nx, (l / nx) % ny, l / (nx * ny));
808            if i < px || i >= px + mx || j < py || j >= py + my || k < pz || k >= pz + mz {
809                *v = 0.0;
810            }
811        });
812    }
813
814    /// `c ← f(l, w)` as a real volume, with `w = W_I` at the mask's voxels and 0 elsewhere (the
815    /// value of `w_mag` there)
816    fn load_w(&self, c: &mut [Complex64], f: impl Fn(usize, f64) -> f64 + Sync + Send) {
817        let (n, w_i, inside) = (self.n, &self.w_i, &self.inside);
818        par_chunked(c, |ch, s| {
819            let o = ch * CHUNK;
820            inside.walk(ch, n, |l, j| s[l - o] = Complex64::new(f(l, j.map_or(0.0, |j| w_i[j])), 0.0));
821        });
822    }
823
824    /// `c ← D.*c`
825    fn kmul(&self, c: &mut [Complex64]) {
826        let d = &self.d;
827        par_rows(c, self.dims, |j, k, _, row| {
828            for (i, v) in row.iter_mut().enumerate() {
829                *v *= d.at(i, j, k);
830            }
831        });
832    }
833
834    /// `c ← ifftn(D.*fftn(c))`
835    fn dc_raw(&self, ws: &mut Workspace) {
836        ws.fft.fft3d(&mut ws.c);
837        self.kmul(&mut ws.c);
838        ws.fft.ifft3d(&mut ws.c);
839    }
840
841    /// `c ← Dc(c)`, `c` holding a real volume: `real(ifftn(D.*fftn(c)))`, as a real volume
842    fn dc_c(&self, ws: &mut Workspace) {
843        self.dc_raw(ws);
844        par_update(&mut ws.c, |_, v| *v = Complex64::new(v.re, 0.0));
845    }
846
847    /// `out ← Dc(c)`, `c` holding a real volume
848    fn dc_out(&self, ws: &mut Workspace, out: &mut [f64]) {
849        self.dc_raw(ws);
850        let c = &ws.c;
851        par_fill(out, |i| c[i].re);
852    }
853
854    /// `c ← Af(c) = Dc(w_phs.*Dc(c))`, `c` holding a real volume
855    fn af_c(&self, ws: &mut Workspace) {
856        self.dc_raw(ws);
857        let (n, w_i, inside) = (self.n, &self.w_i, &self.inside);
858        par_chunked(&mut ws.c, |ch, s| {
859            let o = ch * CHUNK;
860            inside.walk(ch, n, |l, j| {
861                let w = j.map_or(0.0, |j| 2.0 * std::f64::consts::PI * w_i[j]);
862                s[l - o] = Complex64::new(s[l - o].re * w, 0.0);
863            });
864        });
865        self.dc_c(ws);
866    }
867
868    /// Step 1's normal operator on `[x+; x−]`
869    fn a1(&self, ws: &mut Workspace, x: &[f64], out: &mut [f64]) {
870        let n = self.n;
871        let (xp, xn) = x.split_at(n);
872        let (drp, drn) = (self.drp, self.drn);
873        let (op, on) = out.split_at_mut(n);
874        // R2′ part: ArH(Ar(x+, d+) + Ar(x−, d−), d±) = d±.*(w_mag.*Dc(Dc(w_mag.*(d+ x+ + d− x−)))),
875        // the inner part in `op` until the end
876        self.load_w(&mut ws.c, |l, w| w * (drp * xp[l]) + w * (drn * xn[l]));
877        self.dc_c(ws);
878        self.dc_out(ws, op);
879        // field part: Af(Af(x+ − x−))
880        par_fill(&mut ws.c, |i| Complex64::new(xp[i] - xn[i], 0.0));
881        self.af_c(ws);
882        self.af_c(ws);
883        let (c, w_i, inside) = (&ws.c, &self.w_i, &self.inside);
884        par_chunked2(op, on, |ch, sp, sn| {
885            let o = ch * CHUNK;
886            inside.walk(ch, n, |l, j| {
887                let w = j.map_or(0.0, |j| w_i[j]);
888                let (s, t) = (sp[l - o], c[l].re);
889                sp[l - o] = dp_mul(drp, w * s) + t;
890                sn[l - o] = dp_mul(drn, w * s) - t;
891            });
892        });
893    }
894
895    /// Step 1: `pcg` from `start`; returns the solution with the padding ring zeroed.
896    fn step1(&self, ws: &mut Workspace, start: Start<f64>, tol: f64, maxit: usize) -> Vec<f64> {
897        let o = pcg(|v, o| self.a1(ws, v, o), &self.b1, tol, maxit, start, 2 * self.n, None);
898        let mut x = o.x;
899        let (a, b) = x.split_at_mut(self.n);
900        self.zero_ring(a);
901        self.zero_ring(b);
902        x
903    }
904
905    /// `out ← real(ifftn(M_ic.*y))`, `y` on the cone's voxels
906    fn cone_ifft(&self, ws: &mut Workspace, y: &[Complex64], out: &mut [f64]) {
907        let (n, cone) = (self.n, &self.cone);
908        par_chunked(&mut ws.c, |ch, s| {
909            let o = ch * CHUNK;
910            cone.walk(ch, n, |l, j| s[l - o] = j.map_or(Complex64::new(0.0, 0.0), |j| y[j]));
911        });
912        ws.fft.ifft3d(&mut ws.c);
913        let c = &ws.c;
914        par_fill(out, |i| c[i].re);
915    }
916
917    /// The fgrad component `c` (voxel size `vs`) of the masked fast QSM and smoothed R2′ at `p`,
918    /// combined into `|g+|` (`s` = 0) or `|g−|` (`s` = 1)
919    #[inline]
920    fn g_pm(&self, s: usize, c: usize, p: [usize; 3]) -> f64 {
921        let len = [self.dims.0, self.dims.1, self.dims.2];
922        let (gx, gr) = if p[c] + 1 < len[c] {
923            let mut q = p;
924            q[c] += 1;
925            ((self.xm.get(q) - self.xm.get(p)) / self.vs[c], (self.rm.get(q) - self.rm.get(p)) / self.vs[c])
926        } else {
927            (0.0, 0.0)
928        };
929        let (drp, drn) = (self.drp, self.drn);
930        if s == 0 {
931            ((gr + drn * gx) / (drp + drn)).abs()
932        } else {
933            ((gr - drp * gx) / (drp + drn)).abs()
934        }
935    }
936
937    /// `W_G²` of source `s`, axis `c` at voxel `p` (`l`): `conj(W).*W` with `W = W_G.*M`
938    #[inline]
939    fn wg2(&self, s: usize, c: usize, p: [usize; 3], l: usize) -> f64 {
940        if self.m[l] == 0 {
941            return 0.0;
942        }
943        let (lo, hi) = self.wg_thr[s][c];
944        let w = clamp01((hi - self.g_pm(s, c, p)) / (hi - lo));
945        w * w
946    }
947
948    /// `out ← M_ic.*fftn(gT(W_G².*fgrad(x, 1), 1))` on the cone's voxels, for source `s`
949    fn cone_fft_reg(&self, ws: &mut Workspace, s: usize, x: &[f64], out: &mut [Complex64]) {
950        let dims = self.dims;
951        let len = [dims.0, dims.1, dims.2];
952        let stride = [1, dims.0, dims.0 * dims.1];
953        // W_G².*fgrad(x, 1) along `c` at voxel p (index l)
954        let gw = |c: usize, p: [usize; 3], l: usize| -> f64 {
955            let g = if p[c] + 1 < len[c] { x[l + stride[c]] - x[l] } else { 0.0 };
956            g * self.wg2(s, c, p, l)
957        };
958        // gT: per axis −g(1), g(i−1) − g(i), g(n−1), summed over x, y, z
959        par_rows(&mut ws.c, dims, |j, k, l0, row| {
960            for (i, v) in row.iter_mut().enumerate() {
961                let (p, l) = ([i, j, k], l0 + i);
962                let mut acc = 0.0;
963                for c in 0..3 {
964                    let (pos, st) = (p[c], stride[c]);
965                    let mut q = p;
966                    q[c] = pos.wrapping_sub(1);
967                    let t = if pos == 0 {
968                        -gw(c, p, l)
969                    } else if pos == len[c] - 1 {
970                        gw(c, q, l - st)
971                    } else {
972                        gw(c, q, l - st) - gw(c, p, l)
973                    };
974                    acc = if c == 0 { t } else { acc + t };
975                }
976                *v = Complex64::new(acc, 0.0);
977            }
978        });
979        ws.fft.fft3d(&mut ws.c);
980        let (idx, c) = (&self.cone.idx, &ws.c);
981        par_fill(out, |j| c[idx[j] as usize]);
982    }
983
984    /// Step 2 on step 1's `[x+; x−]`, in place: each source minus its streaking component, masked.
985    fn step2(&self, ws: &mut Workspace, x: &mut [f64], params: &ChiSepIlsqrParams) {
986        let n = self.n;
987        let mut t = vec![0.0; n];
988        for (s, xs) in x.chunks_mut(n).enumerate() {
989            let mut b = vec![Complex64::new(0.0, 0.0); self.cone.len()];
990            self.cone_fft_reg(ws, s, xs, &mut b);
991            let y = pcg(
992                |v: &[Complex64], o: &mut [Complex64]| {
993                    self.cone_ifft(ws, v, &mut t);
994                    self.cone_fft_reg(ws, s, &t, o);
995                },
996                &b,
997                params.cg_tol_sa,
998                params.cg_max_iter_sa,
999                Start::Zero,
1000                n,
1001                Some(&self.cone),
1002            );
1003            drop(b);
1004            self.cone_ifft(ws, &y.x, &mut t);
1005            let m = &self.m;
1006            par_update(xs, |i, v| *v = (*v - t[i] * f64::from(m[i])) * f64::from(m[i]));
1007        }
1008    }
1009
1010    /// The fast QSM of step 2 (see the module docs), on the padded grid.
1011    fn fast_qsm(&self, ws: &mut Workspace, f: Vec<f64>, vs: [f64; 3]) -> Vec<f64> {
1012        let n = self.n;
1013        let dims = self.dims;
1014        let d = &self.d;
1015        let m = &self.m;
1016        // k-space blending weight from |D|^0.001
1017        let mut w = vec![0.0; n];
1018        par_rows(&mut w, dims, |j, k, _, row| {
1019            for (i, v) in row.iter_mut().enumerate() {
1020                *v = d.at(i, j, k).abs().powf(0.001);
1021            }
1022        });
1023        let (a, b) = {
1024            let mut t = w.clone();
1025            (prctile(&mut t, 1.0), prctile(&mut t, 30.0))
1026        };
1027        par_update(&mut w, |_, v| *v = clamp01((*v - a) / (b - a)));
1028        // TKD reference: fftn(f)./Dt, |Dt| ≥ 1/8 with sign(0) = 1
1029        let mut fk: Vec<Complex64> = f.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1030        drop(f);
1031        ws.fft.fft3d(&mut fk);
1032        {
1033            let fk = &fk;
1034            par_rows(&mut ws.c, dims, |j, k, l0, row| {
1035                for (i, v) in row.iter_mut().enumerate() {
1036                    let di = d.at(i, j, k);
1037                    let s = if di < 0.0 { -1.0 } else { 1.0 };
1038                    let dt = if di.abs() < TKD_THRESHOLD { TKD_THRESHOLD * s } else { di };
1039                    *v = fk[l0 + i] / dt;
1040                }
1041            });
1042        }
1043        ws.fft.ifft3d(&mut ws.c);
1044        let mut x_tkd = vec![0.0; n];
1045        {
1046            let c = &ws.c;
1047            par_fill(&mut x_tkd, |i| c[i].re * f64::from(m[i]));
1048        }
1049        // the ball's k-space smoothing: lpf(z) = ifftn(fftn(z).*fftn(fftshift(S)))
1050        let kk = {
1051            let mut ball = pv_ball(dims, BALL_RADIUS_MM, vs);
1052            circshift(&mut ball, dims, true);
1053            let mut kk: Vec<Complex64> = ball.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1054            drop(ball);
1055            ws.fft.fft3d(&mut kk);
1056            kk
1057        };
1058        // lpf(z) into `out`
1059        let lpf = |fft: &mut Fft3dWorkspace, z: &[Complex64], out: &mut [Complex64], kk: &[Complex64]| {
1060            out.copy_from_slice(z);
1061            fft.fft3d(out);
1062            par_update(out, |i, v| *v *= kk[i]);
1063            fft.ifft3d(out);
1064        };
1065        // k1c = ifftshift(fftn(f).*sign(D)); k2 = fftshift(W.*k1c + (1 − W).*lpf(k1c))
1066        par_rows(&mut fk, dims, |j, k, _, row| {
1067            for (i, v) in row.iter_mut().enumerate() {
1068                *v *= sign0(d.at(i, j, k));
1069            }
1070        });
1071        circshift(&mut fk, dims, false);
1072        let k1c = fk;
1073        lpf(&mut ws.fft, &k1c, &mut ws.c, &kk);
1074        par_update(&mut ws.c, |i, v| *v = k1c[i] * w[i] + *v * (1.0 - w[i]));
1075        let mut l2 = k1c;
1076        circshift(&mut ws.c, dims, true);
1077        ws.fft.ifft3d(&mut ws.c);
1078        // km = fftn(x2.*m); x3 = real(ifftn(W.*km + (1 − W).*lpf(km))).*m
1079        par_update(&mut ws.c, |i, v| *v = Complex64::new(v.re * f64::from(m[i]), 0.0));
1080        ws.fft.fft3d(&mut ws.c);
1081        lpf(&mut ws.fft, &ws.c, &mut l2, &kk);
1082        drop(kk);
1083        {
1084            let km = &ws.c;
1085            par_update(&mut l2, |i, v| *v = km[i] * w[i] + *v * (1.0 - w[i]));
1086        }
1087        drop(w);
1088        ws.fft.ifft3d(&mut l2);
1089        let mut x3 = vec![0.0; n];
1090        par_fill(&mut x3, |i| l2[i].re * f64::from(m[i]));
1091        drop(l2);
1092        // [ones x3] \ x_tkd over the whole grid
1093        let nf = n as f64;
1094        let mx = det_sum(n, |i| x3[i]) / nf;
1095        let my = det_sum(n, |i| x_tkd[i]) / nf;
1096        let sxy = det_sum(n, |i| (x3[i] - mx) * (x_tkd[i] - my));
1097        let sxx = det_sum(n, |i| (x3[i] - mx) * (x3[i] - mx));
1098        let c2 = sxy / sxx;
1099        let c1 = my - c2 * mx;
1100        par_update(&mut x3, |_, v| *v = c2 * *v + c1);
1101        x3
1102    }
1103
1104    /// R2′ (ppm) smoothed by a Tukey window in k-space:
1105    /// `real(ifftn(fftshift(ifftshift(fftn(r)).*w)))`
1106    fn tukey_lpf(&self, ws: &mut Workspace, r: &[f64]) -> Vec<f64> {
1107        let (nx, ny, nz) = self.dims;
1108        let (wx, wy, wz) = (tukeywin(nx, TUKEY_RATIO), tukeywin(ny, TUKEY_RATIO), tukeywin(nz, TUKEY_RATIO));
1109        par_fill(&mut ws.c, |i| Complex64::new(r[i], 0.0));
1110        ws.fft.fft3d(&mut ws.c);
1111        circshift(&mut ws.c, self.dims, false);
1112        par_rows(&mut ws.c, self.dims, |j, k, _, row| {
1113            for (i, v) in row.iter_mut().enumerate() {
1114                *v *= (wx[i] * wy[j]) * wz[k];
1115            }
1116        });
1117        circshift(&mut ws.c, self.dims, true);
1118        ws.fft.ifft3d(&mut ws.c);
1119        let c = &ws.c;
1120        let mut out = vec![0.0; nx * ny * nz];
1121        par_fill(&mut out, |i| c[i].re);
1122        out
1123    }
1124}
1125
1126/// `d * v`, named so the operation order of the toolbox's `d.*(w.*·)` stays visible
1127#[inline]
1128fn dp_mul(d: f64, v: f64) -> f64 {
1129    d * v
1130}
1131
1132/// MATLAB's `min(max(v, 0), 1)`, which maps NaN to 0 (`clamp` would keep it)
1133#[allow(clippy::manual_clamp)]
1134fn clamp01(v: f64) -> f64 {
1135    v.max(0.0).min(1.0)
1136}
1137
1138/// MATLAB's `sign` (0 at 0)
1139fn sign0(v: f64) -> f64 {
1140    if v > 0.0 {
1141        1.0
1142    } else if v < 0.0 {
1143        -1.0
1144    } else {
1145        0.0
1146    }
1147}
1148
1149/// `(lo, hi)`, the `prctile`s `p` of `v(l)` over the mask's voxels `l`, for the weights
1150/// `min(max((hi − v)/(hi − lo), 0), 1)`
1151fn prc_on(inside: &Support, v: impl Fn(usize) -> f64, p: (f64, f64)) -> (f64, f64) {
1152    let mut sel: Vec<f64> = inside.idx.iter().map(|&l| v(l as usize)).collect();
1153    let lo = prctile(&mut sel, p.0);
1154    let hi = prctile(&mut sel, p.1);
1155    (lo, hi)
1156}
1157
1158/// MATLAB's `prctile(x, p)` (R2023b, `'exact'`, no NaNs): with `r = (p/100)·n` and `k =
1159/// floor(r + 0.5)`, `(0.5 + r − k)·x(k+1) + (0.5 − r + k)·x(k)` of the sorted values, clamped to
1160/// the first and last and copied where the two neighbours are equal. `x` is reordered.
1161fn prctile(x: &mut [f64], p: f64) -> f64 {
1162    let n = x.len();
1163    let r = (p / 100.0) * n as f64;
1164    let k = (r + 0.5).floor();
1165    let rr = r - k;
1166    let k1 = (k as i64).max(1) as usize; // 1-based
1167    let kp1 = ((k as i64 + 1) as usize).min(n);
1168    let xk = *x.select_nth_unstable_by(k1 - 1, f64::total_cmp).1;
1169    let xkp1 = if kp1 == k1 { xk } else { x[k1..].iter().copied().fold(f64::INFINITY, f64::min) };
1170    if rr == -0.5 || xk == xkp1 {
1171        return xk;
1172    }
1173    (0.5 + rr) * xkp1 + (0.5 - rr) * xk
1174}
1175
1176/// MATLAB's `tukeywin(n, ratio)` for `0 < ratio < 1`
1177fn tukeywin(n: usize, ratio: f64) -> Vec<f64> {
1178    if n == 1 {
1179        return vec![1.0];
1180    }
1181    let n1 = (n - 1) as f64;
1182    // linspace(0, 1, n)
1183    let t: Vec<f64> = (0..n).map(|k| if k + 1 == n { 1.0 } else { k as f64 / n1 }).collect();
1184    let per = ratio / 2.0;
1185    let tl = (per * n1).floor() as usize + 1;
1186    let th = n - tl + 1;
1187    let pi_per = std::f64::consts::PI / per;
1188    (0..n)
1189        .map(|k| {
1190            if k < tl {
1191                (1.0 + (pi_per * (t[k] - per)).cos()) / 2.0
1192            } else if k + 1 >= th {
1193                (1.0 + (pi_per * (t[k] - 1.0 + per)).cos()) / 2.0
1194            } else {
1195                1.0
1196            }
1197        })
1198        .collect()
1199}
1200
1201/// `fftshift` (`forward`, shift by `floor(n/2)`) or `ifftshift` (shift by `ceil(n/2)`) per axis,
1202/// in place: a rotation of each x-row, of each z-plane by whole rows, and of the volume by whole
1203/// planes
1204fn circshift<T: Copy + Send>(a: &mut [T], (nx, ny, nz): (usize, usize, usize), forward: bool) {
1205    let s = |n: usize| if forward { n / 2 } else { n.div_ceil(2) };
1206    let (sx, sy, sz) = (s(nx), s(ny), s(nz));
1207    par_rows(a, (nx, ny, nz), |_, _, _, row| row.rotate_right(sx));
1208    #[cfg(feature = "parallel")]
1209    a.par_chunks_mut(nx * ny).for_each(|p| p.rotate_right(sy * nx));
1210    #[cfg(not(feature = "parallel"))]
1211    a.chunks_mut(nx * ny).for_each(|p| p.rotate_right(sy * nx));
1212    a.rotate_right(sz * nx * ny);
1213}
1214
1215/// MATLAB's `del2(f, hx, hy, hz)` with the spacings already in array-axis order (`h[0]` along
1216/// the first axis): per axis, half the centred second difference inside and linear
1217/// extrapolation at the ends, summed over the axes and divided by 3. Coordinates are `h·(1:n)`
1218/// and their differences are taken as MATLAB takes them.
1219fn del2(f: &[f64], (nx, ny, nz): (usize, usize, usize), h: [f64; 3]) -> Vec<f64> {
1220    let n = [nx, ny, nz];
1221    let stride = [1, nx, nx * ny];
1222    let mut v = vec![0.0; f.len()];
1223    for ax in 0..3 {
1224        let len = n[ax];
1225        let hs: Vec<f64> = (0..len - 1).map(|i| h[ax] * (i + 2) as f64 - h[ax] * (i + 1) as f64).collect();
1226        let st = stride[ax];
1227        let g_at = |p0: usize, t: usize| -> f64 {
1228            // interior second difference at line position t (1 ≤ t ≤ len − 2)
1229            let p = p0 + t * st;
1230            ((f[p + st] - f[p]) / hs[t] - (f[p] - f[p - st]) / hs[t - 1]) / (hs[t] + hs[t - 1])
1231        };
1232        par_update(&mut v, |l, out| {
1233            let pos = (l / st) % len;
1234            let p0 = l - pos * st;
1235            let g = if len > 3 {
1236                if pos == 0 {
1237                    g_at(p0, 1) * (hs[0] + hs[1]) / hs[1] - g_at(p0, 2) * hs[0] / hs[1]
1238                } else if pos == len - 1 {
1239                    -g_at(p0, len - 3) * hs[len - 2] / hs[len - 3] + g_at(p0, len - 2) * (hs[len - 2] + hs[len - 3]) / hs[len - 3]
1240                } else {
1241                    g_at(p0, pos)
1242                }
1243            } else if len == 3 {
1244                g_at(p0, 1)
1245            } else {
1246                0.0
1247            };
1248            *out += g;
1249        });
1250    }
1251    par_update(&mut v, |_, x| *x /= 3.0);
1252    v
1253}
1254
1255/// The exact adjoint of MATLAB-style forward differences with unit spacing (0 at the last
1256/// voxel): per axis `−g(1)`, `g(i−1) − g(i)`, `g(n−1)`, summed over x, y, z. The reference for
1257/// the fused version in `Problem::cone_fft_reg`.
1258#[cfg(test)]
1259fn grad_adjoint_unit(g: &[Vec<f64>; 3], (nx, ny, nz): (usize, usize, usize), out: &mut [f64]) {
1260    let n = [nx, ny, nz];
1261    let stride = [1, nx, nx * ny];
1262    par_fill(out, |l| {
1263        let mut acc = 0.0;
1264        for ax in 0..3 {
1265            let (len, st) = (n[ax], stride[ax]);
1266            let pos = (l / st) % len;
1267            let gg = &g[ax];
1268            let t = if pos == 0 {
1269                -gg[l]
1270            } else if pos == len - 1 {
1271                gg[l - st]
1272            } else {
1273                gg[l - st] - gg[l]
1274            };
1275            acc = if ax == 0 { t } else { acc + t };
1276        }
1277        acc
1278    });
1279}
1280
1281/// The fast QSM's smoothing ball: radius `r` mm on the grid `(−n/2 : n/2−1)·vs` per axis (half
1282/// integers on an odd axis, as the dipole kernel), 1 where the voxel centre is within `r − h`
1283/// (`h` the half diagonal), and partial-volume weighted by 20³ sub-samples at offsets
1284/// `(−0.475:0.05:0.475)·vs` where it is within `r + h`; normalised to sum 1. Centred.
1285fn pv_ball((nx, ny, nz): (usize, usize, usize), r: f64, vs: [f64; 3]) -> Vec<f64> {
1286    let coord = |n: usize, v: f64| -> Vec<f64> { (0..n).map(|i| (-(n as f64) / 2.0 + i as f64) * v).collect() };
1287    let (cx, cy, cz) = (coord(nx, vs[0]), coord(ny, vs[1]), coord(nz, vs[2]));
1288    let h = ((vs[0] / 2.0).powi(2) + (vs[1] / 2.0).powi(2) + (vs[2] / 2.0).powi(2)).sqrt();
1289    // -0.475:0.05:0.475 as MATLAB's colon builds it (from both ends)
1290    let sub: Vec<f64> = (0..20).map(|k| if k < 10 { -0.475 + k as f64 * 0.05 } else { 0.475 - (19 - k) as f64 * 0.05 }).collect();
1291    let mut s = vec![0.0; nx * ny * nz];
1292    let r2 = r * r;
1293    for k in 0..nz {
1294        for j in 0..ny {
1295            for i in 0..nx {
1296                let (x, y, z) = (cx[i], cy[j], cz[k]);
1297                let d = (x * x + y * y + z * z).sqrt();
1298                let l = i + nx * (j + ny * k);
1299                if d <= r - h {
1300                    s[l] = 1.0;
1301                } else if d < r + h {
1302                    let mut cnt = 0usize;
1303                    for &c in &sub {
1304                        let zz = z + c * vs[2];
1305                        for &b in &sub {
1306                            let yy = y + b * vs[1];
1307                            for &a in &sub {
1308                                let xx = x + a * vs[0];
1309                                if xx * xx + yy * yy + zz * zz <= r2 {
1310                                    cnt += 1;
1311                                }
1312                            }
1313                        }
1314                    }
1315                    s[l] = cnt as f64 / 8000.0;
1316                }
1317            }
1318        }
1319    }
1320    let tot: f64 = s.iter().sum();
1321    s.iter_mut().for_each(|v| *v /= tot);
1322    s
1323}
1324
1325/// A sum over `0..n` in fixed chunks, so the result does not depend on the thread count
1326fn det_sum<T>(n: usize, f: impl Fn(usize) -> T + Sync) -> T
1327where
1328    T: Copy + Send + Sync + std::ops::Add<Output = T> + Default,
1329{
1330    chunked_sum(n.div_ceil(CHUNK), |c| c * CHUNK..((c + 1) * CHUNK).min(n), f)
1331}
1332
1333/// `f` summed over each of `chunks` ranges in order, then the partial sums in order
1334fn chunked_sum<T>(chunks: usize, range: impl Fn(usize) -> std::ops::Range<usize> + Sync, f: impl Fn(usize) -> T + Sync) -> T
1335where
1336    T: Copy + Send + Sync + std::ops::Add<Output = T> + Default,
1337{
1338    let part = |c: usize| {
1339        let mut acc = T::default();
1340        for i in range(c) {
1341            acc = acc + f(i);
1342        }
1343        acc
1344    };
1345    #[cfg(feature = "parallel")]
1346    let parts: Vec<T> = (0..chunks).into_par_iter().map(part).collect();
1347    #[cfg(not(feature = "parallel"))]
1348    let parts: Vec<T> = (0..chunks).map(part).collect();
1349    parts.into_iter().fold(T::default(), |a, b| a + b)
1350}
1351
1352/// An element of a `pcg` vector: real (step 1) or complex (step 2).
1353trait PcgElem:
1354    Copy + Send + Sync + Default + std::ops::Add<Output = Self> + std::ops::Sub<Output = Self> + std::ops::Mul<Output = Self> + std::ops::Div<Output = Self>
1355{
1356    fn one() -> Self;
1357    /// `conj(self)·o`
1358    fn cmul(self, o: Self) -> Self;
1359    fn abs2(self) -> f64;
1360    fn abs(self) -> f64;
1361    fn re(self) -> f64;
1362    fn is_inf(self) -> bool;
1363    fn is_zero(self) -> bool;
1364}
1365
1366impl PcgElem for f64 {
1367    fn one() -> Self {
1368        1.0
1369    }
1370    fn cmul(self, o: Self) -> Self {
1371        self * o
1372    }
1373    fn abs2(self) -> f64 {
1374        self * self
1375    }
1376    fn abs(self) -> f64 {
1377        f64::abs(self)
1378    }
1379    fn re(self) -> f64 {
1380        self
1381    }
1382    fn is_inf(self) -> bool {
1383        self.is_infinite()
1384    }
1385    fn is_zero(self) -> bool {
1386        self == 0.0
1387    }
1388}
1389
1390impl PcgElem for Complex64 {
1391    fn one() -> Self {
1392        Complex64::new(1.0, 0.0)
1393    }
1394    fn cmul(self, o: Self) -> Self {
1395        self.conj() * o
1396    }
1397    fn abs2(self) -> f64 {
1398        self.norm_sqr()
1399    }
1400    fn abs(self) -> f64 {
1401        self.norm()
1402    }
1403    fn re(self) -> f64 {
1404        self.re
1405    }
1406    fn is_inf(self) -> bool {
1407        self.re.is_infinite() || self.im.is_infinite()
1408    }
1409    fn is_zero(self) -> bool {
1410        self.re == 0.0 && self.im == 0.0
1411    }
1412}
1413
1414/// What [`pcg`] returns: the solution, MATLAB's `flag` and `iter`.
1415struct PcgOut<T> {
1416    x: Vec<T>,
1417    #[allow(dead_code)]
1418    flag: u8,
1419    #[allow(dead_code)]
1420    iter: usize,
1421}
1422
1423/// Where [`pcg`] starts.
1424enum Start<'a, T> {
1425    Zero,
1426    Owned(Vec<T>),
1427    /// A start that writes itself into a buffer (every element), so `pcg` keeps no copy of it.
1428    Fill(&'a (dyn Fn(&mut [T]) + 'a)),
1429}
1430
1431/// Where [`pcg`]'s best iterate so far (MATLAB's `xmin`) is: the start (rewritten by
1432/// [`Start::Fill`] when needed), the current iterate, or its own copy.
1433#[derive(Clone, Copy, PartialEq)]
1434enum XMin {
1435    Start,
1436    X,
1437    Saved,
1438}
1439
1440/// `det_sum` of `f` over a vector of `n`, or over its voxels in `on` (the rest zero)
1441fn psum<T>(on: Option<&Support>, n: usize, f: impl Fn(usize) -> T + Sync) -> T
1442where
1443    T: Copy + Send + Sync + std::ops::Add<Output = T> + Default,
1444{
1445    match on {
1446        Some(s) => s.sum(f),
1447        None => det_sum(n, f),
1448    }
1449}
1450
1451/// MATLAB R2023b's `pcg(A, b, tol, maxit, [], [], x0)` (no preconditioner), line for line.
1452///
1453/// The vectors have `n_logical` elements; with `on`, only those in that set are stored (the
1454/// others are zero in every vector of the solve: `A` maps such vectors to such vectors and `b`
1455/// is one), and the norms and inner products add the same terms as over the whole vector.
1456/// MATLAB's `xmin` is copied only when it stops being the current iterate (`XMin`), so the
1457/// result is the same with one vector less while they coincide; and a returned `iter` of an
1458/// `xmin` that is the last iterate is `imin` (the two candidates are then the same vector).
1459fn pcg<T: PcgElem>(
1460    mut a: impl FnMut(&[T], &mut [T]),
1461    b: &[T],
1462    tol: f64,
1463    maxit: usize,
1464    start: Start<T>,
1465    n_logical: usize,
1466    on: Option<&Support>,
1467) -> PcgOut<T> {
1468    let n = b.len();
1469    let norm = |v: &[T]| psum(on, n, |i| v[i].abs2()).sqrt();
1470    let dot = |u: &[T], v: &[T]| psum(on, n, |i| u[i].cmul(v[i]));
1471    let n2b = norm(b);
1472    if n2b == 0.0 {
1473        return PcgOut { x: vec![T::default(); n], flag: 0, iter: 0 };
1474    }
1475    let (mut x, mut xmin, fill) = match start {
1476        Start::Zero => (vec![T::default(); n], XMin::X, None),
1477        Start::Owned(v) => (v, XMin::X, None),
1478        Start::Fill(f) => {
1479            let mut x = vec![T::default(); n];
1480            f(&mut x);
1481            (x, XMin::Start, Some(f))
1482        }
1483    };
1484    let mut saved: Vec<T> = Vec::new();
1485    let mut flag = 1u8;
1486    let mut imin = 0;
1487    let tolb = tol * n2b;
1488    let mut q = vec![T::default(); n];
1489    a(&x, &mut q);
1490    let mut r: Vec<T> = (0..n).map(|i| b[i] - q[i]).collect();
1491    let mut normr_act = norm(&r);
1492    if normr_act <= tolb {
1493        return PcgOut { x, flag: 0, iter: 0 };
1494    }
1495    let mut normrmin = normr_act;
1496    let mut rho = T::one();
1497    let mut stag = 0;
1498    let mut moresteps = 0;
1499    let maxmsteps = ((n_logical / 50) as i64).min(5).min(n_logical as i64 - maxit as i64);
1500    let maxstagsteps = 3;
1501    let mut p = vec![T::default(); n];
1502    let mut ii = 0;
1503    let mut iter = 0;
1504    for it in 1..=maxit {
1505        ii = it;
1506        let rho1 = rho;
1507        rho = dot(&r, &r);
1508        if rho.is_zero() || rho.is_inf() {
1509            flag = 4;
1510            break;
1511        }
1512        if it == 1 {
1513            p.copy_from_slice(&r);
1514        } else {
1515            let beta = rho / rho1;
1516            if beta.is_zero() || beta.is_inf() {
1517                flag = 4;
1518                break;
1519            }
1520            par_update(&mut p, |i, v| *v = r[i] + beta * *v);
1521        }
1522        a(&p, &mut q);
1523        let pq = dot(&p, &q);
1524        if pq.re() <= 0.0 || pq.is_inf() {
1525            flag = 4;
1526            break;
1527        }
1528        let alpha = rho / pq;
1529        if alpha.is_inf() {
1530            flag = 4;
1531            break;
1532        }
1533        if norm(&p) * alpha.abs() < f64::EPSILON * norm(&x) {
1534            stag += 1;
1535        } else {
1536            stag = 0;
1537        }
1538        if xmin == XMin::X {
1539            if saved.is_empty() {
1540                saved = x.clone();
1541            } else {
1542                saved.copy_from_slice(&x);
1543            }
1544            xmin = XMin::Saved;
1545        }
1546        par_update(&mut x, |i, v| *v = *v + alpha * p[i]);
1547        par_update(&mut r, |i, v| *v = *v - alpha * q[i]);
1548        let normr = norm(&r);
1549        normr_act = normr;
1550        if normr <= tolb || stag >= maxstagsteps || moresteps > 0 {
1551            a(&x, &mut q);
1552            par_fill(&mut r, |i| b[i] - q[i]);
1553            normr_act = norm(&r);
1554            if normr_act <= tolb {
1555                flag = 0;
1556                iter = it;
1557                break;
1558            }
1559            if stag >= maxstagsteps && moresteps == 0 {
1560                stag = 0;
1561            }
1562            moresteps += 1;
1563            if moresteps >= maxmsteps {
1564                flag = 3;
1565                iter = it;
1566                break;
1567            }
1568        }
1569        if normr_act < normrmin {
1570            normrmin = normr_act;
1571            xmin = XMin::X;
1572            imin = it;
1573        }
1574        if stag >= maxstagsteps {
1575            flag = 3;
1576            break;
1577        }
1578    }
1579    if flag != 0 {
1580        if xmin == XMin::X {
1581            iter = imin;
1582        } else {
1583            // r_comp = norm(b − A·xmin); `p` is free now and holds a rebuilt start
1584            if xmin == XMin::Start {
1585                (fill.expect("a start to rebuild"))(&mut p);
1586            }
1587            let xm: &[T] = if xmin == XMin::Saved { &saved } else { &p };
1588            a(xm, &mut q);
1589            let r_comp = psum(on, n, |i| (b[i] - q[i]).abs2()).sqrt();
1590            if r_comp <= normr_act {
1591                x = if xmin == XMin::Saved { saved } else { p };
1592                iter = imin;
1593            } else {
1594                iter = ii;
1595            }
1596        }
1597    }
1598    PcgOut { x, flag, iter }
1599}
1600
1601#[cfg(test)]
1602mod tests {
1603    use super::*;
1604
1605    #[test]
1606    fn defaults_are_the_toolbox_values() {
1607        let p = ChiSepIlsqrParams::default();
1608        assert_eq!((p.dr_pos, p.dr_neg, p.pad), (None, None, [16; 3]));
1609        assert_eq!((p.cg_max_iter_lsqr, p.cg_tol_lsqr, p.cg_max_iter_sa, p.cg_tol_sa), (50, 0.02, 50, 0.02));
1610        assert_eq!((p.d2_thr, p.prc_w_i, p.prc_w_g), (0.11, (60.0, 99.8), (50.0, 70.0)));
1611        assert_eq!((NNLS_TOL, NNLS_ITER_MAX), (0.01, 10));
1612        let (dp, dn) = ChiSepIlsqrParams { cf: 127.74e6, ..p.clone() }.dr();
1613        assert_eq!((dp, dn), (141.7241379310345, 141.7241379310345));
1614        assert_eq!(p.qsm_ilsqr.max_iter, STI_DEFAULT_NITER);
1615    }
1616
1617    #[test]
1618    fn param_errors_name_the_parameter() {
1619        let bad = [
1620            ChiSepIlsqrParams { cf: 0.0, ..Default::default() },
1621            ChiSepIlsqrParams { dr_pos: Some(-1.0), ..Default::default() },
1622            ChiSepIlsqrParams { dr_neg: Some(f64::NAN), ..Default::default() },
1623            ChiSepIlsqrParams { d2_thr: 0.0, ..Default::default() },
1624            ChiSepIlsqrParams { cg_tol_lsqr: 1.0, ..Default::default() },
1625            ChiSepIlsqrParams { cg_tol_sa: 0.0, ..Default::default() },
1626            ChiSepIlsqrParams { prc_w_i: (99.8, 60.0), ..Default::default() },
1627            ChiSepIlsqrParams { prc_w_g: (50.0, 101.0), ..Default::default() },
1628        ];
1629        let names = ["cf", "dr_pos", "dr_neg", "d2_thr", "cg_tol_lsqr", "cg_tol_sa", "prc_w_i", "prc_w_g"];
1630        for (p, name) in bad.iter().zip(names) {
1631            let e = p.validate().unwrap_err().to_string();
1632            assert!(e.starts_with(&format!("χ-separation iLSQR {name} = ")), "{e}");
1633        }
1634        assert!(ChiSepIlsqrParams::default().validate().is_ok());
1635    }
1636
1637    #[test]
1638    fn wrong_buffer_length_and_empty_mask_are_input_errors() {
1639        let nt = 8 * 8 * 6;
1640        let v = vec![0.0; nt];
1641        let short = vec![0.0; nt - 1];
1642        let m = vec![1u8; nt];
1643        let grid = Grid::new(8, 8, 6, 1.0, 1.0, 1.0);
1644        let inputs = ChiSepIlsqrInputs { local_field: &v, r2prime: &short, qsm: &v, mask: &m };
1645        let e = chi_sep_ilsqr(&inputs, &grid, (0.0, 0.0, 1.0), &ChiSepIlsqrParams::default(), |_, _| {}).unwrap_err();
1646        assert_eq!(e.to_string(), format!("χ-separation iLSQR r2prime has {} values, expected {nt}", nt - 1));
1647        let z = vec![0u8; nt];
1648        let inputs = ChiSepIlsqrInputs { local_field: &v, r2prime: &v, qsm: &v, mask: &z };
1649        assert!(chi_sep_ilsqr(&inputs, &grid, (0.0, 0.0, 1.0), &ChiSepIlsqrParams::default(), |_, _| {}).is_err());
1650    }
1651
1652    /// Values from MATLAB R2023b.
1653    #[test]
1654    fn matlab_helpers() {
1655        let mut x: Vec<f64> = vec![5.0, 1.0, 4.0, 2.0, 3.0];
1656        // prctile(1:5, [1 30 60 99.8]) = 1, 2, 3.5, 5
1657        assert_eq!(prctile(&mut x.clone(), 1.0), 1.0);
1658        assert_eq!(prctile(&mut x.clone(), 30.0), 2.0);
1659        assert_eq!(prctile(&mut x.clone(), 60.0), 3.5);
1660        assert_eq!(prctile(&mut x, 99.8), 5.0);
1661        // tukeywin(9, 0.5)' = [0 0.5 1 1 1 1 1 0.5 0]
1662        let w = tukeywin(9, 0.5);
1663        let want = [0.0, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.0];
1664        assert!(w.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-15), "{w:?}");
1665        // del2 of x.^2 along the first axis is 2/6 everywhere (exact quadratic, extrapolated ends)
1666        let dims = (6, 4, 5);
1667        let f: Vec<f64> = (0..120).map(|l| ((l % 6) as f64).powi(2)).collect();
1668        assert!(del2(&f, dims, [1.0; 3]).iter().all(|v| (v - 2.0 / 6.0).abs() < 1e-13));
1669        // ifftshift then fftshift is the identity on odd and even axes
1670        let a: Vec<usize> = (0..7 * 4 * 3).collect();
1671        let mut b = a.clone();
1672        circshift(&mut b, (7, 4, 3), false);
1673        circshift(&mut b, (7, 4, 3), true);
1674        assert_eq!(b, a);
1675        let mut b = vec![0, 1, 2, 3, 4];
1676        circshift(&mut b, (5, 1, 1), true);
1677        assert_eq!(b, vec![3, 4, 0, 1, 2]);
1678        // the in-place shift moves each element where the out-of-place one does
1679        for dims in [(7, 4, 3), (6, 5, 4), (1, 3, 2), (5, 1, 6)] {
1680            for forward in [true, false] {
1681                let a: Vec<usize> = (0..dims.0 * dims.1 * dims.2).collect();
1682                let mut b = a.clone();
1683                circshift(&mut b, dims, forward);
1684                assert_eq!(b, circshift_ref(&a, dims, forward), "{dims:?} {forward}");
1685            }
1686        }
1687    }
1688
1689    /// MATLAB's `pcg` returns its start when one iteration does not lower the residual, and the
1690    /// exact solution of a small SPD system otherwise.
1691    #[test]
1692    fn pcg_follows_matlab() {
1693        let m = [[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0]];
1694        let apply = |x: &[f64], o: &mut [f64]| {
1695            for i in 0..3 {
1696                o[i] = (0..3).map(|j| m[i][j] * x[j]).sum();
1697            }
1698        };
1699        let b = [1.0, 2.0, 3.0];
1700        let out = pcg(apply, &b, 1e-12, 10, Start::Zero, 3, None);
1701        let mut ax = [0.0; 3];
1702        apply(&out.x, &mut ax);
1703        assert!(ax.iter().zip(&b).all(|(a, b)| (a - b).abs() < 1e-10), "{:?}", out.x);
1704        assert_eq!(out.flag, 0);
1705        // complex unknown with an R-linear operator: Re-part projection keeps CG well defined
1706        let bc = [Complex64::new(1.0, 0.5), Complex64::new(-1.0, 0.0)];
1707        let out = pcg(|x: &[Complex64], o: &mut [Complex64]| { o[0] = x[0] * 2.0; o[1] = x[1] * 3.0; }, &bc, 1e-12, 5, Start::Zero, 2, None);
1708        assert!((out.x[0] - Complex64::new(0.5, 0.25)).norm() < 1e-14 && (out.x[1] - Complex64::new(-1.0 / 3.0, 0.0)).norm() < 1e-14);
1709    }
1710
1711    /// The out-of-place `fftshift`/`ifftshift` that [`circshift`] replaced
1712    fn circshift_ref<T: Copy>(a: &[T], (nx, ny, nz): (usize, usize, usize), forward: bool) -> Vec<T> {
1713        let s = |n: usize| if forward { n / 2 } else { n.div_ceil(2) };
1714        let (sx, sy, sz) = (s(nx), s(ny), s(nz));
1715        let mut out = a.to_vec();
1716        for k in 0..nz {
1717            for j in 0..ny {
1718                for i in 0..nx {
1719                    out[(i + sx) % nx + nx * ((j + sy) % ny + ny * ((k + sz) % nz))] = a[i + nx * (j + ny * k)];
1720                }
1721            }
1722        }
1723        out
1724    }
1725
1726    #[test]
1727    fn kernel_is_the_toolbox_kernel_bit_for_bit() {
1728        let h = (0.1f64, -0.4, 0.9);
1729        let norm = (h.0 * h.0 + h.1 * h.1 + h.2 * h.2).sqrt();
1730        for (dims, vs, b0) in [((8, 6, 4), [1.0; 3], (0.0, 0.0, 1.0)), ((7, 5, 9), [0.8, 0.9, 1.5], (h.0 / norm, h.1 / norm, h.2 / norm))] {
1731            let want = super::super::chi_sep_medi::toolbox_dipole_kernel(dims, vs, b0);
1732            let kernel = Kernel::new(dims, vs, b0);
1733            for l in 0..want.len() {
1734                let (i, j, k) = (l % dims.0, (l / dims.0) % dims.1, l / (dims.0 * dims.1));
1735                assert_eq!(kernel.at(i, j, k).to_bits(), want[l].to_bits(), "{dims:?} at {l}");
1736            }
1737        }
1738    }
1739
1740    /// A vector on a support (zero elsewhere): sums over it equal the full vector's bit for bit,
1741    /// including a chunk with no voxels of the support.
1742    #[test]
1743    fn sums_on_a_support_are_the_full_sums() {
1744        let n = 3 * CHUNK + 1234;
1745        let keep = |l: usize| (l / CHUNK != 1) && (l.is_multiple_of(7) || l % 11 == 3);
1746        let sup = Support::new(n, keep);
1747        let full: Vec<f64> = (0..n).map(|l| if keep(l) { ((l as f64) * 0.618).sin() * 1e3 } else { 0.0 }).collect();
1748        let on: Vec<f64> = sup.idx.iter().map(|&l| full[l as usize]).collect();
1749        assert_eq!(sup.bounds[1], sup.bounds[2]);
1750        assert_eq!(det_sum(n, |i| full[i] * full[i]).to_bits(), sup.sum(|j| on[j] * on[j]).to_bits());
1751        assert_eq!(det_sum(n, |i| full[i]).to_bits(), sup.sum(|j| on[j]).to_bits());
1752        let mut seen = 0;
1753        for c in 0..n.div_ceil(CHUNK) {
1754            sup.walk(c, n, |l, j| {
1755                assert_eq!(j.is_some(), keep(l));
1756                if let Some(j) = j {
1757                    assert_eq!(sup.idx[j] as usize, l);
1758                }
1759                seen += 1;
1760            });
1761        }
1762        assert_eq!(seen, n);
1763    }
1764
1765    /// `pcg` on a support and from a rebuilt start returns what it returns on the whole vector
1766    /// from a stored start, bit for bit.
1767    #[test]
1768    fn pcg_support_and_start_do_not_change_the_result() {
1769        let n = 2 * CHUNK + 77;
1770        let keep = |l: usize| l % 5 != 1;
1771        let sup = Support::new(n, keep);
1772        let ns = sup.len();
1773        // an SPD operator coupling the kept voxels in order: 3 on the diagonal, 0.5 to the neighbours
1774        let op_on = |x: &[f64], o: &mut [f64]| {
1775            for j in 0..x.len() {
1776                let prev = if j > 0 { x[j - 1] } else { 0.0 };
1777                let next = if j + 1 < x.len() { x[j + 1] } else { 0.0 };
1778                o[j] = 3.0 * x[j] + 0.5 * prev + 0.5 * next;
1779            }
1780        };
1781        let gather = |x: &[f64]| -> Vec<f64> { sup.idx.iter().map(|&l| x[l as usize]).collect() };
1782        let scatter = |v: &[f64], x: &mut [f64]| {
1783            x.fill(0.0);
1784            sup.idx.iter().zip(v).for_each(|(&l, &v)| x[l as usize] = v);
1785        };
1786        let op_full = |x: &[f64], o: &mut [f64]| {
1787            let mut t = vec![0.0; ns];
1788            op_on(&gather(x), &mut t);
1789            scatter(&t, o);
1790        };
1791        let b_on: Vec<f64> = (0..ns).map(|j| ((j as f64) * 0.37).cos()).collect();
1792        let mut b = vec![0.0; n];
1793        scatter(&b_on, &mut b);
1794        let x0_on: Vec<f64> = (0..ns).map(|j| 0.1 * ((j as f64) * 1.3).sin()).collect();
1795        let mut x0 = vec![0.0; n];
1796        scatter(&x0_on, &mut x0);
1797        for maxit in [1, 3, 30] {
1798            let full = pcg(op_full, &b, 1e-10, maxit, Start::Owned(x0.clone()), n, None).x;
1799            let fill = |x: &mut [f64]| scatter(&x0_on, x);
1800            let rebuilt = pcg(op_full, &b, 1e-10, maxit, Start::Fill(&fill), n, None).x;
1801            let on = pcg(op_on, &b_on, 1e-10, maxit, Start::Owned(x0_on.clone()), n, Some(&sup)).x;
1802            assert!(full.iter().zip(&rebuilt).all(|(a, b)| a.to_bits() == b.to_bits()), "maxit {maxit}");
1803            assert!(gather(&full).iter().zip(&on).all(|(a, b)| a.to_bits() == b.to_bits()), "maxit {maxit}");
1804        }
1805    }
1806
1807    /// A small phantom: an ellipsoid with a paramagnetic and a diamagnetic half.
1808    fn small_phantom() -> (Grid, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>) {
1809        let (nx, ny, nz) = (20, 18, 14);
1810        let nt = nx * ny * nz;
1811        let mut mask = vec![0u8; nt];
1812        let mut chi = vec![0.0; nt];
1813        let mut r2p = vec![0.0; nt];
1814        for k in 0..nz {
1815            for j in 0..ny {
1816                for i in 0..nx {
1817                    let l = i + nx * (j + ny * k);
1818                    let (x, y, z) = (i as f64 - 10.0, j as f64 - 9.0, k as f64 - 7.0);
1819                    if (x / 8.0).powi(2) + (y / 7.0).powi(2) + (z / 5.5).powi(2) <= 1.0 {
1820                        mask[l] = 1;
1821                        let (p, q) = (if x > 1.0 { 0.05 } else { 0.0 }, if x < -1.0 { 0.03 } else { 0.0 });
1822                        chi[l] = p - q;
1823                        r2p[l] = 141.0 * (p + q) + 2.0;
1824                    }
1825                }
1826            }
1827        }
1828        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1829        let d = crate::kernels::dipole::dipole_kernel(&grid, (0.0, 0.0, 1.0));
1830        let mut buf: Vec<Complex64> = chi.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1831        crate::fft::fft3d(&mut buf, nx, ny, nz);
1832        buf.iter_mut().zip(&d).for_each(|(b, &dv)| *b *= dv);
1833        crate::fft::ifft3d(&mut buf, nx, ny, nz);
1834        let field: Vec<f64> = buf.iter().zip(&mask).map(|(b, &m)| b.re * m as f64).collect();
1835        (grid, field, r2p, chi, mask)
1836    }
1837
1838    /// Step 2's gradient weights, recomputed where used, and its regulariser, with the gradient
1839    /// fused into the adjoint, equal the full-volume computation bit for bit.
1840    #[test]
1841    fn fused_regulariser_is_the_full_volume_one() {
1842        use crate::inversion::medi::fgrad;
1843        let (grid, field, r2p, chi, mask) = small_phantom();
1844        let inputs = ChiSepIlsqrInputs { local_field: &field, r2prime: &r2p, qsm: &chi, mask: &mask };
1845        let params = ChiSepIlsqrParams { pad: [4; 3], ..Default::default() };
1846        let (pb, mut ws, _) = Problem::new(&inputs, &grid, (0.0, 0.0, 1.0), &params);
1847        let (dims, n) = (pb.dims, pb.n);
1848        let at = |l: usize| [l % dims.0, (l / dims.0) % dims.1, l / (dims.0 * dims.1)];
1849        let full = |b: &BoxVol| (0..n).map(|l| b.get(at(l))).collect::<Vec<f64>>();
1850        let (xm, rm) = (full(&pb.xm), full(&pb.rm));
1851        // the weights as computed before: fgrad of the volumes, ramp over the mask, (W.*M).^2
1852        let mut gx = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
1853        let mut gr = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
1854        fgrad(&xm, dims, pb.vs, &mut gx);
1855        fgrad(&rm, dims, pb.vs, &mut gr);
1856        let (drp, drn) = (pb.drp, pb.drn);
1857        let inside: Vec<usize> = (0..n).filter(|&l| pb.m[l] != 0).collect();
1858        let mut wg2 = [[vec![], vec![], vec![]], [vec![], vec![], vec![]]];
1859        for c in 0..3 {
1860            let gp: Vec<f64> = (0..n).map(|i| ((gr[c][i] + drn * gx[c][i]) / (drp + drn)).abs()).collect();
1861            let gn: Vec<f64> = (0..n).map(|i| ((gr[c][i] - drp * gx[c][i]) / (drp + drn)).abs()).collect();
1862            for (s, g) in [gp, gn].into_iter().enumerate() {
1863                let mut sel: Vec<f64> = inside.iter().map(|&i| g[i]).collect();
1864                let (lo, hi) = (prctile(&mut sel, params.prc_w_g.0), prctile(&mut sel, params.prc_w_g.1));
1865                wg2[s][c] = (0..n).map(|i| clamp01((hi - g[i]) / (hi - lo)) * f64::from(pb.m[i])).map(|v| v * v).collect();
1866                for l in 0..n {
1867                    assert_eq!(pb.wg2(s, c, at(l), l).to_bits(), wg2[s][c][l].to_bits(), "W_G² {s} {c} at {l}");
1868                }
1869            }
1870        }
1871        let x: Vec<f64> = (0..n).map(|l| ((l as f64) * 0.71).sin() * 0.01).collect();
1872        for s in 0..2 {
1873            let mut g = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
1874            fgrad(&x, dims, [1.0; 3], &mut g);
1875            for c in 0..3 {
1876                g[c].iter_mut().zip(&wg2[s][c]).for_each(|(v, w)| *v *= w);
1877            }
1878            let mut t = vec![0.0; n];
1879            grad_adjoint_unit(&g, dims, &mut t);
1880            let mut want: Vec<Complex64> = t.iter().map(|&v| Complex64::new(v, 0.0)).collect();
1881            ws.fft.fft3d(&mut want);
1882            let mut got = vec![Complex64::new(0.0, 0.0); pb.cone.len()];
1883            pb.cone_fft_reg(&mut ws, s, &x, &mut got);
1884            for (j, &l) in pb.cone.idx.iter().enumerate() {
1885                let w = want[l as usize];
1886                assert_eq!((got[j].re.to_bits(), got[j].im.to_bits()), (w.re.to_bits(), w.im.to_bits()), "source {s} at {l}");
1887            }
1888        }
1889    }
1890
1891    #[test]
1892    fn sources_have_their_signs() {
1893        let (grid, field, r2p, chi, mask) = small_phantom();
1894        let nt = mask.len();
1895        let inputs = ChiSepIlsqrInputs { local_field: &field, r2prime: &r2p, qsm: &chi, mask: &mask };
1896        let mut iters = 0;
1897        let (p, q, t) = chi_sep_ilsqr(&inputs, &grid, (0.0, 0.0, 1.0), &ChiSepIlsqrParams { pad: [4; 3], ..Default::default() }, |i, _| iters = i).unwrap();
1898        assert!((1..=NNLS_ITER_MAX + 1).contains(&iters));
1899        for l in 0..nt {
1900            assert!(p[l] >= 0.0 && q[l] <= 0.0 && t[l] == p[l] + q[l]);
1901            if mask[l] == 0 {
1902                assert!(p[l] == 0.0 && q[l] == 0.0);
1903            }
1904        }
1905        assert!(p.iter().any(|&v| v > 0.01) && q.iter().any(|&v| v < -0.01));
1906    }
1907}