Skip to main content

qsm_core/separation/
chi_sep_medi.rs

1//! χ-separation (MEDI): the SNU χ-separation toolbox's `chi_sep_MEDI`
2//!
3//! [`chi_sep_medi`] implements **`chi_sep_MEDI` of the χ-separation toolbox v1.1.3** (SNU-LIST,
4//! Jongho Lee's lab; `functions/corefunc/chi_sep_MEDI.p` and `chi_sep_MEDI_func.p`), as its
5//! script `Chisep_script.m` calls it (l. 153-161):
6//! `chi_sep_MEDI(mag, local_field_hz, r2prime, N_std, mask, params, option_data)` with
7//! `params.lambda = 1`, `params.lambda_CSF = 1`, `option_data.qsm`, `option_data.mask_CSF` and
8//! `option_data.N_std`. The toolbox ships as protected P-code; with the authors' permission the
9//! algorithm was recovered by black-box probing in licensed MATLAB R2023b: the profiler (which
10//! lists the bodies of the P-code's anonymous functions verbatim), logging wrappers on the
11//! functions it calls that save the caller's whole workspace (`evalin('caller', 'save ...')`),
12//! and replaying the operators captured in those workspaces (their function handles stay
13//! callable after `load`). A MATLAB re-implementation built from that reproduces
14//! `chi_sep_MEDI` to 3·10⁻¹⁶ relative on six phantoms (see *Agreement with the toolbox*).
15//!
16//! Earlier QSM.rs versions ported Shin et al. 2021's Supplementary Methods instead, with their
17//! own weights, λ and no QSM, CSF mask or noise map; the toolbox's algorithm below differs in
18//! every step after the forward model.
19//!
20//! # The algorithm
21//!
22//! The unknowns are `x+ = χ+ ≥ 0` and `x− = |χ−| ≥ 0` in ppm. With `D` the dipole convolution in
23//! Hz (`(CF/10⁶)·F⁻¹ D F`) and `D_r,±` the relaxometric constants (Hz/ppm):
24//!
25//! 1. **Defaults** (`chi_sep_MEDI`): `B0 = CF/42.58·10⁶`; `D_r,pos = D_r,neg = 137·(B0/2.9)`
26//!    ([`toolbox_default_dr`]); `pad_size` 5, `tol_norm_ratio` 0.03, `max_iter` 30,
27//!    `cg_max_iter` 100, `cg_tol` 0.05 (see [`ChiSepMediParams`]); and, not parameters, at least
28//!    [`CHI_SEP_MEDI_MIN_ITER`] = 6 iterations, `ε` = 10⁻⁵ and `wei_mag` = 1/(2π).
29//! 2. **Edge masks** on the input grid: `wG = gradient_mask(mag, mask, 0.9)` (MEDI's
30//!    `gradient_mask.m`: `|∇(mag·mask)|` thresholded so that 90 % of the mask's voxel count of
31//!    gradient components lie above the level; 1 = not an edge). The R2′ edge mask `wG_r2p` is
32//!    the same magnitude mask.
33//! 3. **Padding**: every volume is zero-padded by `pad_size` voxels at both ends of the **third
34//!    axis only**; `wG`, `wG_r2p` are padded with 1. Everything below runs on that grid. Exactly
35//!    zero R2′ voxels (including the padding) become `eps` (2.2·10⁻¹⁶).
36//! 4. **Weights and targets**: `m = dataterm_mask(N_std, mask)` (MEDI's: `mask/N_std`, 0 where not
37//!    finite, divided by its mean over the mask); R2′ residual weight `wei_mag·m`, field residual
38//!    weight `m`. Targets: `R2′·mask` where `R2′ > 0` (0 elsewhere) and the local field in Hz.
39//! 5. **Dipole kernel**: MEDI's `dipole_kernel(N, vs, B0_dir, 'kspace')`: the k-grid
40//!    `(−N/2 : N/2−1)/(N·vs)` per axis, `D = 1/3 − (k·B0)²/|k|²` (0 at `k = 0`), `fftshift`ed. On
41//!    an axis with an odd number of voxels that grid steps through half-integers and misses
42//!    `k = 0`, so the kernel is sampled half a frequency bin off and the convolution has an
43//!    imaginary part; it is kept as the toolbox keeps it (complex arithmetic throughout, real part
44//!    taken where the toolbox takes it).
45//! 6. **Initial guess** from the QSM `χ` (`option_data.qsm`): the voxelwise solution of
46//!    `D_r,pos·x+ + D_r,neg·x− = R2′`, `x+ − x− = χ`, each clamped at 0:
47//!    `x+ = max((R2′ + D_r,neg·χ)/(D_r,pos + D_r,neg), 0)`, `x− = max((R2′ − D_r,pos·χ)/(…), 0)`
48//!    (with the raw R2′, negative values included). The iterate is that, masked. A copy with the
49//!    CSF voxels set to 0 is what the first iteration's L1 weights see (a quirk of the toolbox:
50//!    the iterate itself keeps the CSF values).
51//! 7. **Gauss-Newton** while (`res > tol` or `iter < 6`) and `iter < max_iter`:
52//!    - L1 weights `V_s = 1/√(|wG·∇x_s|² + ε)` for `s = +, −`, and `V_t = 1/√(|wG·∇(x+ − x−)|² + ε)`
53//!      (∇: MEDI's `fgrad`, forward differences scaled by the voxel size, 0 at the last voxel).
54//!    - Gradients `b_s` of
55//!      `‖wei_mag·m·(D_r,pos·x+ + D_r,neg·x− − R2′)‖² + ‖m·(D x+ − D x− − f)‖²
56//!       + λ Σ_s ‖wG V_s^½ ∇x_s‖² + 2λ ‖wG V_t^½ ∇(x+ − x−)‖² + λ_CSF Σ_s ‖LT(|x_s|)‖²`
57//!      (as the toolbox evaluates them: `2·D_rᵀ M²·residual`, `∇ᵀ(wG V wG ∇·)` with `∇ᵀ` the exact
58//!      adjoint of `fgrad`), where `LT(v) = M_CSF·(v − mean(v over M_CSF))`.
59//!    - One CG solve **per source** (`cgsolve.m`, MEDI's: zero start, last iterate, residual
60//!      recomputed every 50 iterations): `A_s dx_s = −b_s`, where `A_s` is the same expression's
61//!      linearisation in `x_s` with the other source's step set to zero, **CSF term included**
62//!      (`λ_CSF·LT(LT(|dx|))`, nonlinear through the `abs`; the toolbox rebinds its `reg_p`/`reg_n`
63//!      to include it before building the CG operators).
64//!    - `x_s ← max(x_s + dx_s·mask, 0)`; `res = ‖Δx‖/‖x‖` over both sources inside the mask
65//!      eroded by a sphere (`imerode(mask, strel('sphere', erode_size))`, voxels outside the
66//!      array counted as inside), with `Δx` the projected step. `erode_size =
67//!      min(round(min(effective_mask_size)/10), 5)` voxels, `effective_mask_size` being the
68//!      number of slices along each axis that hold any mask voxel (5 on a head); with
69//!      `erode_size` 0 the whole padded grid counts. The iterate with the smallest `res` so far is
70//!      kept.
71//! 8. **Output**: that iterate, cropped back to the input grid: `x_para = x+`, `x_dia = x−` (≥ 0),
72//!    `x_tot = x+ − x−`. QSM.rs returns `(χ+, χ− = −x−, χ+ + χ−)`.
73//!
74//! # Units
75//!
76//! The toolbox takes the local field in Hz; [`chi_sep_medi`] takes it in ppm and multiplies by
77//! `cf/10⁶` (so the result depends on [`ChiSepMediParams::cf`] only through `D_r`'s default and the
78//! relative weight of the two residuals). R2′ is in Hz, the QSM and the output in ppm.
79//!
80//! # Agreement with the toolbox
81//!
82//! Compared with `chi_sep_MEDI` itself (toolbox v1.1.3 in licensed MATLAB R2023b; QSM.rs in
83//! double precision, as the toolbox runs):
84//!
85//! - **Phantoms** (`tests/chi_sep_medi_snu_parity.rs`, fixture from
86//!   `tests/data/chi_sep_medi_snu_small.m`): 40×36×28 at 3 T with a CSF mask; 33×29×21 at 7 T,
87//!   0.8×0.9×1.5 mm, oblique B0, with a CSF mask; negative R2′, `D_r` 120/150, λ = 2, no CSF
88//!   mask. Same Gauss-Newton iteration counts (7, 6, 6), χ+ and |χ−| to relative L2
89//!   ≤ 1.1·10⁻¹⁵, max |Δ| ≤ 8·10⁻¹⁷ ppm.
90//! - **The verified QSM-CI `chi-sep-medi` container** (v4, identical to licensed MATLAB;
91//!   QSMxT/QSM-CI#351): `option_data.qsm` = χ_total, `N_std` = `Fit_ppm_complex`'s `dp1` from the
92//!   echoes' magnitudes, `mask_CSF = []`, `CF` = 42.5774 MHz/T·B0. QSM.rs's `N_std`
93//!   ([`crate::utils::fit_ppm_complex_noise`]) equals the container's bit for bit in every case.
94//!   - a 24×22×18 phantom whose voxels decay at different rates (`tests/chi_sep_medi_qsmci_parity.rs`,
95//!     regenerated in Actions by `tests/data/chi_sep_medi_qsmci_small.py`): χ+ and |χ−| to relative
96//!     L2 2.6·10⁻⁸, the container's float32 output;
97//!   - in vivo (the EMTP travelling-head scan of QSM-CI's fidelity check, 3 T, 184×256×128,
98//!     1.39 M mask voxels; its local field, R2′ and χ): 6 iterations each, the same `res` at every
99//!     iteration (to the 4 digits the toolbox prints); χ+ and |χ−| to relative L2 2.5·10⁻⁸, max
100//!     |Δ| 3·10⁻⁸ / 9·10⁻⁸ ppm, the float32 output (measured 2026-11-01, `examples/chi_sep_file.rs`);
101//!   - QSM-CI's χ-separation phantom (`chisep-mc`, 7 T, 164×205×205, same inputs): 6 iterations
102//!     each, `res` equal at the first; χ+ relative L2 1.7·10⁻³ (max |Δ| 8.3·10⁻³ ppm), |χ−|
103//!     9.3·10⁻³ (7.9·10⁻³ ppm), r ≥ 0.9999. That residual is rounding, amplified: on this phantom
104//!     every inner CG solve runs 42-50 iterations (5-10 in vivo), and the result moves as much under
105//!     changes at the last bit. The toolbox itself, run with 4 instead of 8 threads, moves by
106//!     3.7·10⁻³ / 1.13·10⁻² (more than QSM.rs differs from it), and QSM.rs moves by 2.1·10⁻³ /
107//!     4.5·10⁻³ when `CF` changes by one ulp. QSM-CI's `chisep-medi` / `chisep-medi-qsmrs` parity
108//!     pair checks this phantom.
109//!
110//!   Before QSM.rs#382 these cases ran with `N_std = 1/|mag|` (in vivo relative L2 8·10⁻¹³ against
111//!   licensed MATLAB with that input), and the convergence test eroded the mask by 3 voxels
112//!   instead of the toolbox's `erode_size` (5 on these scans; see step 7), which changed `res` by
113//!   about 5 % but neither the iteration counts nor the results here.
114
115//! # Performance
116//!
117//! Each CG iteration costs four 3-D FFTs (two dipole convolutions, complex) and two fused
118//! gradient-adjoint passes; a Gauss-Newton iteration is two CG solves. In vivo (184×256×138
119//! padded, 6 iterations) [`chi_sep_medi`] takes 106-133 s on 6 threads with a 2.7 GB peak (the
120//! toolbox: 286 s on 8); on `chisep-mc` (164×205×215, CG solves of ~45 iterations) 282-292 s
121//! (the toolbox: 931 s). Memory is about 45 `f64` per padded voxel.
122//!
123//! # Deviations
124//!
125//! - **An all-zero CSF mask means no CSF term.** The toolbox applies the CSF term whenever
126//!   `option_data.mask_CSF` is not empty; with a mask that has no voxels, the mean over it is
127//!   `NaN`, `NaN·λ_CSF` is `NaN` even for `λ_CSF = 0`, and every step is lost (the solver returns its
128//!   initial guess). Its own GUI passes `[]` when it has no CSF mask, which turns the term off.
129//!   The CSF mask is a required input here, as in `Chisep_script.m`; passing an all-zero mask
130//!   reproduces the GUI's no-CSF behaviour (`mask_CSF = []`).
131//! - **No wall-clock limit.** The toolbox also stops after `params.time_limit` (default 1800 s) of
132//!   iterations. That makes its output depend on the machine; it is not reproduced.
133//! - Options of `chi_sep_MEDI` that `Chisep_script.m` does not use are not exposed:
134//!   `option_data.wG`/`wG_r2p` (precomputed edge masks), `mask_FastRelax`/`mask_SlowRelax`
135//!   (voxels whose R2′ residual weight drops to 0.1; both default to empty), and the TKD
136//!   initialisation it uses without `option_data.qsm` (`params.init_option`).
137//!
138//! # Reference pipeline
139//!
140//! What `Chisep_script.m` (toolbox v1.1.3) runs before `chi_sep_MEDI`, with its line numbers:
141//!
142//! - Tukey k-space windowing of the complex multi-echo data, factor 0.4 for Siemens/GE and 0 for
143//!   Philips (l. 70-73); `mag` = root-sum-of-squares over echoes (l. 75).
144//! - `N_std` from `Preprocessing4Phase(mag, phase)` (l. 76), which is MEDI's `Fit_ppm_complex`
145//!   noise map (probed: equal to 3·10⁻¹⁸; QSM.rs [`crate::utils::fit_ppm_complex_noise`]).
146//! - Mask: MEDI's `BET` on `mag` (l. 82); R2* by ARLO with more than three equally spaced echoes,
147//!   NNLS otherwise (l. 87-93); `mask_CSF = extract_CSF(r2star, mask, voxel_size)` (MEDI's
148//!   `extract_CSF.m`, l. 96; QSM.rs [`crate::inversion::extract_csf`]).
149//! - ROMEO with phase-offset correction and weighted echo averaging, `W = TE·exp(−TE/40 ms)`
150//!   (l. 99-123); V-SHARP `smvsize` 12 (l. 136-139); the local field in Hz (l. 139).
151//! - QSM: STI Suite `QSM_iLSQR(..., 'padsize', [12 12 12])` (l. 145), passed as `option_data.qsm`
152//!   (l. 158).
153//! - R2′ is the user's (the script's input; its README offers a pseudo-R2 map otherwise).
154//!
155//! The toolbox's GUI (`WrapperChisepMEDI.p`, probed with a stand-in app object) differs: it
156//! passes `mask_CSF = []` (no CSF term), the RSS of the Tukey-windowed magnitude, `λ = λ_CSF = 1`,
157//! and `option_data.qsm` only when its "QSM initialisation" option is on. Its `N_std` is the
158//! script's: `Fit_ppm_complex`'s `dp1` from the multi-echo magnitude, zero outside the mask
159//! (QSM.rs#382; probed in licensed R2023b on phantoms whose voxels decay at different rates, where
160//! the GUI's and `Preprocessing4Phase`'s `N_std` both equal `dp1` to 1.1·10⁻¹⁵). An earlier reading
161//! of the GUI's as `c/|mag|` came from a phantom with uniform decay, where `dp1` is proportional to
162//! `1/RSS`; with per-voxel decay `dp1·RSS` varies (0.72-0.87 there).
163//!
164//! Reference: Shin, H.-G., Lee, J., Yun, Y. H., Yoo, S. H., Jang, J., Oh, S.-H., Nam, Y., Jung,
165//! S., Kim, S., Fukunaga, M., Kim, W., Choi, H. J., Lee, J. (2021). "χ-separation: Magnetic
166//! susceptibility source separation toward iron and myelin mapping in the brain." NeuroImage,
167//! 240:118371. <https://doi.org/10.1016/j.neuroimage.2021.118371>
168//!
169//! # Pipeline rules
170//!
171//! The facts above that a configuration can depart from are published as rules in
172//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source):
173//! `chi-sep.mask-bet`, `chi-sep.unwrap-romeo`, `chi-sep.echo-combination`, `chi-sep.echo-weights`,
174//! `chi-sep.bg-vsharp`, `chi-sep-medi.qsm-ilsqr`, and by manufacturer `chi-sep.tukey-siemens-ge`
175//! and `chi-sep.tukey-philips` (`Chisep_script.m` Tukey-windows the echoes before this method as
176//! before χ-sepnet; [`crate::utils::tukey_window`]). All are the authors' setting
177//! (`Kind::Reference`).
178
179use crate::error::{check_len, InputError};
180use crate::fft::Fft3dWorkspace;
181use crate::inversion::medi::{cgsolve, dataterm_mask, fgrad, gradient_mask, par_fill, par_update, reg, CgBuffers};
182use crate::Grid;
183use num_complex::Complex64;
184
185use super::ChiSepMaps;
186
187/// The gyromagnetic ratio the toolbox's `chi_sep_MEDI` and `chi_sep_iLSQR` use to get the field
188/// strength from `params.CF` (their `gyro_`), in Hz/T. It differs from
189/// [`crate::constants::GAMMA_BAR_HZ_PER_T`] (42.576 MHz/T); kept so the field-dependent defaults
190/// match the toolbox.
191pub const TOOLBOX_GYRO_HZ_PER_T: f64 = 42.58e6;
192
193/// The field strength, in tesla, at which the toolbox's default relaxometric constant is
194/// [`super::DR_SHIN_2021`] (137 Hz/ppm): `chi_sep_MEDI` and `chi_sep_iLSQR` default to
195/// `D_r = 137·(B0/2.9)` for both sources, `B0 = CF/42.58e6`, when `params.Dr_pos`/`Dr_neg` are not
196/// given (probed: 141.7241379310345 at `CF` = 127.74 MHz, bit for bit). Shin et al. 2021 measured
197/// 137 on a nominal 3 T (2.89 T) scanner; the toolbox scales it linearly with the field.
198pub const TOOLBOX_DR_REFERENCE_TESLA: f64 = 2.9;
199
200/// The toolbox's default `D_r` in Hz/ppm at central frequency `cf` (Hz): `137·((cf/42.58e6)/2.9)`,
201/// in that order of operations.
202pub fn toolbox_default_dr(cf: f64) -> f64 {
203    super::DR_SHIN_2021 * ((cf / TOOLBOX_GYRO_HZ_PER_T) / TOOLBOX_DR_REFERENCE_TESLA)
204}
205
206/// Gauss-Newton iterations `chi_sep_MEDI` always runs (its `min_iter`, not a parameter), even
207/// once the relative change is below [`ChiSepMediParams::tol`].
208pub const CHI_SEP_MEDI_MIN_ITER: usize = 6;
209
210/// `ε` in the L1 weights `1/sqrt(|wG·∇χ|² + ε)` (`chi_sep_MEDI_func`'s `eps__`).
211const EPS_L1: f64 = 1e-5;
212/// Weight of the R2′ residual against the field residual, both in Hz (`wei_mag` = 1/(2π)).
213const WEI_MAG: f64 = 1.0 / (2.0 * std::f64::consts::PI);
214/// MEDI `gradient_mask`'s `percentage` as the toolbox passes it: the number of gradient components
215/// marked as edges, as a fraction of the mask's voxel count.
216const EDGE_PERCENTAGE: f64 = 0.9;
217/// The largest radius, in voxels, of `chi_sep_MEDI`'s convergence-mask erosion.
218const ERODE_MAX: i64 = 5;
219
220/// `chi_sep_MEDI`'s convergence mask (`Mask_erode`): the mask eroded by a sphere of radius
221/// `erode_size = min(round(min(effective_mask_size)/10), 5)` voxels, where `effective_mask_size`
222/// counts, along each axis, the slices that hold any mask voxel (probed in licensed R2023b: two
223/// 10³ cubes far apart give [20 20 20]; voxel sizes and `pad_size` play no part; a head gives 5).
224/// A radius of 0 (an axis with fewer than five such slices) makes it the whole padded grid, as in
225/// the toolbox. `round` is MATLAB's, halves away from zero (15 slices give 2, 25 give 3), as
226/// Rust's.
227fn convergence_mask(mask: &[bool], dims: (usize, usize, usize)) -> Vec<bool> {
228    let (nx, ny, nz) = dims;
229    let mut any = [vec![false; nx], vec![false; ny], vec![false; nz]];
230    for (p, _) in mask.iter().enumerate().filter(|(_, &m)| m) {
231        any[0][p % nx] = true;
232        any[1][(p / nx) % ny] = true;
233        any[2][p / (nx * ny)] = true;
234    }
235    let extent = any.iter().map(|a| a.iter().filter(|&&v| v).count()).min().unwrap_or(0);
236    let r = ((extent as f64 / 10.0).round() as i64).min(ERODE_MAX);
237    if r == 0 {
238        vec![true; mask.len()]
239    } else {
240        erode_sphere(mask, dims, r)
241    }
242}
243
244/// Parameters for [`chi_sep_medi`]. The defaults are the toolbox's (see the module docs).
245#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
246#[derive(Clone, Debug)]
247pub struct ChiSepMediParams {
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    /// Weight of the edge-masked L1 terms (`params.lambda`; default 1, as `Chisep_script.m` and
252    /// the toolbox GUI set it).
253    pub lambda: f64,
254    /// Weight of the CSF term (`params.lambda_CSF`; default 1, as `Chisep_script.m` and the GUI
255    /// set it). Used only where [`ChiSepMediInputs::mask_csf`] has voxels.
256    pub lambda_csf: f64,
257    /// Paramagnetic relaxometric constant in Hz/ppm (`params.Dr_pos`). `None` (the default):
258    /// the toolbox's `137·(B0/2.9)` ([`toolbox_default_dr`]).
259    pub dr_pos: Option<f64>,
260    /// Diamagnetic relaxometric constant in Hz/ppm (`params.Dr_neg`). `None` (the default): the
261    /// same as [`Self::dr_pos`]'s default.
262    pub dr_neg: Option<f64>,
263    /// Zero padding, in voxels, added at both ends of the third axis only (`params.pad_size`;
264    /// default 5).
265    pub pad: usize,
266    /// Stop once the relative change of `[χ+ |χ−|]` in the eroded mask falls to this, after at
267    /// least [`CHI_SEP_MEDI_MIN_ITER`] iterations (`params.tol_norm_ratio`; default 0.03).
268    pub tol: f64,
269    /// Gauss-Newton iterations at most (`params.max_iter`; default 30).
270    pub max_iter: usize,
271    /// Conjugate-gradient relative tolerance (`params.cg_tol`; default 0.05).
272    pub cg_tol: f64,
273    /// Conjugate-gradient iterations at most, per source and Gauss-Newton step
274    /// (`params.cg_max_iter`; default 100).
275    pub cg_max_iter: usize,
276}
277
278impl Default for ChiSepMediParams {
279    fn default() -> Self {
280        Self {
281            cf: 123.2e6,
282            lambda: 1.0,
283            lambda_csf: 1.0,
284            dr_pos: None,
285            dr_neg: None,
286            pad: 5,
287            tol: 0.03,
288            max_iter: 30,
289            cg_tol: 0.05,
290            cg_max_iter: 100,
291        }
292    }
293}
294
295impl ChiSepMediParams {
296    /// The relaxometric constants used, `(D_r,pos, D_r,neg)` in Hz/ppm: the explicit values, or
297    /// the toolbox's [`toolbox_default_dr`].
298    pub fn dr(&self) -> (f64, f64) {
299        let default = toolbox_default_dr(self.cf);
300        (self.dr_pos.unwrap_or(default), self.dr_neg.unwrap_or(default))
301    }
302
303    /// Checks the parameters [`chi_sep_medi`] checks.
304    pub fn validate(&self) -> Result<(), InputError> {
305        let pos = |name: &str, v: f64| {
306            if v.is_finite() && v > 0.0 {
307                Ok(())
308            } else {
309                Err(InputError::param(name, v, "finite and > 0"))
310            }
311        };
312        pos("χ-separation MEDI cf", self.cf)?;
313        let (dp, dn) = self.dr();
314        pos("χ-separation MEDI dr_pos", dp)?;
315        pos("χ-separation MEDI dr_neg", dn)?;
316        for (name, v) in [("χ-separation MEDI lambda", self.lambda), ("χ-separation MEDI lambda_csf", self.lambda_csf)] {
317            if !(v.is_finite() && v >= 0.0) {
318                return Err(InputError::param(name, v, "finite and ≥ 0"));
319            }
320        }
321        for (name, v) in [("χ-separation MEDI tol", self.tol), ("χ-separation MEDI cg_tol", self.cg_tol)] {
322            if !(v.is_finite() && v >= 0.0) {
323                return Err(InputError::param(name, v, "finite and ≥ 0"));
324            }
325        }
326        if self.max_iter == 0 {
327            return Err(InputError::param("χ-separation MEDI max_iter", 0, "≥ 1"));
328        }
329        Ok(())
330    }
331}
332
333/// The volumes [`chi_sep_medi`] takes, as `chi_sep_MEDI(mag, local_field_hz, r2prime, N_std,
334/// mask, params, option_data)` takes them. All are on the grid, column-major.
335#[derive(Clone, Copy)]
336pub struct ChiSepMediInputs<'a> {
337    /// Magnitude (`mag`; `Chisep_script.m` passes the root-sum-of-squares over echoes). Only its
338    /// edges are used (MEDI's `gradient_mask`).
339    pub magnitude: &'a [f64],
340    /// Local field in ppm (the toolbox takes it in Hz; converted with [`ChiSepMediParams::cf`]).
341    pub local_field: &'a [f64],
342    /// R2′ in Hz (`r2prime`).
343    pub r2prime: &'a [f64],
344    /// Noise standard deviation of the field (`N_std`); the data weights are `mask/N_std`
345    /// normalised to mean 1, so only its relative values matter. The toolbox (script and GUI)
346    /// passes MEDI `Fit_ppm_complex`'s `dp1` from the echoes' magnitudes,
347    /// [`crate::utils::fit_ppm_complex_noise`].
348    pub n_std: &'a [f64],
349    /// Conventional QSM in ppm (`option_data.qsm`), the initial guess.
350    pub qsm: &'a [f64],
351    /// Brain mask (`mask`), nonzero inside.
352    pub mask: &'a [u8],
353    /// CSF mask (`option_data.mask_CSF`), nonzero inside; required, as `Chisep_script.m` passes
354    /// one. All zero: no CSF term, which reproduces the toolbox GUI (`mask_CSF = []`).
355    pub mask_csf: &'a [u8],
356}
357
358/// The arrays of one run, on the padded grid.
359struct Problem {
360    dims: (usize, usize, usize),
361    vs: [f64; 3],
362    /// R2′ data weight `wei_mag·m` and field data weight `m` (`M_cat`'s two halves)
363    w_mag: Vec<f64>,
364    w_phs: Vec<f64>,
365    /// R2′`.*mask` where R2′ > 0, and the local field in Hz (`target_`'s two halves)
366    t_mag: Vec<f64>,
367    t_phs: Vec<f64>,
368    /// Dipole kernel, MEDI's `dipole_kernel(..., 'kspace')`
369    d: Vec<f64>,
370    /// MEDI edge mask of the magnitude, 1 = not an edge (`wG`; `wG_r2p` is the same)
371    wg: [Vec<f64>; 3],
372    csf: Vec<usize>,
373    drp: f64,
374    drn: f64,
375    cf_mhz: f64,
376    lambda: f64,
377    lambda_csf: f64,
378}
379
380/// χ-separation by the SNU χ-separation toolbox's `chi_sep_MEDI` (see the module docs).
381///
382/// Takes the volumes of [`ChiSepMediInputs`] on `grid` and the B0 direction `bdir` (used as
383/// given, as the toolbox uses `params.b0_dir`; pass a unit vector), and returns `(χ+, χ−,
384/// χ_total)` in ppm, χ+ ≥ 0, χ− ≤ 0, zero outside the mask. `progress` receives
385/// `(iteration, max_iter)` after each Gauss-Newton step.
386///
387/// # Errors
388/// An [`InputError`] for parameters [`ChiSepMediParams::validate`] rejects and for a volume
389/// whose length is not the grid's.
390pub fn chi_sep_medi(
391    inputs: &ChiSepMediInputs,
392    grid: &Grid,
393    bdir: (f64, f64, f64),
394    params: &ChiSepMediParams,
395    mut progress: impl FnMut(usize, usize),
396) -> Result<ChiSepMaps, InputError> {
397    params.validate()?;
398    let n0 = grid.n_total();
399    for (name, len) in [
400        ("χ-separation MEDI magnitude", inputs.magnitude.len()),
401        ("χ-separation MEDI local_field", inputs.local_field.len()),
402        ("χ-separation MEDI r2prime", inputs.r2prime.len()),
403        ("χ-separation MEDI n_std", inputs.n_std.len()),
404        ("χ-separation MEDI qsm", inputs.qsm.len()),
405        ("χ-separation MEDI mask", inputs.mask.len()),
406        ("χ-separation MEDI mask_csf", inputs.mask_csf.len()),
407    ] {
408        check_len(name, len, n0)?;
409    }
410    let (nx, ny, nz0) = grid.dims;
411    let pz = params.pad;
412    let nz = nz0 + 2 * pz;
413    let dims = (nx, ny, nz);
414    let n = nx * ny * nz;
415    let nxy = nx * ny;
416    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
417    // padarray(a, [0 0 pad_size], fill, 'both')
418    let pad = |a: &dyn Fn(usize) -> f64, fill: f64| -> Vec<f64> {
419        let mut out = vec![fill; n];
420        out[pz * nxy..(pz + nz0) * nxy].iter_mut().enumerate().for_each(|(i, v)| *v = a(i));
421        out
422    };
423    let mask0: Vec<bool> = inputs.mask.iter().map(|&m| m != 0).collect();
424    // wG = gradient_mask(iMag, Mask, 0.9) on the input grid; wG_r2p is the same. Padded with 1.
425    let wg0 = gradient_mask(inputs.magnitude, &mask0, grid, EDGE_PERCENTAGE);
426    let wg = [0, 1, 2].map(|c| pad(&|i| wg0[c][i], 1.0));
427    let maskf = pad(&|i| f64::from(u8::from(mask0[i])), 0.0);
428    let mask: Vec<bool> = maskf.iter().map(|&m| m != 0.0).collect();
429    let (drp, drn) = params.dr();
430    let cf_mhz = params.cf / 1e6;
431    let t_phs = pad(&|i| inputs.local_field[i] * cf_mhz, 0.0);
432    // r2prime(r2prime == 0) = eps, after padding
433    let mut r2p = pad(&|i| inputs.r2prime[i], 0.0);
434    par_update(&mut r2p, |_, v| {
435        if *v == 0.0 {
436            *v = f64::EPSILON;
437        }
438    });
439    let n_std = pad(&|i| inputs.n_std[i], 0.0);
440    let qsm = pad(&|i| inputs.qsm[i], 0.0);
441    let csf_mask: Vec<bool> = pad(&|i| f64::from(u8::from(inputs.mask_csf[i] != 0)), 0.0).iter().map(|&v| v != 0.0).collect();
442    let csf: Vec<usize> = (0..n).filter(|&i| csf_mask[i]).collect();
443
444    // m = dataterm_mask(1, N_std, Mask); M_cat = [wei_mag.*m.*wei_r2p, m]
445    let mut m = vec![0.0; n];
446    dataterm_mask(1, &n_std, &mask, &mut m);
447    let mut w_mag = vec![0.0; n];
448    par_fill(&mut w_mag, |i| WEI_MAG * m[i]);
449    // target: R2' where it is positive (the initial guess below takes it as given)
450    let mut t_mag = vec![0.0; n];
451    par_fill(&mut t_mag, |i| if r2p[i] > 0.0 { r2p[i] * maskf[i] } else { 0.0 });
452    let d = toolbox_dipole_kernel(dims, vs, bdir);
453
454    // Initial guess from the voxelwise system Dr_pos·x+ + Dr_neg·x− = R2', x+ − x− = χ.
455    let init = |sign: f64, dr_other: f64| -> Vec<f64> {
456        let mut v = vec![0.0; n];
457        par_fill(&mut v, |i| ((r2p[i] + sign * dr_other * qsm[i]) / (drp + drn)).max(0.0));
458        v
459    };
460    let xp0 = init(1.0, drn);
461    let xn0 = init(-1.0, drp);
462    // The weights of the first iteration see the guess with the CSF zeroed; the iterate does not.
463    let mut x_pos = xp0.clone();
464    let mut x_neg = xn0.clone();
465    for &i in &csf {
466        x_pos[i] = 0.0;
467        x_neg[i] = 0.0;
468    }
469    let mut it_pos = xp0;
470    let mut it_neg = xn0;
471    par_update(&mut it_pos, |i, v| *v *= maskf[i]);
472    par_update(&mut it_neg, |i, v| *v *= maskf[i]);
473    let eroded = convergence_mask(&mask, dims);
474
475    let pb = Problem {
476        dims, vs, w_mag, w_phs: m, t_mag, t_phs, d, wg, csf,
477        drp, drn, cf_mhz, lambda: params.lambda, lambda_csf: params.lambda_csf,
478    };
479    let mut ws = Workspace::new(dims);
480
481    let mut iter = 0;
482    let mut res = f64::INFINITY;
483    let mut res_min = f64::INFINITY;
484    let (mut best_pos, mut best_neg) = (x_pos.clone(), x_neg.clone());
485    let mut cg = CgBuffers::new(n);
486    let mut bp = vec![0.0; n];
487    let mut bn = vec![0.0; n];
488    while (res > params.tol || iter < CHI_SEP_MEDI_MIN_ITER) && iter < params.max_iter {
489        iter += 1;
490        // Vr = 1./sqrt(abs(wG.*grad(x)).^2 + eps), for each source and for their difference
491        let vr = |ws: &mut Workspace, x: &[f64]| -> [Vec<f64>; 3] {
492            fgrad(x, dims, vs, &mut ws.g);
493            let mut out = [vec![0.0; n], vec![0.0; n], vec![0.0; n]];
494            for c in 0..3 {
495                let (g, w) = (&ws.g[c], &pb.wg[c]);
496                par_fill(&mut out[c], |i| {
497                    let a = w[i] * g[i];
498                    // wG.*(Vr.*(wG.*.)) with wG ∈ {0, 1}
499                    w[i] / (a * a + EPS_L1).sqrt()
500                });
501            }
502            out
503        };
504        let mut diff = vec![0.0; n];
505        par_fill(&mut diff, |i| x_pos[i] - x_neg[i]);
506        let w_pos = vr(&mut ws, &x_pos);
507        let w_neg = vr(&mut ws, &x_neg);
508        let w_tot = vr(&mut ws, &diff);
509        // bp, bn: the gradients, at (x_pos, x_neg)
510        pb.gradients(&mut ws, &x_pos, &x_neg, &w_pos, &w_neg, &w_tot, &mut bp, &mut bn);
511        par_update(&mut bp, |_, v| *v = -*v);
512        par_update(&mut bn, |_, v| *v = -*v);
513        cgsolve(|dx, out| pb.apply(&mut ws, dx, true, &w_pos, &w_tot, out), &bp, params.cg_tol, params.cg_max_iter, &mut cg, |_| {});
514        let dxp = cg.x.clone();
515        cgsolve(|dx, out| pb.apply(&mut ws, dx, false, &w_neg, &w_tot, out), &bn, params.cg_tol, params.cg_max_iter, &mut cg, |_| {});
516        let dxn = &cg.x;
517        // x = max(x + dx.*Mask, 0); res = norm(dx_real.*Me)/norm(x.*Me) over both sources
518        let mut num = 0.0;
519        let mut den = 0.0;
520        for (it, x, dx) in [(&mut it_pos, &mut x_pos, &dxp[..]), (&mut it_neg, &mut x_neg, &dxn[..])] {
521            for i in 0..n {
522                let new = (it[i] + dx[i] * maskf[i]).max(0.0);
523                if eroded[i] {
524                    let dr = new - it[i];
525                    num += dr * dr;
526                    den += new * new;
527                }
528                it[i] = new;
529                x[i] = new;
530            }
531        }
532        res = num.sqrt() / den.sqrt();
533        if res < res_min {
534            res_min = res;
535            best_pos.copy_from_slice(&x_pos);
536            best_neg.copy_from_slice(&x_neg);
537        }
538        progress(iter, params.max_iter);
539    }
540
541    // Crop the padding; χ− is returned signed.
542    let crop = |a: &[f64], sign: f64| -> Vec<f64> {
543        (0..n0).map(|i| if mask0[i] { sign * a[pz * nxy + i] } else { 0.0 }).collect()
544    };
545    let chi_pos = crop(&best_pos, 1.0);
546    let chi_neg = crop(&best_neg, -1.0);
547    let chi_total = chi_pos.iter().zip(&chi_neg).map(|(p, q)| p + q).collect();
548    Ok((chi_pos, chi_neg, chi_total))
549}
550
551/// Buffers reused across operator applications.
552struct Workspace {
553    fft: Fft3dWorkspace,
554    a: Vec<Complex64>,
555    b: Vec<Complex64>,
556    g: [Vec<f64>; 3],
557    r: Vec<f64>,
558    s: Vec<f64>,
559}
560
561impl Workspace {
562    fn new((nx, ny, nz): (usize, usize, usize)) -> Self {
563        let n = nx * ny * nz;
564        let z = Complex64::new(0.0, 0.0);
565        Self {
566            fft: Fft3dWorkspace::new(nx, ny, nz),
567            a: vec![z; n],
568            b: vec![z; n],
569            g: [vec![0.0; n], vec![0.0; n], vec![0.0; n]],
570            r: vec![0.0; n],
571            s: vec![0.0; n],
572        }
573    }
574}
575
576impl Problem {
577    /// `Dconv_p` in place: `buf ← (CF/1e6)·ifftn(D.*fftn(buf))`
578    fn dconv(&self, fft: &mut Fft3dWorkspace, buf: &mut [Complex64]) {
579        fft.fft3d(buf);
580        par_update(buf, |i, v| *v *= self.d[i]);
581        fft.ifft3d(buf);
582        let s = self.cf_mhz;
583        par_update(buf, |_, v| *v *= s);
584    }
585
586    /// `λ_CSF·LT(LT(|x|))` added to `out`, with `LT(v) = Mask_CSF.*(v − mean(v(Mask_CSF)))`
587    fn add_csf(&self, x: &[f64], out: &mut [f64]) {
588        if self.csf.is_empty() {
589            return;
590        }
591        let k = self.csf.len() as f64;
592        let m1 = self.csf.iter().map(|&i| x[i].abs()).sum::<f64>() / k;
593        let lt: Vec<f64> = self.csf.iter().map(|&i| x[i].abs() - m1).collect();
594        let m2 = lt.iter().sum::<f64>() / k;
595        for (&i, &v) in self.csf.iter().zip(&lt) {
596            out[i] += self.lambda_csf * (v - m2);
597        }
598    }
599
600    /// The regulariser of one source: `λ·∇ᵀ(W_single ∇x) + 2λ·∇ᵀ(W_tot ∇(x − y)) + CSF(x)` with
601    /// `x − y` given as `diff`, added to `out`.
602    #[allow(clippy::too_many_arguments)]
603    fn add_reg(&self, ws: &mut Workspace, x: &[f64], diff: &[f64], w_single: &[Vec<f64>; 3], w_tot: &[Vec<f64>; 3], out: &mut [f64]) {
604        let lam = self.lambda;
605        reg(x, w_single, self.dims, self.vs, &mut ws.r);
606        reg(diff, w_tot, self.dims, self.vs, &mut ws.s);
607        let (r, s) = (&ws.r, &ws.s);
608        par_update(out, |i, v| *v += lam * r[i] + 2.0 * (lam * s[i]));
609        self.add_csf(x, out);
610    }
611
612    /// The CG operator of one source (`Ap` or `An`): data term plus regulariser at `dx` with the
613    /// other source's step zero.
614    fn apply(&self, ws: &mut Workspace, dx: &[f64], pos: bool, w_single: &[Vec<f64>; 3], w_tot: &[Vec<f64>; 3], out: &mut [f64]) {
615        let dr = if pos { self.drp } else { self.drn };
616        // phase: Dconv_p(m.*(m.*Dconv_p(dx)))
617        par_fill(&mut ws.a, |i| Complex64::new(dx[i], 0.0));
618        self.dconv(&mut ws.fft, &mut ws.a);
619        let w = &self.w_phs;
620        par_update(&mut ws.a, |i, v| *v = (*v * w[i]) * w[i]);
621        self.dconv(&mut ws.fft, &mut ws.a);
622        let (a, wm) = (&ws.a, &self.w_mag);
623        par_fill(out, |i| 2.0 * (dr * (wm[i] * (wm[i] * (dr * dx[i])))) + 2.0 * a[i].re);
624        // reg_p(dx, zero_mat) / reg_n(zero_mat, dx): the difference term sees dx − 0
625        self.add_reg(ws, dx, dx, w_single, w_tot, out);
626    }
627
628    /// `bp = fidelity_derivative_p + reg_p` and `bn` likewise, at `(xp, xn)`.
629    #[allow(clippy::too_many_arguments)]
630    fn gradients(
631        &self,
632        ws: &mut Workspace,
633        xp: &[f64],
634        xn: &[f64],
635        w_pos: &[Vec<f64>; 3],
636        w_neg: &[Vec<f64>; 3],
637        w_tot: &[Vec<f64>; 3],
638        bp: &mut [f64],
639        bn: &mut [f64],
640    ) {
641        let n = xp.len();
642        // field residual: Dconv_p(xp) + (−Dconv_p(xn)) − f, weighted twice
643        par_fill(&mut ws.a, |i| Complex64::new(xp[i], 0.0));
644        self.dconv(&mut ws.fft, &mut ws.a);
645        par_fill(&mut ws.b, |i| Complex64::new(xn[i], 0.0));
646        self.dconv(&mut ws.fft, &mut ws.b);
647        let (b, t, w) = (&ws.b, &self.t_phs, &self.w_phs);
648        par_update(&mut ws.a, |i, v| *v = ((*v + (-b[i])) - t[i]) * w[i] * w[i]);
649        self.dconv(&mut ws.fft, &mut ws.a);
650        // R2' residual: Dr_pos·xp + Dr_neg·xn − r2p.*mask, weighted twice
651        let mut rm = vec![0.0; n];
652        let (wm, tm) = (&self.w_mag, &self.t_mag);
653        par_fill(&mut rm, |i| wm[i] * (wm[i] * ((self.drp * xp[i] + self.drn * xn[i]) - tm[i])));
654        let a = &ws.a;
655        par_fill(bp, |i| 2.0 * (self.drp * rm[i]) + 2.0 * a[i].re);
656        par_fill(bn, |i| 2.0 * (self.drn * rm[i]) - 2.0 * a[i].re);
657        let mut diff = vec![0.0; n];
658        par_fill(&mut diff, |i| xp[i] - xn[i]);
659        self.add_reg(ws, xp, &diff, w_pos, w_tot, bp);
660        par_fill(&mut diff, |i| xn[i] - xp[i]);
661        self.add_reg(ws, xn, &diff, w_neg, w_tot, bn);
662    }
663}
664
665/// MEDI's `dipole_kernel(matrix_size, voxel_size, B0_dir, 'kspace')`, as the toolbox builds it:
666/// the k-grid `(-N/2 : N/2-1)/(N·vs)` per axis (half-integer steps on an odd axis), `D = 1/3 −
667/// (k·B0)²/|k|²`, `NaN` (k = 0) set to 0, then `fftshift`ed (circular shift by `floor(N/2)`).
668pub(super) fn toolbox_dipole_kernel((nx, ny, nz): (usize, usize, usize), vs: [f64; 3], b0: (f64, f64, f64)) -> Vec<f64> {
669    let coord = |i: usize, n: usize, v: f64| (-(n as f64) / 2.0 + i as f64) / (n as f64 * v);
670    let mut d = vec![0.0; nx * ny * nz];
671    for k in 0..nz {
672        let z = coord(k, nz, vs[2]);
673        let kk = (k + nz / 2) % nz;
674        for j in 0..ny {
675            let y = coord(j, ny, vs[1]);
676            let jj = (j + ny / 2) % ny;
677            for i in 0..nx {
678                let x = coord(i, nx, vs[0]);
679                let ii = (i + nx / 2) % nx;
680                let s = x * b0.0 + y * b0.1 + z * b0.2;
681                let den = x * x + y * y + z * z;
682                let v = 1.0 / 3.0 - (s * s) / den;
683                d[ii + nx * (jj + ny * kk)] = if v.is_nan() { 0.0 } else { v };
684            }
685        }
686    }
687    d
688}
689
690/// `imerode(mask, strel('sphere', r))`: a voxel stays if every voxel of the ball of radius `r`
691/// voxels around it is in the mask, counting voxels outside the array as in the mask (MATLAB's
692/// border rule for erosion).
693fn erode_sphere(mask: &[bool], (nx, ny, nz): (usize, usize, usize), r: i64) -> Vec<bool> {
694    let mut offs = Vec::new();
695    for dz in -r..=r {
696        for dy in -r..=r {
697            for dx in -r..=r {
698                if dx * dx + dy * dy + dz * dz <= r * r {
699                    offs.push((dx, dy, dz));
700                }
701            }
702        }
703    }
704    let mut out = vec![false; mask.len()];
705    par_fill(&mut out, |p| {
706        if !mask[p] {
707            return false;
708        }
709        let (i, j, k) = ((p % nx) as i64, ((p / nx) % ny) as i64, (p / (nx * ny)) as i64);
710        offs.iter().all(|&(dx, dy, dz)| {
711            let (a, b, c) = (i + dx, j + dy, k + dz);
712            if a < 0 || b < 0 || c < 0 || a >= nx as i64 || b >= ny as i64 || c >= nz as i64 {
713                return true;
714            }
715            mask[a as usize + nx * (b as usize + ny * c as usize)]
716        })
717    });
718    out
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    /// A small separable problem: a χ+ block and a χ− block in an ellipsoid, with R2′ and field
726    /// consistent with them (the field from QSM.rs's own kernel; only shapes matter here).
727    /// (field ppm, R2′ Hz, χ ppm, magnitude, N_std, mask)
728    type Problem6 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
729
730    fn problem(n: (usize, usize, usize)) -> Problem6 {
731        let (nx, ny, nz) = n;
732        let nt = nx * ny * nz;
733        let mut chi = vec![0.0; nt];
734        let mut mask = vec![0u8; nt];
735        let mut r2p = vec![0.0; nt];
736        for k in 0..nz {
737            for j in 0..ny {
738                for i in 0..nx {
739                    let l = i + nx * (j + ny * k);
740                    let (x, y, z) = (i as f64 - nx as f64 / 2.0, j as f64 - ny as f64 / 2.0, k as f64 - nz as f64 / 2.0);
741                    if (x / (0.4 * nx as f64)).powi(2) + (y / (0.4 * ny as f64)).powi(2) + (z / (0.4 * nz as f64)).powi(2) <= 1.0 {
742                        mask[l] = 1;
743                        let (p, q) = (if x > 1.0 { 0.05 } else { 0.0 }, if x < -1.0 { 0.03 } else { 0.0 });
744                        chi[l] = p - q;
745                        r2p[l] = 137.0 * (p + q) + 2.0;
746                    }
747                }
748            }
749        }
750        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
751        let d = crate::kernels::dipole::dipole_kernel(&grid, (0.0, 0.0, 1.0));
752        let mut buf: Vec<Complex64> = chi.iter().map(|&v| Complex64::new(v, 0.0)).collect();
753        crate::fft::fft3d(&mut buf, nx, ny, nz);
754        buf.iter_mut().zip(&d).for_each(|(b, &dv)| *b *= dv);
755        crate::fft::ifft3d(&mut buf, nx, ny, nz);
756        let field: Vec<f64> = buf.iter().zip(&mask).map(|(b, &m)| b.re * m as f64).collect();
757        let mag: Vec<f64> = (0..nt).map(|l| 100.0 + (l % 7) as f64).collect();
758        let n_std: Vec<f64> = mask.iter().map(|&m| 0.01 * m as f64).collect();
759        (field, r2p, chi, mag, n_std, mask)
760    }
761
762    fn run(
763        n: (usize, usize, usize),
764        csf: bool,
765        params: &ChiSepMediParams,
766    ) -> (Result<ChiSepMaps, InputError>, usize, Vec<u8>) {
767        let (field, r2p, chi, mag, n_std, mask) = problem(n);
768        let mut mask_csf = vec![0u8; mask.len()];
769        if csf {
770            let c = n.0 / 2 + n.0 * (n.1 / 2 + n.1 * (n.2 / 2));
771            mask_csf[c] = 1;
772            mask_csf[c + 1] = 1;
773        }
774        let inputs = ChiSepMediInputs {
775            magnitude: &mag, local_field: &field, r2prime: &r2p, n_std: &n_std, qsm: &chi, mask: &mask, mask_csf: &mask_csf,
776        };
777        let grid = Grid::new(n.0, n.1, n.2, 1.0, 1.0, 1.0);
778        let mut iters = 0;
779        let r = chi_sep_medi(&inputs, &grid, (0.0, 0.0, 1.0), params, |i, _| iters = i);
780        (r, iters, mask)
781    }
782
783    #[test]
784    fn defaults_are_the_toolbox_values() {
785        let p = ChiSepMediParams::default();
786        assert_eq!((p.lambda, p.lambda_csf, p.pad, p.tol, p.max_iter, p.cg_tol, p.cg_max_iter), (1.0, 1.0, 5, 0.03, 30, 0.05, 100));
787        assert_eq!((p.dr_pos, p.dr_neg), (None, None));
788        let (dp, dn) = ChiSepMediParams { cf: 127.74e6, ..p.clone() }.dr();
789        assert_eq!((dp, dn), (141.7241379310345, 141.7241379310345));
790        let (dp, dn) = ChiSepMediParams { dr_pos: Some(120.0), dr_neg: Some(150.0), ..p }.dr();
791        assert_eq!((dp, dn), (120.0, 150.0));
792    }
793
794    #[test]
795    fn param_errors_name_the_parameter() {
796        let bad = [
797            ChiSepMediParams { cf: 0.0, ..Default::default() },
798            ChiSepMediParams { dr_pos: Some(-1.0), ..Default::default() },
799            ChiSepMediParams { dr_neg: Some(f64::NAN), ..Default::default() },
800            ChiSepMediParams { lambda: -1.0, ..Default::default() },
801            ChiSepMediParams { lambda_csf: f64::INFINITY, ..Default::default() },
802            ChiSepMediParams { tol: -0.1, ..Default::default() },
803            ChiSepMediParams { cg_tol: f64::NAN, ..Default::default() },
804            ChiSepMediParams { max_iter: 0, ..Default::default() },
805        ];
806        let names = ["cf", "dr_pos", "dr_neg", "lambda", "lambda_csf", "tol", "cg_tol", "max_iter"];
807        for (p, name) in bad.iter().zip(names) {
808            let e = p.validate().unwrap_err().to_string();
809            assert!(e.starts_with(&format!("χ-separation MEDI {name} = ")), "{e}");
810        }
811        assert!(ChiSepMediParams::default().validate().is_ok());
812    }
813
814    #[test]
815    fn wrong_buffer_length_is_an_input_error() {
816        let n = (8, 8, 6);
817        let nt = n.0 * n.1 * n.2;
818        let v = vec![0.0; nt];
819        let short = vec![0.0; nt - 1];
820        let m = vec![1u8; nt];
821        let grid = Grid::new(n.0, n.1, n.2, 1.0, 1.0, 1.0);
822        let inputs = ChiSepMediInputs { magnitude: &v, local_field: &v, r2prime: &short, n_std: &v, qsm: &v, mask: &m, mask_csf: &m };
823        let e = chi_sep_medi(&inputs, &grid, (0.0, 0.0, 1.0), &ChiSepMediParams::default(), |_, _| {}).unwrap_err();
824        assert_eq!(e.to_string(), format!("χ-separation MEDI r2prime has {} values, expected {nt}", nt - 1));
825    }
826
827    #[test]
828    fn sources_have_their_signs_and_runs_at_least_six_iterations() {
829        let (r, iters, mask) = run((16, 14, 10), true, &ChiSepMediParams::default());
830        let (p, q, t) = r.unwrap();
831        assert!(iters >= CHI_SEP_MEDI_MIN_ITER, "{iters}");
832        for l in 0..p.len() {
833            assert!(p[l] >= 0.0 && q[l] <= 0.0 && t[l] == p[l] + q[l]);
834            if mask[l] == 0 {
835                assert!(p[l] == 0.0 && q[l] == 0.0);
836            }
837        }
838        assert!(p.iter().any(|&v| v > 0.01) && q.iter().any(|&v| v < -0.01));
839        // max_iter below the minimum caps it
840        let (_, iters, _) = run((16, 14, 10), true, &ChiSepMediParams { max_iter: 2, ..Default::default() });
841        assert_eq!(iters, 2);
842    }
843
844    /// The toolbox turns every value NaN with a CSF mask that has no voxels; here it means no CSF
845    /// term, and the result is the same as with a mask that never touches a voxel's update.
846    /// `nnz(Mask_erode)` as `chi_sep_MEDI` made it (licensed R2023b, read from its workspace) for
847    /// box masks on a 64×60×56 grid (66 slices after its padding): the erosion radius follows the
848    /// smallest number of mask slices along an axis, rounded to tens.
849    #[test]
850    fn convergence_mask_matches_the_toolbox() {
851        let dims = (64, 60, 66);
852        let boxes = |boxes: &[([usize; 3], [usize; 3])]| -> Vec<bool> {
853            let mut m = vec![false; dims.0 * dims.1 * dims.2];
854            for &(o, n) in boxes {
855                for k in o[2]..o[2] + n[2] {
856                    for j in o[1]..o[1] + n[1] {
857                        for i in o[0]..o[0] + n[0] {
858                            m[i + dims.0 * (j + dims.1 * (k + 5))] = true;
859                        }
860                    }
861                }
862            }
863            m
864        };
865        let centred = |n: [usize; 3]| boxes(&[([(64 - n[0]) / 2, (60 - n[1]) / 2, (56 - n[2]) / 2], n)]);
866        let count = |m: &[bool]| convergence_mask(m, dims).iter().filter(|&&v| v).count();
867        for (n, want) in [([10, 10, 10], 512), ([16, 16, 16], 1728), ([24, 24, 24], 8000), ([30, 30, 30], 13824),
868                          ([48, 48, 48], 54872), ([40, 20, 10], 5472), ([15, 30, 30], 7436), ([25, 30, 30], 10944),
869                          ([4, 30, 30], 64 * 60 * 66), ([56, 56, 50], 84640)] {
870            assert_eq!(count(&centred(n)), want, "box {n:?}");
871        }
872        // Two 10³ cubes far apart count 20 slices per axis: radius 2.
873        assert_eq!(count(&boxes(&[([4, 4, 4], [10; 3]), ([44, 39, 39], [10; 3])])), 432);
874    }
875
876    #[test]
877    fn empty_csf_mask_means_no_csf_term() {
878        let (r, _, _) = run((16, 14, 10), false, &ChiSepMediParams::default());
879        let (p, q, _) = r.unwrap();
880        assert!(p.iter().chain(&q).all(|v| v.is_finite()));
881        let (r0, _, _) = run((16, 14, 10), false, &ChiSepMediParams { lambda_csf: 0.0, ..Default::default() });
882        assert_eq!(r0.unwrap().0, p);
883    }
884
885    /// On an even grid MEDI's k-space kernel is the usual unshifted kernel; on an odd axis its
886    /// grid misses k = 0 by half a bin.
887    #[test]
888    fn toolbox_kernel_is_the_fft_kernel_on_even_grids_and_half_a_bin_off_on_odd() {
889        let vs = [0.8, 1.0, 1.5];
890        let h = (0.1, -0.2, 0.97);
891        let d = toolbox_dipole_kernel((8, 6, 4), vs, h);
892        let grid = Grid::new(8, 6, 4, vs[0], vs[1], vs[2]);
893        let norm = (h.0 * h.0 + h.1 * h.1 + h.2 * h.2).sqrt();
894        let r = crate::kernels::dipole::dipole_kernel(&grid, (h.0 / norm, h.1 / norm, h.2 / norm));
895        let rn = toolbox_dipole_kernel((8, 6, 4), vs, (h.0 / norm, h.1 / norm, h.2 / norm));
896        assert!(rn.iter().zip(&r).all(|(a, b)| (a - b).abs() < 1e-12));
897        assert_eq!(d[0], 0.0);
898        let odd = toolbox_dipole_kernel((5, 6, 4), [1.0; 3], (0.0, 0.0, 1.0));
899        // no exact zero frequency on the odd axis: no entry is the k = 0 fill value except by
900        // coincidence, and index 0 holds k_x = -0.5 + 2 = ... the fftshift of -2.5..1.5
901        assert!(odd[0] != 0.0);
902    }
903
904    #[test]
905    fn erosion_counts_voxels_outside_the_array_as_inside() {
906        let dims = (9, 9, 9);
907        let full = vec![true; 729];
908        assert!(erode_sphere(&full, dims, 3).iter().all(|&v| v));
909        let mut holed = full.clone();
910        holed[4 + 9 * (4 + 9 * 4)] = false;
911        let e = erode_sphere(&holed, dims, 3);
912        // every voxel within distance 3 of the hole is removed, the rest stay
913        for k in 0..9i64 {
914            for j in 0..9i64 {
915                for i in 0..9i64 {
916                    let d2 = (i - 4).pow(2) + (j - 4).pow(2) + (k - 4).pow(2);
917                    assert_eq!(e[(i + 9 * (j + 9 * k)) as usize], d2 > 9, "({i},{j},{k})");
918                }
919            }
920        }
921    }
922}