Skip to main content

qsm_core/inversion/
hdqsm.rs

1//! HD-QSM: Hybrid data-fidelity two-stage linear dipole inversion.
2//!
3//! A linear dipole-inversion method that runs in two stages. Stage 1 solves an
4//! L1 data-fidelity problem (robust to phase/model errors) and derives a
5//! spatially-varying discrepancy weighting. Stage 2 solves an L2 data-fidelity
6//! problem reweighted by that discrepancy map. Both stages use ADMM with an
7//! L1 total-variation regularizer.
8//!
9//! # Data weight
10//! `w` is the magnitude normalised as in HDQSM.m's usage example,
11//! `mask.*mag/max(mag)`, or the mask when there is no magnitude (HDQSM.m: "Not used if not
12//! specified"). The magnitude is the paper's Eq. 2 echo combination,
13//! `sum(TE_i*mag_i^2) / sum(TE_i*mag_i)` ([`hdqsm_magnitude`]), which the QSM-CI original
14//! (HD-QSM c456a94) uses too; the pipeline makes it from every echo's magnitude and requires
15//! them (QSM.rs#403: QSMxT gave the port the root sum of squares, part of a 9.1 % / 7.1 % gap
16//! on sim / KKI).
17//!
18//! # Input
19//! The field is masked first, as in HDQSM.m's usage example (`params.input =
20//! mask.*phase_use/phase_scale`) and the QSM-CI original. It matters where a local field is
21//! non-zero outside the mask (stage 1's L1 term and the stage-2 discrepancy see it).
22//!
23//! # Dipole kernel
24//! HDQSM.m takes the kernel as an input and ships none; it is "based on" FANSI, and the
25//! original is run with FANSI's `dipole_kernel_angulated` (mode 1, discrete). That is the
26//! default here ([`HdQsmParams::kernel`], [`FansiKernel`] has the decision and the
27//! measurements); the continuous kernel is an option.
28//!
29//! # Finite differences
30//! As in HDQSM.m, the gradients are unit-spacing forward differences whatever
31//! the voxel size; only the dipole kernel uses the voxel size.
32//!
33//! # Units
34//! Like the other inversions, HD-QSM takes the local field in ppm and returns
35//! ppm. HDQSM.m's usage example passes `phase/phase_scale`, and the authors'
36//! scripts on the 2019 challenge data divide the frequency map by
37//! `gamma*B0`, i.e. they work in ppm, so no conversion is made. Stage 2 is not
38//! scale invariant, so `alpha_l2` is a value for fields in ppm.
39//!
40//! Reference:
41//! Lambert, M., Tejos, C., Langkammer, C., Milovic, C. (2022).
42//! "Hybrid data fidelity term approach for quantitative susceptibility mapping."
43//! Magnetic Resonance in Medicine, 88(2):962-972.
44//! https://doi.org/10.1002/mrm.29218
45//!
46//! Reference implementation: HDQSM.m, https://github.com/mglambert/HD-QSM (MIT).
47//!
48//! # Reference pipeline
49//! Lambert et al. 2022, Section 3.3 (in vivo): Siemens 3T, 6 echoes, TE1 = spacing = 4.92 ms,
50//! 0.8x0.8x2 mm; SEGUE unwrapping, PDF background removal, a magnitude-weighted least-squares
51//! echo fit for the local field, and the residual background removed with WH-QSM's harmonic
52//! estimate. For the 2019-challenge simulations the field was the magnitude-weighted
53//! least-squares fit, zero-padded to 256^3, with 300 iterations (Section 3.2); this port does not
54//! pad. HDQSM.m's header says `tol_update` defaults to 1.0, its code to 1e-2 (L68-72); the code
55//! value is used here.
56//!
57//! # Parity
58//! Against the verified original (QSM-CI pair `hdqsm`; FANSI-toolbox 93044a3 / HD-QSM c456a94, identical to
59//! licensed MATLAB R2023b), final-map χ NRMSE at both sides' defaults on QSM-CI's simulated head
60//! and the KKI travelling head (QSM-CI parity run 41403629314, QSM.rs#399–#403): sim 0.0015 %, KKI 0.00086 %, from
61//! 9.1 % / 7.1 % (sim / KKI) before. Three causes: the kernel, the Eq. 2 magnitude (QSMxT gave the root sum of squares) and the masked input. What remains is
62//! single (MATLAB) vs double (here) precision (HD-QSM works in ppm, so no γ). Fixture: `tests/fansi_qsmci_parity.rs` (relative L2 below 1e-5
63//! on an oblique, anisotropic phantom).
64//!
65//! # Pipeline rules
66//!
67//! The facts above that a configuration can depart from are published as rules in
68//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `hdqsm.bg-pdf`.
69
70use num_complex::Complex64;
71use crate::inversion::admm::{fansi_unit_ee2, prepare_fansi_kernel, FansiKernel};
72use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
73use crate::utils::{apply_mask_zero, shrink};
74use crate::Grid;
75
76/// HD-QSM algorithm parameters.
77#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
78#[derive(Clone, Debug)]
79pub struct HdQsmParams {
80    /// L2-stage TV weight, for a field in ppm and the magnitude weight (default 3e-5).
81    ///
82    /// HDQSM.m requires it and gives no default; its example uses 10^-4.785 (1.6e-5) and the
83    /// paper's 2019-challenge reconstructions 6.3e-6, both on simulated data. 3e-5 was chosen
84    /// from a sweep (1e-6 to 1e-4) on the 2016 challenge (COSMOS reference: best correlation,
85    /// 0.76, NRMSE 73%), the 2019-style simulation (NRMSE 39%) and ten travelling-head scans
86    /// (stable on all of them, with less noise than the mask-weighted 1e-4 the port used).
87    pub alpha_l2: f64,
88    /// L2-stage gradient-consistency ADMM weight; `None` is `100 * alpha_l2`.
89    ///
90    /// The paper ties it to alpha_l2 with 10 <= mu1_l2/alpha_l2 <= 100; HDQSM.m's example and
91    /// the paper's challenge reconstructions use 10. On in vivo travelling-head scans the ADMM
92    /// diverges (in HDQSM.m as well) once mu1_l2 falls to about 3e-4 or below, which a ratio of
93    /// 10 reaches at the alpha_l2 that suits in vivo data, so the default uses the paper's 100.
94    pub mu1_l2: Option<f64>,
95    /// Fidelity consistency weight (HDQSM.m `mu2L1`, default 1.0).
96    pub mu2: f64,
97    /// Stage-1 (L1) iterations (HDQSM.m `maxOuterIterL1`, default 20).
98    pub max_iter_l1: usize,
99    /// Stage-2 (L2) iterations (HDQSM.m `maxOuterIterL2`, default 80).
100    pub max_iter_l2: usize,
101    /// Stage-2 percent-update stopping tolerance (HDQSM.m `tol_update`, default 1e-2).
102    pub tol_update: f64,
103    /// The dipole kernel. Default [`FansiKernel::Discrete`], FANSI's
104    /// `dipole_kernel_angulated` as the QSM-CI original is run with (HDQSM.m ships no kernel); [`FansiKernel::Continuous`] is
105    /// the crate's continuous kernel (see [`FansiKernel`] for the decision and the measurements).
106    pub kernel: FansiKernel,
107    // Stage-1 alpha_l1 / mu1_l1 are sqrt(alpha_l2) / sqrt(mu1_l2), as in HDQSM.m.
108}
109
110impl Default for HdQsmParams {
111    fn default() -> Self {
112        Self {
113            alpha_l2: 3e-5,
114            mu1_l2: None,
115            mu2: 1.0,
116            max_iter_l1: 20,
117            max_iter_l2: 80,
118            tol_update: 1e-2,
119            kernel: FansiKernel::Discrete,
120        }
121    }
122}
123
124/// HD-QSM's magnitude, the paper's Eq. 2 echo combination (Lambert et al. 2022), as the
125/// QSM-CI original computes it: `mag_use = sum(TE.*mag.^2, 4) ./ sum(TE.*mag, 4)`, with
126/// non-finite values (no signal in any echo) set to 0.
127///
128/// `echoes` holds every echo's magnitude stacked with the echo slowest (`echo_times.len()`
129/// volumes); the result is one volume.
130///
131/// # Panics
132/// If `echoes.len()` is not a multiple of `echo_times.len()`, or there are no echo times.
133pub fn hdqsm_magnitude(echoes: &[f64], echo_times: &[f64]) -> Vec<f64> {
134    let ne = echo_times.len();
135    assert!(ne > 0 && echoes.len().is_multiple_of(ne), "HD-QSM: {} magnitude values for {ne} echoes", echoes.len());
136    let n = echoes.len() / ne;
137    (0..n)
138        .map(|i| {
139            let (mut num, mut den) = (0.0, 0.0);
140            for (e, &te) in echo_times.iter().enumerate() {
141                let m = echoes[e * n + i];
142                num += te * m * m;
143                den += te * m;
144            }
145            let v = num / den;
146            if v.is_finite() { v } else { 0.0 }
147        })
148        .collect()
149}
150
151/// L2 norm of a slice.
152#[inline]
153fn norm2(v: &[f64]) -> f64 {
154    v.iter().map(|&x| x * x).sum::<f64>().sqrt()
155}
156
157/// Percent update `100 * ||x - x_prev|| / ||x||`.
158#[inline]
159fn percent_update(x: &[f64], x_prev: &[f64]) -> f64 {
160    let diff: f64 = x.iter().zip(x_prev).map(|(&a, &b)| (a - b) * (a - b)).sum::<f64>().sqrt();
161    let nx = norm2(x);
162    if nx > 0.0 { 100.0 * diff / nx } else { 0.0 }
163}
164
165/// Forward dipole convolution: `Dx = real(ifft(kernel .* fft(x)))`.
166///
167/// `cbuf` is scratch of length n_total; result written to `out`.
168fn apply_dipole(
169    fft_ws: &mut crate::fft::Fft3dWorkspace,
170    k: &[f64],
171    x: &[f64],
172    cbuf: &mut [Complex64],
173    out: &mut [f64],
174) {
175    for (c, &xv) in cbuf.iter_mut().zip(x.iter()) {
176        *c = Complex64::new(xv, 0.0);
177    }
178    fft_ws.fft3d(cbuf);
179    for (c, &kv) in cbuf.iter_mut().zip(k.iter()) {
180        *c *= kv;
181    }
182    fft_ws.ifft3d(cbuf);
183    for (o, c) in out.iter_mut().zip(cbuf.iter()) {
184        *o = c.re;
185    }
186}
187
188/// HD-QSM dipole inversion.
189///
190/// # Arguments
191/// * `local_field` - Local field values in ppm (nx * ny * nz).
192/// * `mask` - Binary ROI mask (nx * ny * nz), 1 = inside.
193/// * `magnitude` - Magnitude image, the data weight (see module docs; the pipeline passes
194///   [`hdqsm_magnitude`]); `None` weights every voxel in the mask by 1
195/// * `grid` - Volume grid (dimensions and voxel sizes).
196/// * `bdir` - B0 field direction.
197/// * `params` - HD-QSM parameters.
198/// * `progress` - Progress callback `(global_iter, max_iter_l1 + max_iter_l2)`.
199///
200/// # Returns
201/// Susceptibility map in ppm.
202pub fn hdqsm(
203    local_field: &[f64],
204    mask: &[u8],
205    magnitude: Option<&[f64]>,
206    grid: &Grid,
207    bdir: (f64, f64, f64),
208    params: &HdQsmParams,
209    mut progress: impl FnMut(usize, usize),
210) -> Vec<f64> {
211    let n = grid.n_total();
212    let mu1_l2 = params.mu1_l2.unwrap_or(100.0 * params.alpha_l2);
213    let (mut fft_ws, k) = prepare_fansi_kernel(grid, bdir, params.kernel);
214    // HDQSM.m's finite differences are on unit spacing whatever the voxel size
215    // (E_j = 1 - exp(2*pi*i*k_j/N_j)); the dipole kernel keeps the voxel size.
216    let ee2 = fansi_unit_ee2(grid.dims);
217    let ugrid = Grid::new(grid.nx(), grid.ny(), grid.nz(), 1.0, 1.0, 1.0);
218    // K2 = abs(kernel).^2 = k^2 (kernel is real). mu*EE2 is added per stage.
219    let denom_base: Vec<f64> = k.iter()
220        .map(|&kk| 1e-30 + params.mu2 * kk * kk)
221        .collect();
222
223    // weight = mask.*(mag/max(mag(:))) (HDQSM.m usage example); the mask when there is no
224    // magnitude.
225    let mag_max = magnitude.map_or(0.0, |m| m.iter().cloned().fold(0.0f64, f64::max));
226    let weight_mask: Vec<f64> = (0..n)
227        .map(|i| {
228            if mask[i] == 0 {
229                return 0.0;
230            }
231            match magnitude {
232                Some(m) if mag_max > 0.0 => m[i] / mag_max,
233                _ => 1.0,
234            }
235        })
236        .collect();
237
238    // input = mask.*field (HDQSM.m usage example).
239    let masked_field: Vec<f64> = (0..n).map(|i| if mask[i] != 0 { local_field[i] } else { 0.0 }).collect();
240    let local_field = &masked_field[..];
241
242    let total_iter = params.max_iter_l1 + params.max_iter_l2;
243    let mut global_iter = 0usize;
244
245    // Working buffers.
246    let mut x = vec![0.0f64; n];
247    let mut x_prev = vec![0.0f64; n];
248
249    let mut z_dx = vec![0.0f64; n];
250    let mut z_dy = vec![0.0f64; n];
251    let mut z_dz = vec![0.0f64; n];
252    let mut s_dx = vec![0.0f64; n];
253    let mut s_dy = vec![0.0f64; n];
254    let mut s_dz = vec![0.0f64; n];
255    let mut x_dx = vec![0.0f64; n];
256    let mut x_dy = vec![0.0f64; n];
257    let mut x_dz = vec![0.0f64; n];
258
259    let mut z2 = vec![0.0f64; n];
260    let mut s2 = vec![0.0f64; n];
261    let mut dx = vec![0.0f64; n]; // Dx = real(ifft(K.*Fx))
262
263    let mut div = vec![0.0f64; n];
264    let mut gx = vec![0.0f64; n];
265    let mut gy = vec![0.0f64; n];
266    let mut gz = vec![0.0f64; n];
267
268    let mut cbuf = vec![Complex64::new(0.0, 0.0); n];
269    let mut fdiv = vec![Complex64::new(0.0, 0.0); n];
270    let mut fdt = vec![Complex64::new(0.0, 0.0); n];
271
272    // ---- Helper closures cannot capture &mut fft_ws + buffers simultaneously,
273    //      so the x-subproblem is inlined below. ----
274
275    // =========================================================================
276    // STAGE 1: L1 data fidelity.
277    // =========================================================================
278    {
279        let mu = mu1_l2.sqrt();
280        let alpha = params.alpha_l2.sqrt();
281        let mu2 = params.mu2;
282        let ll = alpha / mu;
283
284        // Denominator for stage 1: eps + mu2*K2 + mu*EE2.
285        let denom: Vec<f64> = (0..n).map(|i| denom_base[i] + mu * ee2[i]).collect();
286
287        // Wy = input.
288        let wy = local_field;
289
290        for t in 0..params.max_iter_l1 {
291            x_prev.copy_from_slice(&x);
292
293            // Dt_kspace_arg = z2 - s2 + Wy.
294            for i in 0..n {
295                gx[i] = z2[i] - s2[i] + wy[i]; // reuse gx as dt_arg scratch
296            }
297            // fft(dt_arg) -> fdt
298            for (c, &v) in fdt.iter_mut().zip(gx.iter()) {
299                *c = Complex64::new(v, 0.0);
300            }
301            fft_ws.fft3d(&mut fdt);
302
303            // fft(bdiv(z_d - s_d)) -> fdiv
304            for i in 0..n {
305                gx[i] = z_dx[i] - s_dx[i];
306                gy[i] = z_dy[i] - s_dy[i];
307                gz[i] = z_dz[i] - s_dz[i];
308            }
309            bdiv_inplace(&mut div, &gx, &gy, &gz, &ugrid);
310            for (c, &v) in fdiv.iter_mut().zip(div.iter()) {
311                *c = Complex64::new(v, 0.0);
312            }
313            fft_ws.fft3d(&mut fdiv);
314
315            // num = mu*fdiv + mu2*conj(K)*fdt  (K real -> conj(K)=K); divide by denom.
316            // Guard the dipole null-space (DC/singular bins): denom -> ~0 there
317            // (dipole kernel and Laplacian both vanish). Zero it instead of
318            // dividing FFT round-off by ~0, which would create a huge DC pedestal.
319            for i in 0..n {
320                // Minus on the gradient term: adjoint of crate `fgrad` is `-bdiv` (matches
321                // QSM.rs TV-ADMM). `+bdiv` doubles the effective regularization. See fansi.rs.
322                let num = -fdiv[i] * mu + fdt[i] * (mu2 * k[i]);
323                cbuf[i] = if denom[i] > 1e-20 { num / denom[i] } else { Complex64::new(0.0, 0.0) };
324            }
325            fft_ws.ifft3d(&mut cbuf);
326            for i in 0..n {
327                x[i] = cbuf[i].re;
328            }
329
330            global_iter += 1;
331            progress(global_iter, total_iter);
332
333            if t < params.max_iter_l1 - 1 {
334                // fgrad(x) -> x_d
335                fgrad_inplace(&mut x_dx, &mut x_dy, &mut x_dz, &x, &ugrid);
336                // z_d = shrink(x_d + s_d, ll); s_d += x_d - z_d.
337                for i in 0..n {
338                    let ax = x_dx[i] + s_dx[i];
339                    let ay = x_dy[i] + s_dy[i];
340                    let az = x_dz[i] + s_dz[i];
341                    z_dx[i] = shrink(ax, ll);
342                    z_dy[i] = shrink(ay, ll);
343                    z_dz[i] = shrink(az, ll);
344                    s_dx[i] += x_dx[i] - z_dx[i];
345                    s_dy[i] += x_dy[i] - z_dy[i];
346                    s_dz[i] += x_dz[i] - z_dz[i];
347                }
348
349                // Dx = real(ifft(K.*Fx))
350                apply_dipole(&mut fft_ws, &k, &x, &mut cbuf, &mut dx);
351                // z2_inner = Dx + s2 - Wy; z2 = shrink(z2_inner, weight/mu2); s2 = z2_inner - z2.
352                for i in 0..n {
353                    let z2_inner = dx[i] + s2[i] - wy[i];
354                    z2[i] = shrink(z2_inner, weight_mask[i] / mu2);
355                    s2[i] = z2_inner - z2[i];
356                }
357            }
358        }
359    }
360
361    // Discrepancy factor: dphi = ifft(fft(input) - fft(x).*K); dphi = abs(dphi).*mask; dphi /= max.
362    // = |input - Dx| .* mask, normalized.  (fft(input) - K.*fft(x) -> ifft = input - Dx)
363    let mut dphi = vec![0.0f64; n];
364    {
365        // Dx = real(ifft(K.*Fx)) with current x.
366        apply_dipole(&mut fft_ws, &k, &x, &mut cbuf, &mut dx);
367        let mut maxv = 0.0f64;
368        for i in 0..n {
369            let v = (local_field[i] - dx[i]).abs() * (if mask[i] != 0 { 1.0 } else { 0.0 });
370            dphi[i] = v;
371            if v > maxv {
372                maxv = v;
373            }
374        }
375        if maxv > 0.0 {
376            for v in dphi.iter_mut() {
377                *v /= maxv;
378            }
379        }
380    }
381
382    // =========================================================================
383    // STAGE 2: L2 data fidelity, reweighted by discrepancy.
384    // =========================================================================
385    {
386        let mu = mu1_l2;
387        let alpha = params.alpha_l2;
388        let mu2 = params.mu2;
389        let ll = alpha / mu;
390
391        // Denominator for stage 2: eps + mu2*K2 + mu*EE2.
392        let denom: Vec<f64> = (0..n).map(|i| denom_base[i] + mu * ee2[i]).collect();
393
394        // weight = weight.*weight.*(1-dphi).*(1-dphi).
395        let weight: Vec<f64> = (0..n).map(|i| {
396            let w = weight_mask[i];
397            let d = 1.0 - dphi[i];
398            w * w * d * d
399        }).collect();
400        // Wy = weight.*input./(weight+mu2).
401        let wy: Vec<f64> = (0..n).map(|i| weight[i] * local_field[i] / (weight[i] + mu2)).collect();
402
403        // Reset dual/aux for stage 2 (x carried over).
404        for i in 0..n {
405            z_dx[i] = 0.0; z_dy[i] = 0.0; z_dz[i] = 0.0;
406            s_dx[i] = 0.0; s_dy[i] = 0.0; s_dz[i] = 0.0;
407            s2[i] = 0.0;
408        }
409
410        for _t in 0..params.max_iter_l2 {
411            x_prev.copy_from_slice(&x);
412
413            // z/s updates BEFORE the x-update (unlike stage 1).
414            fgrad_inplace(&mut x_dx, &mut x_dy, &mut x_dz, &x, &ugrid);
415            for i in 0..n {
416                let ax = x_dx[i] + s_dx[i];
417                let ay = x_dy[i] + s_dy[i];
418                let az = x_dz[i] + s_dz[i];
419                z_dx[i] = shrink(ax, ll);
420                z_dy[i] = shrink(ay, ll);
421                z_dz[i] = shrink(az, ll);
422                s_dx[i] += x_dx[i] - z_dx[i];
423                s_dy[i] += x_dy[i] - z_dy[i];
424                s_dz[i] += x_dz[i] - z_dz[i];
425            }
426
427            // Dx = real(ifft(K.*Fx))
428            apply_dipole(&mut fft_ws, &k, &x, &mut cbuf, &mut dx);
429            // z2 = Wy + mu2*(Dx + s2)./(weight + mu2); s2 = s2 + Dx - z2.
430            for i in 0..n {
431                z2[i] = wy[i] + mu2 * (dx[i] + s2[i]) / (weight[i] + mu2);
432                s2[i] = s2[i] + dx[i] - z2[i];
433            }
434
435            // x update. Dt_kspace_arg = z2 - s2.
436            for i in 0..n {
437                gx[i] = z2[i] - s2[i];
438            }
439            for (c, &v) in fdt.iter_mut().zip(gx.iter()) {
440                *c = Complex64::new(v, 0.0);
441            }
442            fft_ws.fft3d(&mut fdt);
443
444            for i in 0..n {
445                gx[i] = z_dx[i] - s_dx[i];
446                gy[i] = z_dy[i] - s_dy[i];
447                gz[i] = z_dz[i] - s_dz[i];
448            }
449            bdiv_inplace(&mut div, &gx, &gy, &gz, &ugrid);
450            for (c, &v) in fdiv.iter_mut().zip(div.iter()) {
451                *c = Complex64::new(v, 0.0);
452            }
453            fft_ws.fft3d(&mut fdiv);
454
455            for i in 0..n {
456                // Minus on the gradient term: adjoint of crate `fgrad` is `-bdiv` (matches
457                // QSM.rs TV-ADMM). `+bdiv` doubles the effective regularization. See fansi.rs.
458                let num = -fdiv[i] * mu + fdt[i] * (mu2 * k[i]);
459                // Guard the dipole null-space (DC/singular bins) — see stage 1.
460                cbuf[i] = if denom[i] > 1e-20 { num / denom[i] } else { Complex64::new(0.0, 0.0) };
461            }
462            fft_ws.ifft3d(&mut cbuf);
463            for i in 0..n {
464                x[i] = cbuf[i].re;
465            }
466
467            global_iter += 1;
468            progress(global_iter, total_iter);
469
470            let upd = percent_update(&x, &x_prev);
471            if upd < params.tol_update {
472                break;
473            }
474        }
475    }
476
477    // Ensure progress reaches the total even if stage 2 stopped early.
478    if global_iter < total_iter {
479        progress(total_iter, total_iter);
480    }
481
482    apply_mask_zero(&mut x, mask);
483    x
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    /// Eq. 2: sum(TE*mag^2)/sum(TE*mag) per voxel, 0 where there is no signal.
491    #[test]
492    fn test_hdqsm_magnitude_eq2() {
493        // two voxels, two echoes (echo slowest)
494        let m = hdqsm_magnitude(&[2.0, 0.0, 1.0, 0.0], &[0.01, 0.02]);
495        let want = (0.01 * 4.0 + 0.02 * 1.0) / (0.01 * 2.0 + 0.02 * 1.0);
496        assert!((m[0] - want).abs() < 1e-15);
497        assert_eq!(m[1], 0.0);
498    }
499
500    #[test]
501    fn test_hdqsm_zero_field() {
502        let n = 8;
503        let field = vec![0.0; n * n * n];
504        let mask = vec![1u8; n * n * n];
505        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
506        let params = HdQsmParams {
507            max_iter_l1: 5,
508            max_iter_l2: 10,
509            ..HdQsmParams::default()
510        };
511
512        let chi = hdqsm(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
513
514        for &val in chi.iter() {
515            assert!(val.abs() < 1e-6, "Zero field should give zero chi, got {}", val);
516        }
517    }
518
519    #[test]
520    fn test_hdqsm_finite() {
521        let n = 8;
522        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.001).collect();
523        let mask = vec![1u8; n * n * n];
524        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
525        let params = HdQsmParams {
526            max_iter_l1: 5,
527            max_iter_l2: 10,
528            ..HdQsmParams::default()
529        };
530
531        let chi = hdqsm(&field, &mask, None, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
532
533        for (i, &val) in chi.iter().enumerate() {
534            assert!(val.is_finite(), "Chi should be finite at index {}", i);
535        }
536    }
537
538    #[test]
539    fn test_hdqsm_magnitude_weight() {
540        let n = 8;
541        let field: Vec<f64> = (0..n * n * n).map(|i| ((i % 7) as f64 - 3.0) * 0.01).collect();
542        let mask = vec![1u8; n * n * n];
543        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
544        let params = HdQsmParams { max_iter_l1: 5, max_iter_l2: 10, ..HdQsmParams::default() };
545        let run = |mag: Option<&[f64]>| hdqsm(&field, &mask, mag, &grid, (0.0, 0.0, 1.0), &params, |_, _| {});
546
547        // A flat magnitude normalises to 1 everywhere: the same as no magnitude (mask weight).
548        let flat = vec![5.0; n * n * n];
549        let a = run(None);
550        let b = run(Some(&flat));
551        assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-12));
552
553        // A varying magnitude changes the weight, and so the result.
554        let ramp: Vec<f64> = (0..n * n * n).map(|i| 1.0 + (i % n) as f64).collect();
555        let c = run(Some(&ramp));
556        assert!(c.iter().all(|v| v.is_finite()));
557        assert!(a.iter().zip(&c).any(|(x, y)| (x - y).abs() > 1e-9));
558    }
559}