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(¶ms.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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
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), ¶ms, |_, _| {});
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}