Skip to main content

qsm_core/inversion/
whqsm.rs

1//! Weak-Harmonic QSM (WH-QSM) dipole inversion
2//!
3//! Jointly estimates the susceptibility map `x` AND a residual harmonic
4//! background field `phi_h`, so that any harmonic (Laplacian-null) field
5//! remaining after background-field removal is absorbed into `phi_h` instead
6//! of corrupting the susceptibility estimate. This makes the reconstruction
7//! robust to imperfect background-field removal.
8//!
9//! The optimization solves a nonlinear total-variation problem via ADMM,
10//! with an additional weak-harmonic regularization term coupling `x` and the
11//! harmonic field. The data-fidelity term is nonlinear (sine of the phase),
12//! and is solved with an inner Newton iteration.
13//!
14//! Reference:
15//! Milovic, C., Bilgic, B., Zhao, B., Acosta-Cabronero, J., Tejos, C. (2019).
16//! "Weak-harmonic regularization for quantitative susceptibility mapping."
17//! Magnetic Resonance in Medicine, 81(2):1399-1411.
18//! <https://doi.org/10.1002/mrm.27483>
19//!
20//! Ported from FANSI's `WH_nlTV.m` (https://gitlab.com/cmilovic/FANSI-toolbox).
21//!
22//! # Units
23//! Like the other inversions, WH-QSM takes the local field in ppm and returns ppm. `WH_nlTV.m`
24//! works on the phase in radians, so the field is converted with `2*pi*gamma*b0*te` first and the
25//! result converted back at the end; left in ppm, `sin(x) ~ x` and the data term would not be
26//! nonlinear. `b0` and `echo_times` are scan parameters that the pipeline fills from metadata, and
27//! [`WhQsmParams::te`] picks the echo time the conversion uses (by default the reference's choice;
28//! see there).
29//!
30//! # Data weight
31//! `WH_nlTV.m` squares `params.weight` (recommended: the magnitude). FANSI's WH-QSM example
32//! (`script_DICOM_Philips.m`, the only script that calls `WH_nlTV`) sets
33//! `weight = m0.*mask./cnoise`, divided by its maximum, where `m0` is the proton density
34//! from its nonlinear T2* fit (`nlT2sTV`) and `cnoise` the complex-domain noise from its
35//! nonlinear multi-echo field fit (`nlme_L2gradient`). The dipole stage has neither map. The
36//! default ([`WhQsmWeight::Flat`]) is `WH_nlTV.m`'s own default when no weight is given: 1 over
37//! the whole volume (`W = ones(N)`, lines 70-75), with the mask given separately (it confines
38//! `x` and the harmonic penalty `beta`), as the QSM-CI original runs it. The data term outside
39//! the mask is part of the method: an earlier port weighted only the mask, which with the
40//! continuous kernel left it 21.4 % / 50.7 % (sim / KKI χ NRMSE) from the verified original;
41//! a numpy transcription of `WH_nlTV.m` on the original's own inputs with the mask as the
42//! weight diverges on sim, and with `ones(N)` and FANSI's kernel it is 0.0006 % from it
43//! (QSM.rs#402). The magnitude weights are options:
44//! [`WhQsmWeight::MaxNormalised`] is the example's formula with the magnitude for `m0` and no
45//! noise term, `mask.*mag / max(mask.*mag)`, and [`WhQsmWeight::InVivo`] FANSI's
46//! `script_invivo.m` form, `min(2*mask*mag/max(mag), 1)`, as NDI uses. How the weight and the
47//! echo time ([`WhQsmParams::te`]) interact on the phantom is noted there.
48//!
49//! # Newton safeguard
50//! The inner Newton step for `z2` (`WH_nlTV.m` l. 258-268) minimises, voxel by voxel,
51//! `W*(1 - cos(z2 - phase)) + mu2/2*(z2 - rhs/mu2)^2`, whose minimiser lies within `W/mu2` of
52//! `rhs/mu2` (where the derivative `W*sin(z2 - phase) + mu2*(z2 - rhs/mu2)` vanishes). With
53//! the flat weight, a field that is non-zero outside the mask (QSM-CI's simulated local
54//! field, up to ~6 rad) puts some voxels where `W*cos + mu2` nears 0, and the unguarded step
55//! bounces: on that sim the original's Newton happened to settle after ~24 inner steps, while
56//! the port's (same formula, FFT rounding apart) ran to its 50-step cap at 1e16 and the solve
57//! went NaN. The port clamps each Newton iterate to that interval. This changes nothing where
58//! the step stays inside it (it does wherever the original converges normally), and the fixed
59//! point is the same minimiser; it is not in `WH_nlTV.m`. With it the port is 0.0006 % (sim)
60//! and 0.0009 % (KKI, no clamping needed) from the verified original (χ in radians, QSM.rs#402).
61//!
62//! # Finite differences
63//! As in `WH_nlTV.m`, the gradient and Laplacian are unit-spacing differences whatever the
64//! voxel size; only the dipole kernel uses it.
65//!
66//! # Reference pipeline
67//! FANSI `script_DICOM_Philips.m` (commit 93044a3) runs `WH_nlTV` on the same local field and
68//! TE scaling as nlTV (see [`super::fansi`](mod@super::fansi): SEGUE, multi-echo fit, MSMV background removal,
69//! `deltaTE` with "any individual echo time" allowed, L364), with `mask = mask2`, `beta = 200`,
70//! `muh = 5` and the advice that beta is "usually ... 20 to 50 times larger than muh" and that
71//! alpha1 may be slightly lower than FANSI's (L425-436). This port uses `WH_nlTV.m`'s own
72//! defaults for beta (150) and muh (5), not the script's beta of 200. Without `params.mask`,
73//! `WH_nlTV.m` takes the mask as `W > 0`; here the mask is always given.
74//!
75//! # Parity
76//! Against the verified original (QSM-CI pair `whqsm`; FANSI-toolbox 93044a3, identical to
77//! licensed MATLAB R2023b), final-map χ NRMSE at both sides' defaults on QSM-CI's simulated head
78//! and the KKI travelling head (QSM-CI parity run 41403629314, QSM.rs#399–#403): sim 0.0018 %, KKI 0.0038 %, from
79//! 21.4 % / 50.7 % (sim / KKI) before. Two causes: the kernel, and `WH_nlTV.m`'s flat weight over the whole volume (see Data weight); the Newton safeguard keeps the sim run from diverging (see Newton safeguard). What remains is
80//! single (MATLAB) vs double (here) precision and FANSI's γ, 42.58 against the crate's 42.576
81//! (the wrappers keep theirs). Fixture: `tests/fansi_qsmci_parity.rs` (relative L2 below 1e-5
82//! on an oblique, anisotropic phantom).
83//!
84//! # Pipeline rules
85//!
86//! The facts above that a configuration can depart from are published as rules in
87//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `fansi.no-laplacian-multi-echo`, `whqsm.flat-weight-te-spacing`, `whqsm.magnitude-weight-te-spacing`.
88
89use crate::inversion::admm::{fansi_unit_ee2, prepare_fansi_kernel, FansiKernel};
90use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
91use crate::utils::{apply_mask_zero, shrink};
92use crate::{EchoTime, Grid};
93use num_complex::Complex64;
94
95/// The data weight (see the module docs).
96#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
98pub enum WhQsmWeight {
99    /// 1 everywhere, no magnitude: `WH_nlTV.m`'s own default when `params.weight` is not
100    /// given (`W = ones(N)`, then squared; lines 70-75). The data term covers the whole
101    /// volume; the mask still confines `x` and the harmonic penalty.
102    #[default]
103    Flat,
104    /// `mask.*mag / max(mask.*mag)`: FANSI's WH-QSM example (`script_DICOM_Philips.m`)
105    /// without its T2*-fit and noise maps. Needs the magnitude.
106    MaxNormalised,
107    /// `min(2*mask*mag/max(mag), 1)`: FANSI's `script_invivo.m`, as NDI uses. Needs the
108    /// magnitude.
109    InVivo,
110}
111
112impl WhQsmWeight {
113    /// Whether this weight is made from the magnitude.
114    pub fn needs_magnitude(self) -> bool {
115        !matches!(self, WhQsmWeight::Flat)
116    }
117}
118
119/// WH-QSM parameters.
120#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
121#[derive(Clone, Debug)]
122pub struct WhQsmParams {
123    /// TV regularization weight.
124    pub alpha1: f64,
125    /// ADMM penalty for the TV splitting (mu).
126    pub mu1: f64,
127    /// ADMM penalty for the data-fidelity splitting.
128    pub mu2: f64,
129    /// Weak-harmonic ROI penalty (constrains the harmonic field inside the mask). Default 150,
130    /// `WH_nlTV.m`'s own default. FANSI's demo (`script_DICOM_Philips.m` l. 432-433) sets
131    /// `beta = 200`, `muh = 5`, as a starting point to "fine tune" ("usually, beta is between 20
132    /// to 50 times larger than muh"); the function's default is kept.
133    pub beta: f64,
134    /// ADMM penalty for the harmonic-field splitting.
135    pub muh: f64,
136    /// Maximum outer iterations.
137    pub max_iter: usize,
138    /// Percent-update stopping tolerance on `x`.
139    pub tol_update: f64,
140    /// Inner Newton stopping tolerance.
141    pub tol_delta: f64,
142    /// Magnitude normalisation for the data weight.
143    pub weight: WhQsmWeight,
144    /// Field strength in Tesla (scan parameter, for the radian conversion).
145    pub b0: f64,
146    /// Echo times in seconds, in acquisition order (scan parameter, for the radian
147    /// conversion).
148    pub echo_times: Vec<f64>,
149    /// Which echo time the radian conversion uses. Default [`EchoTime::First`]. FANSI's
150    /// multi-echo example, `script_DICOM_Philips.m`, scales the field by `deltaTE =
151    /// TE(echo2) - TE(echo1)` (FANSI-toolbox 93044a3, line 364) before `WH_nlTV` (line 436), but
152    /// with a weight from M0 and the fit noise, and its comment on that line says "You may use
153    /// also any individual echo time". With the default flat weight ([`WhQsmWeight::Flat`]), the
154    /// first echo does better: on QSM-CI's simulated head (7 T, echoes 4/12/20/28 ms, its true
155    /// local field and mask; FANSI's kernel, the flat weight over the whole volume, QSM.rs#402)
156    /// χ NRMSE 33.6 % and r 0.945 at the first echo against 42.0 % and 0.914 at the spacing.
157    /// (Before QSM.rs#402, with the continuous kernel and the weight only in the mask, the
158    /// spacing diverged on the qsm-forward phantom, r 0.07, and the first echo gave XSIM 0.754,
159    /// NRMSE 35.5 %.) With a magnitude weight the echo spacing does better than
160    /// the first echo (echo-1 magnitude, [`WhQsmWeight::MaxNormalised`]; qsm-forward phantom,
161    /// integration-test settings, FANSI's kernel, QSM.rs#418; XSIM / NRMSE / r 0.676 / 48.9% /
162    /// 0.876 vs 0.638 / 49.9% / 0.873; with the continuous kernel, #276: XSIM 0.673 vs 0.637).
163    pub te: EchoTime,
164    /// The dipole kernel. Default [`FansiKernel::Discrete`], FANSI's
165    /// `dipole_kernel_angulated` as the original is run with; [`FansiKernel::Continuous`] is
166    /// the crate's continuous kernel (see [`FansiKernel`] for the decision and the measurements).
167    pub kernel: FansiKernel,
168}
169
170impl Default for WhQsmParams {
171    /// `WH_nlTV.m` defaults (mu2 = 1, muh = 5, beta = 150, 150 iterations, tolUpdate 0.1,
172    /// tolDelta 1e-6). `WH_nlTV.m` has no default for alpha1; mu1 is its default 100*alpha1.
173    fn default() -> Self {
174        Self {
175            alpha1: 2e-4,
176            mu1: 2e-2,
177            mu2: 1.0,
178            beta: 150.0,
179            muh: 5.0,
180            max_iter: 150,
181            tol_update: 0.1,
182            tol_delta: 1e-6,
183            weight: WhQsmWeight::Flat,
184            b0: 3.0,
185            echo_times: vec![0.02],
186            te: EchoTime::First,
187            kernel: FansiKernel::Discrete,
188        }
189    }
190}
191
192/// L2 norm of a real vector.
193#[inline]
194fn norm(v: &[f64]) -> f64 {
195    v.iter().map(|&a| a * a).sum::<f64>().sqrt()
196}
197
198/// Weak-Harmonic QSM dipole inversion.
199///
200/// Jointly estimates the susceptibility map and a residual harmonic
201/// background field, returning the susceptibility map (`out.x`).
202///
203/// # Arguments
204/// * `local_field` - Local field in ppm (nx * ny * nz).
205/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI.
206/// * `magnitude` - Magnitude image, for a magnitude data weight ([`WhQsmParams::weight`]);
207///   ignored by the default flat weight, which weights every voxel in the mask by 1.
208/// * `grid` - Volume grid (dimensions and voxel sizes).
209/// * `bdir` - B0 field direction.
210/// * `params` - WH-QSM parameters.
211/// * `progress` - Progress callback `(iteration, max_iter)`.
212///
213/// # Returns
214/// Susceptibility map in ppm.
215///
216/// # Panics
217/// If `params.weight` needs the magnitude and `magnitude` is `None`, or if `params.te` cannot
218/// be resolved from `params.echo_times`.
219pub fn whqsm(
220    local_field: &[f64],
221    mask: &[u8],
222    magnitude: Option<&[f64]>,
223    grid: &Grid,
224    bdir: (f64, f64, f64),
225    params: &WhQsmParams,
226    mut progress: impl FnMut(usize, usize),
227) -> Vec<f64> {
228    let n = grid.n_total();
229
230    let alpha = params.alpha1;
231    let mu = params.mu1;
232    let mu2 = params.mu2;
233    let beta = params.beta;
234    let muh = params.muh;
235    let alpha_over_mu = alpha / mu;
236
237    // Spectral operators: k = real dipole kernel D(k) (DC=0), ee2 = FANSI's EE2. WH_nlTV.m's
238    // E1..E3 are unit-spacing differences whatever the voxel size, so the gradient and
239    // divergence below run on a unit-voxel grid too; only the dipole kernel uses the voxel size.
240    let (mut fft_ws, k) = prepare_fansi_kernel(grid, bdir, params.kernel);
241    let ee2 = fansi_unit_ee2(grid.dims);
242    let ugrid = Grid::new(grid.nx(), grid.ny(), grid.nz(), 1.0, 1.0, 1.0);
243
244    let mask_f: Vec<f64> = mask.iter().map(|&m| if m != 0 { 1.0 } else { 0.0 }).collect();
245
246    // W = weight.^2 (WH_nlTV.m line 75), weight as `params.weight` says: ones over the whole
247    // volume (WH_nlTV.m's default), or from the magnitude (zero outside the mask).
248    assert!(
249        !params.weight.needs_magnitude() || magnitude.is_some(),
250        "WH-QSM: the {:?} weight needs the magnitude", params.weight
251    );
252    let magnitude = if params.weight.needs_magnitude() { magnitude } else { None };
253    let mag_max = magnitude.map_or(0.0, |m| match params.weight {
254        WhQsmWeight::Flat => 0.0,
255        WhQsmWeight::MaxNormalised => (0..n)
256            .filter(|&i| mask[i] != 0)
257            .map(|i| m[i])
258            .fold(0.0f64, f64::max),
259        WhQsmWeight::InVivo => m.iter().cloned().fold(0.0f64, f64::max),
260    });
261    let w: Vec<f64> = (0..n)
262        .map(|i| {
263            if params.weight == WhQsmWeight::Flat {
264                return 1.0;
265            }
266            if mask[i] == 0 {
267                return 0.0;
268            }
269            let v = match magnitude {
270                Some(m) if mag_max > 0.0 => match params.weight {
271                    WhQsmWeight::Flat => 1.0,
272                    WhQsmWeight::MaxNormalised => m[i] / mag_max,
273                    WhQsmWeight::InVivo => (2.0 * m[i] / mag_max).min(1.0),
274                },
275                _ => 1.0,
276            };
277            v * v
278        })
279        .collect();
280
281    // ppm -> radians, as WH_nlTV.m expects.
282    // FANSI's scripts use `gyro = 2*pi*42.58`; QSM.rs uses GAMMA_BAR_MHZ_PER_T, which is closer
283    // to the CODATA shielded-proton value (see `crate::constants`).
284    let ppm_to_rad = 2.0 * std::f64::consts::PI * crate::constants::GAMMA_BAR_MHZ_PER_T * params.b0 * params.te.seconds(&params.echo_times, "WH-QSM");
285    let phase: Vec<f64> = local_field.iter().map(|&f| f * ppm_to_rad).collect();
286
287    // Denominator for x-subproblem: 1e-30 + mu2*k^2 + mu*ee2
288    let x_denom: Vec<f64> = (0..n)
289        .map(|i| 1e-30 + mu2 * k[i] * k[i] + mu * ee2[i])
290        .collect();
291    // Denominator for phi_h subproblem: eps + mu2 + muh*ee2^2
292    let phi_denom: Vec<f64> = (0..n)
293        .map(|i| 1e-30 + mu2 + muh * ee2[i] * ee2[i])
294        .collect();
295
296    // State variables.
297    let mut x = vec![0.0f64; n];
298    let mut z_dx = vec![0.0f64; n];
299    let mut z_dy = vec![0.0f64; n];
300    let mut z_dz = vec![0.0f64; n];
301    let mut s_dx = vec![0.0f64; n];
302    let mut s_dy = vec![0.0f64; n];
303    let mut s_dz = vec![0.0f64; n];
304
305    let mut phi_h = vec![0.0f64; n];
306    let mut z_h = vec![0.0f64; n];
307    let mut s_h = vec![0.0f64; n];
308
309    // z2 = phase .* W ; s2 = 0
310    let mut z2: Vec<f64> = (0..n).map(|i| phase[i] * w[i]).collect();
311    let mut s2 = vec![0.0f64; n];
312
313    // Scratch buffers.
314    let mut cbuf = vec![Complex64::new(0.0, 0.0); n]; // general complex scratch
315    let mut cbuf2 = vec![Complex64::new(0.0, 0.0); n]; // second complex scratch
316    let mut fx = vec![Complex64::new(0.0, 0.0); n]; // Fx = fft(x)
317    let mut real_scratch = vec![0.0f64; n];
318    let mut gx = vec![0.0f64; n];
319    let mut gy = vec![0.0f64; n];
320    let mut gz = vec![0.0f64; n];
321    let mut dg = vec![0.0f64; n]; // divergence buffer
322    let mut dx_real = vec![0.0f64; n]; // Dx = real(ifft(k .* Fx))
323    let mut ee2_phi = vec![0.0f64; n]; // real(ifft(EE2 .* Fphi_h))
324
325    for t in 0..params.max_iter {
326        progress(t + 1, params.max_iter);
327
328        // --- x-subproblem (masked) ---
329        // numerator k-space = mu * fft(bdiv(z_d - s_d)) + K .* fft(z2 - s2 - phi_h)
330        // x = mask .* real(ifft( numerator ./ x_denom ))
331
332        // bdiv(z_d - s_d) in real space, then fft.
333        for i in 0..n {
334            gx[i] = z_dx[i] - s_dx[i];
335            gy[i] = z_dy[i] - s_dy[i];
336            gz[i] = z_dz[i] - s_dz[i];
337        }
338        bdiv_inplace(&mut dg, &gx, &gy, &gz, &ugrid);
339        for i in 0..n {
340            cbuf[i] = Complex64::new(dg[i], 0.0);
341        }
342        fft_ws.fft3d(&mut cbuf); // cbuf = fft(bdiv(z_d - s_d))
343
344        // Dt_kspace source in real space = z2 - s2 - phi_h ; fft it.
345        for i in 0..n {
346            fx[i] = Complex64::new(z2[i] - s2[i] - phi_h[i], 0.0);
347        }
348        fft_ws.fft3d(&mut fx); // fx = fft(z2 - s2 - phi_h)
349
350        // numerator ./ x_denom  (k real -> K.* = k[i] * fx[i]; conj(K)=K since real)
351        for i in 0..n {
352            // Minus on the gradient term: adjoint of crate `fgrad` is `-bdiv` (matches
353            // QSM.rs TV-ADMM). `+bdiv` doubles the effective regularization. See fansi.rs.
354            let num = -mu * cbuf[i] + k[i] * fx[i];
355            // Guard the dipole null-space (DC/singular bins): x_denom -> ~0 there
356            // (both dipole kernel and Laplacian vanish). Zero it instead of
357            // dividing FFT round-off by ~1e-30, which blows up and (via the
358            // harmonic-field coupling) diverges the whole solve.
359            cbuf[i] = if x_denom[i] > 1e-20 { num / x_denom[i] } else { Complex64::new(0.0, 0.0) };
360        }
361        fft_ws.ifft3d(&mut cbuf);
362
363        // Compute x_update = 100 * norm(x - x_prev) / norm(x) after updating.
364        // Keep old x in real_scratch for the diff.
365        real_scratch.copy_from_slice(&x);
366        for i in 0..n {
367            x[i] = mask_f[i] * cbuf[i].re;
368        }
369        // x_update.
370        let mut diff_norm = 0.0;
371        for i in 0..n {
372            let d = x[i] - real_scratch[i];
373            diff_norm += d * d;
374        }
375        let diff_norm = diff_norm.sqrt();
376        // As in WH_nlTV.m: 0/0 is NaN when x is all zero, and NaN < tol is false.
377        let x_update = 100.0 * diff_norm / norm(&x);
378        if x_update < params.tol_update {
379            progress(t + 1, t + 1);
380            break;
381        }
382
383        if t + 1 < params.max_iter {
384            // Fx = fft(x)
385            for i in 0..n {
386                fx[i] = Complex64::new(x[i], 0.0);
387            }
388            fft_ws.fft3d(&mut fx);
389
390            // Forward gradient of x (== [real(ifft(E1.*Fx)), ...]).
391            fgrad_inplace(&mut gx, &mut gy, &mut gz, &x, &ugrid);
392
393            // z_d = shrink(grad + s_d, alpha_over_mu) ; s_d += grad - z_d.
394            for i in 0..n {
395                let ax = gx[i] + s_dx[i];
396                let ay = gy[i] + s_dy[i];
397                let az = gz[i] + s_dz[i];
398                z_dx[i] = shrink(ax, alpha_over_mu);
399                z_dy[i] = shrink(ay, alpha_over_mu);
400                z_dz[i] = shrink(az, alpha_over_mu);
401                s_dx[i] += gx[i] - z_dx[i];
402                s_dy[i] += gy[i] - z_dy[i];
403                s_dz[i] += gz[i] - z_dz[i];
404            }
405
406            // Dx = real(ifft(K .* Fx))
407            for i in 0..n {
408                cbuf[i] = k[i] * fx[i];
409            }
410            fft_ws.ifft3d(&mut cbuf);
411            for i in 0..n {
412                dx_real[i] = cbuf[i].re;
413            }
414
415            // rhs_z2 = mu2 * (Dx + s2 + phi_h) ; z2 = rhs_z2 / mu2 = Dx + s2 + phi_h.
416            // Store rhs_z2 in real_scratch.
417            for i in 0..n {
418                real_scratch[i] = mu2 * (dx_real[i] + s2[i] + phi_h[i]);
419                z2[i] = real_scratch[i] / mu2;
420            }
421
422            // Newton iteration on z2 (nonlinear data term).
423            let mut delta = f64::INFINITY;
424            let mut inn = 0;
425            while delta > params.tol_delta && inn < 50 {
426                inn += 1;
427                let norm_old = norm(&z2);
428                // update = (W.*sin(z2-phase) + mu2*z2 - rhs_z2) ./ (W.*cos(z2-phase) + mu2)
429                let mut upd_norm2 = 0.0;
430                for i in 0..n {
431                    let dphi = z2[i] - phase[i];
432                    let numr = w[i] * dphi.sin() + mu2 * z2[i] - real_scratch[i];
433                    let denr = w[i] * dphi.cos() + mu2;
434                    // Safeguard (not in WH_nlTV.m): keep the iterate in the interval that holds
435                    // the minimiser, |z2 - rhs/mu2| <= W/mu2 (from W*sin(z2-phase) +
436                    // mu2*(z2 - rhs/mu2) = 0). See "Newton safeguard" in the module docs.
437                    let r = real_scratch[i] / mu2;
438                    let b = w[i] / mu2;
439                    let z_new = (z2[i] - numr / denr).clamp(r - b, r + b);
440                    let u = z2[i] - z_new;
441                    z2[i] = z_new;
442                    upd_norm2 += u * u;
443                }
444                let upd_norm = upd_norm2.sqrt();
445                delta = if norm_old > 0.0 {
446                    upd_norm / norm_old
447                } else {
448                    // guard: if z2 became zero, stop.
449                    0.0
450                };
451            }
452
453            // --- Harmonic field update (phi_h) ---
454            // Fphi_h = ( muh*EE2.*fft(z_h - s_h) + mu2*fft(z2 - s2) - mu2*K.*Fx )
455            //          ./ (eps + mu2 + muh*EE2.^2)
456            // fft(z_h - s_h)
457            for i in 0..n {
458                cbuf[i] = Complex64::new(z_h[i] - s_h[i], 0.0);
459            }
460            fft_ws.fft3d(&mut cbuf); // cbuf = fft(z_h - s_h)
461
462            // fft(z2 - s2)  -> reuse a fresh complex buffer via dx_real? Need another buffer.
463            // Use `phi_h`-sized complex scratch: allocate once via a persistent buffer.
464            // We reuse `cbuf` after combining; but we need fft(z2 - s2) simultaneously.
465            // Compute fft(z2 - s2) into a second complex buffer.
466            // (fx currently holds Fx; we still need Fx for the -mu2*K.*Fx term.)
467            for i in 0..n {
468                cbuf2[i] = Complex64::new(z2[i] - s2[i], 0.0);
469            }
470            fft_ws.fft3d(&mut cbuf2); // cbuf2 = fft(z2 - s2)
471
472            // Fphi_h (store into cbuf).
473            for i in 0..n {
474                let numer =
475                    muh * ee2[i] * cbuf[i] + mu2 * cbuf2[i] - mu2 * (k[i] * fx[i]);
476                cbuf[i] = numer / phi_denom[i];
477            }
478            // cbuf now = Fphi_h.
479
480            // EE2_phi = real(ifft(EE2 .* Fphi_h)) : first compute with EE2 multiply.
481            for i in 0..n {
482                cbuf2[i] = ee2[i] * cbuf[i];
483            }
484            fft_ws.ifft3d(&mut cbuf2);
485            for i in 0..n {
486                ee2_phi[i] = cbuf2[i].re;
487            }
488
489            // phi_h = real(ifft(Fphi_h))
490            fft_ws.ifft3d(&mut cbuf);
491            for i in 0..n {
492                phi_h[i] = cbuf[i].re;
493            }
494
495            // z_h = muh*(EE2_phi + s_h) ./ (muh + beta*mask)   (mask 0/1)
496            for i in 0..n {
497                z_h[i] = muh * (ee2_phi[i] + s_h[i]) / (muh + beta * mask_f[i]);
498            }
499
500            // --- dual updates ---
501            // s2 = s2 + real(ifft(K.*Fx)) - z2 + phi_h  (= s2 + Dx - z2 + phi_h)
502            for i in 0..n {
503                s2[i] = s2[i] + dx_real[i] - z2[i] + phi_h[i];
504            }
505            // s_h = s_h + EE2_phi - z_h
506            for i in 0..n {
507                s_h[i] = s_h[i] + ee2_phi[i] - z_h[i];
508            }
509        }
510    }
511
512    // Back to ppm.
513    for v in x.iter_mut() {
514        *v /= ppm_to_rad;
515    }
516    apply_mask_zero(&mut x, mask);
517
518    x
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_whqsm_zero_field() {
527        // Zero field should give (near) zero susceptibility.
528        let n = 8;
529        let field = vec![0.0; n * n * n];
530        let mask = vec![1u8; n * n * n];
531        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
532        let params = WhQsmParams {
533            max_iter: 15,
534            ..Default::default()
535        };
536
537        let chi = whqsm(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
538
539        for &val in chi.iter() {
540            assert!(
541                val.abs() < 1e-6,
542                "Zero field should give ~zero chi, got {}",
543                val
544            );
545        }
546    }
547
548    #[test]
549    fn test_whqsm_finite() {
550        // Small ramp field -> all outputs finite.
551        let n = 8;
552        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
553        let mask = vec![1u8; n * n * n];
554        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
555        let params = WhQsmParams {
556            max_iter: 15,
557            ..Default::default()
558        };
559
560        let chi = whqsm(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
561
562        for (i, &val) in chi.iter().enumerate() {
563            assert!(val.is_finite(), "Chi should be finite at index {}", i);
564        }
565    }
566
567    #[test]
568    fn test_whqsm_uniform_magnitude_is_mask_weight() {
569        // A uniform magnitude normalises to 1 either way, the same weight as the flat default.
570        let n = 8;
571        let field: Vec<f64> = (0..n * n * n).map(|i| ((i % 7) as f64 - 3.0) * 0.01).collect();
572        let mask = vec![1u8; n * n * n];
573        let mag = vec![3.0; n * n * n];
574        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
575        let params = WhQsmParams { max_iter: 10, ..Default::default() };
576
577        let a = whqsm(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
578        for weight in [WhQsmWeight::MaxNormalised, WhQsmWeight::InVivo] {
579            let p = WhQsmParams { weight, ..params.clone() };
580            let b = whqsm(&field, &mask, Some(&mag), &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
581            for (x, y) in a.iter().zip(&b) {
582                assert!((x - y).abs() < 1e-12);
583            }
584        }
585        // the flat default ignores a magnitude it is given
586        let mag_varied: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % 5) as f64).collect();
587        let c = whqsm(&field, &mask, Some(&mag_varied), &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
588        assert_eq!(a, c);
589    }
590
591    #[test]
592    #[should_panic(expected = "needs the magnitude")]
593    fn test_whqsm_magnitude_weight_needs_magnitude() {
594        let n = 4;
595        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
596        let p = WhQsmParams { weight: WhQsmWeight::MaxNormalised, max_iter: 1, ..Default::default() };
597        whqsm(&[0.0; 64], &[1u8; 64], None, &grid, (0.0, 0.0, 1.0), &p, |_, _| {});
598    }
599}