Skip to main content

qsm_core/separation/
chi_sep_ilsqr.rs

1//! χ-separation via the original Shin 2021 projected-CG algorithm.
2//!
3//! Faithful implementation of the algorithm in the Supplementary Methods of
4//! Shin et al. 2021 (the SNU-LIST toolbox ships it compiled as `chi_sep_iLSQR.p`
5//! / `chi_sep_MEDI.p`, so this is a paper-based port). The minimization is
6//!
7//! ```text
8//! argmin_{χ+, χ−} ‖ Wr·{R2' − (Dr,pos·|χ+| + Dr,neg·|χ−|)}
9//!                  + i·2π·Wf·{f − Df ∗ (χ+ + χ−)} ‖₂² + reg(χ+, χ−)
10//!                  subject to χ+ ≥ 0, χ− ≤ 0
11//! ```
12//!
13//! where `Wf` is an SNR weight from the GRE magnitude and `Wr = Wf/10` where
14//! R2' is unreliable (R2' > 30 Hz or < 1 Hz). Because the R2' term is real and
15//! the field term imaginary, the complex norm decouples into two independent
16//! least-squares terms; with the sign constraints enforced (|χ+| = χ+,
17//! |χ−| = −χ−) both are linear in (χ+, χ−). The regularization is edge-masked
18//! L1 total variation (MEDI-style, IRLS-linearized here):
19//!
20//! ```text
21//! reg = 2λ1‖M_Mag ∇(χ+ + χ−)‖₁ + λ1‖M_R2' ∇χ+‖₁ + λ1‖M_R2' ∇χ−‖₁
22//! ```
23//!
24//! with `M_Mag` a binary edge mask from the magnitude and `M_R2'` one from the
25//! R2' map. The solution is initialized from the voxelwise 2×2 system
26//! `{Dr,pos·χ+ − Dr,neg·χ− = R2',  χ+ + χ− = χ_QSM}` using a conventional QSM
27//! reconstruction — the toolbox's `chi_sep_iLSQR` name refers to feeding it an
28//! iLSQR QSM ([`crate::inversion::ilsqr`]); per the QSM-CI diagnostic the QSM
29//! must be reconstructed from the same local field with matching conventions,
30//! not supplied externally. Iteration is Gauss-Newton/CG with sign projection
31//! each step; it stops when ‖χⁿ⁺¹ − χⁿ‖/‖χⁿ‖ < tol on χ_total or at max_iter.
32//!
33//! Reference: Shin, H., et al. (2021). "χ-separation: Magnetic susceptibility
34//! source separation toward iron and myelin mapping in the brain." NeuroImage,
35//! 240:118371 (Supplementary Methods, "Algorithm for χ-separation").
36
37use crate::fft::Fft3dWorkspaceF32;
38use crate::inversion::medi::{
39    bdiv_periodic_inplace_f32, fgrad_periodic_inplace_f32, gradient_mask_f32,
40};
41use crate::kernels::dipole::dipole_kernel_f32;
42use crate::utils::padding::{next_fast_fft_size, pad3d, unpad3d};
43use crate::utils::simd_ops::{
44    apply_gradient_weights_f32, axpy_f32, compute_p_weights_f32, dot_product_f32, xpby_f32,
45};
46use crate::Grid;
47use num_complex::Complex32;
48
49const TWO_PI: f32 = std::f32::consts::TAU;
50
51/// Parameters for [`chi_sep_ilsqr`].
52#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
53#[derive(Clone, Debug)]
54pub struct ChiSepIlsqrParams {
55    /// Central frequency in Hz (e.g. 123.2e6 at 3T, 298.0e6 at 7T)
56    pub cf: f64,
57    /// Paramagnetic relaxometric constant in Hz/ppm (Shin 2021: 137)
58    pub dr_pos: f64,
59    /// Diamagnetic relaxometric constant in Hz/ppm (Shin 2021: same as dr_pos)
60    pub dr_neg: f64,
61    /// L1 edge-masked TV weight λ1 (the total-χ term carries 2λ1)
62    pub lambda1: f64,
63    /// Edge-mask keep fraction for M_Mag / M_R2' (MEDI convention, 0.9)
64    pub percentage: f64,
65    /// R2' reliability window: Wr = Wf/10 outside [r2p_min, r2p_max] Hz
66    pub r2p_min: f64,
67    /// Upper bound of the reliable R2' window in Hz
68    pub r2p_max: f64,
69    /// Outer Gauss-Newton max iterations (paper: 30)
70    pub max_iter: usize,
71    /// Outer relative-change tolerance on χ_total (paper: 0.01)
72    pub tol: f64,
73    /// Inner conjugate-gradient max iterations
74    pub cg_max_iter: usize,
75    /// Inner conjugate-gradient relative tolerance
76    pub cg_tol: f64,
77}
78
79impl Default for ChiSepIlsqrParams {
80    fn default() -> Self {
81        Self {
82            cf: 123.2e6,
83            dr_pos: 137.0,
84            dr_neg: 137.0,
85            lambda1: 1.0,
86            percentage: 0.9,
87            r2p_min: 1.0,
88            r2p_max: 30.0,
89            max_iter: 30,
90            tol: 0.01,
91            cg_max_iter: 30,
92            cg_tol: 0.05,
93        }
94    }
95}
96
97/// Shared buffers for the operator applications (all length n unless noted).
98struct Ws {
99    n: usize,
100    nx: usize,
101    ny: usize,
102    nz: usize,
103    vsx: f32,
104    vsy: f32,
105    vsz: f32,
106    fft_ws: Fft3dWorkspaceF32,
107    gx: Vec<f32>,
108    gy: Vec<f32>,
109    gz: Vec<f32>,
110    wx: Vec<f32>,
111    wy: Vec<f32>,
112    wz: Vec<f32>,
113    div: Vec<f32>,
114    dip: Vec<f32>,
115    cbuf: Vec<Complex32>,
116}
117
118impl Ws {
119    fn new(grid: &Grid) -> Self {
120        let (nx, ny, nz) = grid.dims;
121        let (vsx, vsy, vsz) = grid.voxel_size;
122        let n = nx * ny * nz;
123        Self {
124            n,
125            nx,
126            ny,
127            nz,
128            vsx: vsx as f32,
129            vsy: vsy as f32,
130            vsz: vsz as f32,
131            fft_ws: Fft3dWorkspaceF32::new(nx, ny, nz),
132            gx: vec![0.0; n],
133            gy: vec![0.0; n],
134            gz: vec![0.0; n],
135            wx: vec![0.0; n],
136            wy: vec![0.0; n],
137            wz: vec![0.0; n],
138            div: vec![0.0; n],
139            dip: vec![0.0; n],
140            cbuf: vec![Complex32::new(0.0, 0.0); n],
141        }
142    }
143
144    /// out (div) = bdiv( M · Vr · M · ∇x ), the IRLS-linearized TV Hessian apply.
145    #[allow(clippy::too_many_arguments)]
146    fn tv_apply(&mut self, x: &[f32], mx: &[f32], my: &[f32], mz: &[f32], vr: &[f32]) {
147        fgrad_periodic_inplace_f32(
148            &mut self.gx, &mut self.gy, &mut self.gz, x, self.nx, self.ny, self.nz, self.vsx,
149            self.vsy, self.vsz,
150        );
151        apply_gradient_weights_f32(
152            &mut self.wx, &mut self.wy, &mut self.wz, mx, my, mz, vr, &self.gx, &self.gy, &self.gz,
153        );
154        bdiv_periodic_inplace_f32(
155            &mut self.div, &self.wx, &self.wy, &self.wz, self.nx, self.ny, self.nz, self.vsx,
156            self.vsy, self.vsz,
157        );
158    }
159
160    /// dip = D(k) applied to x (unitless dipole convolution).
161    fn dipole_apply(&mut self, x: &[f32], d_kernel: &[f32]) {
162        self.fft_ws
163            .apply_dipole_inplace(x, d_kernel, &mut self.dip, &mut self.cbuf);
164    }
165}
166
167/// IRLS weights Vr = 1/sqrt(|M·∇χ|² + eps) for one TV term.
168fn irls_weights(ws: &mut Ws, x: &[f32], mx: &[f32], my: &[f32], mz: &[f32], vr: &mut [f32]) {
169    let eps = 1.0e-6_f32;
170    fgrad_periodic_inplace_f32(
171        &mut ws.gx, &mut ws.gy, &mut ws.gz, x, ws.nx, ws.ny, ws.nz, ws.vsx, ws.vsy, ws.vsz,
172    );
173    compute_p_weights_f32(vr, mx, my, mz, &ws.gx, &ws.gy, &ws.gz, eps);
174}
175
176/// χ-separation (Shin 2021, projected Gauss-Newton/CG).
177///
178/// # Arguments
179/// * `local_field` - Local (tissue) field map in ppm `[nx*ny*nz]` (same units
180///   convention as the dipole-inversion algorithms; converted to Hz internally
181///   via `params.cf`)
182/// * `r2prime` - R2' map in Hz `[nx*ny*nz]`
183/// * `magnitude` - GRE magnitude (echo-combined) for the SNR weight and edge mask
184/// * `qsm` - Conventional QSM in ppm for initialization (use [`crate::inversion::ilsqr`]
185///   on the same local field — see module docs)
186/// * `mask` - Binary brain mask `[nx*ny*nz]`
187/// * `grid` - Volume grid
188/// * `bdir` - B0 direction unit vector
189/// * `params` - Algorithm parameters (see [`ChiSepIlsqrParams`])
190/// * `progress` - Progress callback `(iteration, max_iterations)`
191///
192/// # Returns
193/// `(chi_pos, chi_neg, chi_total)` in ppm; `chi_neg` is ≤ 0.
194///
195/// Volumes whose dimensions are not FFT-friendly (2ᵃ·3ᵇ·5ᶜ) are transparently
196/// zero-padded to the next fast size for the internal FFTs and cropped back —
197/// awkward prime factors (e.g. 41 in a 164×205×205 acquisition) otherwise
198/// dominate the runtime, and the padding also increases the circular-wrap
199/// margin of the dipole convolution.
200#[allow(clippy::too_many_arguments)]
201pub fn chi_sep_ilsqr<F>(
202    local_field: &[f64],
203    r2prime: &[f64],
204    magnitude: &[f64],
205    qsm: &[f64],
206    mask: &[u8],
207    grid: &Grid,
208    bdir: (f64, f64, f64),
209    params: &ChiSepIlsqrParams,
210    progress: F,
211) -> (Vec<f64>, Vec<f64>, Vec<f64>)
212where
213    F: FnMut(usize, usize),
214{
215    let dims = grid.dims;
216    let fast = (
217        next_fast_fft_size(dims.0),
218        next_fast_fft_size(dims.1),
219        next_fast_fft_size(dims.2),
220    );
221    if fast == dims {
222        return chi_sep_ilsqr_core(
223            local_field, r2prime, magnitude, qsm, mask, grid, bdir, params, progress,
224        );
225    }
226    let (vsx, vsy, vsz) = grid.voxel_size;
227    let pgrid = Grid::new(fast.0, fast.1, fast.2, vsx, vsy, vsz);
228    let (chi_pos, chi_neg, chi_total) = chi_sep_ilsqr_core(
229        &pad3d(local_field, dims, fast),
230        &pad3d(r2prime, dims, fast),
231        &pad3d(magnitude, dims, fast),
232        &pad3d(qsm, dims, fast),
233        &pad3d(mask, dims, fast),
234        &pgrid,
235        bdir,
236        params,
237        progress,
238    );
239    (
240        unpad3d(&chi_pos, fast, dims),
241        unpad3d(&chi_neg, fast, dims),
242        unpad3d(&chi_total, fast, dims),
243    )
244}
245
246#[allow(clippy::too_many_arguments)]
247fn chi_sep_ilsqr_core<F>(
248    local_field: &[f64],
249    r2prime: &[f64],
250    magnitude: &[f64],
251    qsm: &[f64],
252    mask: &[u8],
253    grid: &Grid,
254    bdir: (f64, f64, f64),
255    params: &ChiSepIlsqrParams,
256    mut progress: F,
257) -> (Vec<f64>, Vec<f64>, Vec<f64>)
258where
259    F: FnMut(usize, usize),
260{
261    let (nx, ny, nz) = grid.dims;
262    let n = nx * ny * nz;
263    assert_eq!(local_field.len(), n, "local_field length must match grid");
264    assert_eq!(r2prime.len(), n, "r2prime length must match grid");
265    assert_eq!(magnitude.len(), n, "magnitude length must match grid");
266    assert_eq!(qsm.len(), n, "qsm length must match grid");
267    assert_eq!(mask.len(), n, "mask length must match grid");
268
269    let cf_ppm = (params.cf * 1.0e-6) as f32; // Hz per ppm
270    let dr_p = params.dr_pos as f32;
271    let dr_q = params.dr_neg as f32;
272    let lambda1 = params.lambda1 as f32;
273    let tol = params.tol as f32;
274
275    let mut ws = Ws::new(grid);
276    let d_kernel = dipole_kernel_f32(grid, (bdir.0 as f32, bdir.1 as f32, bdir.2 as f32));
277
278    // --- SNR weights: Wf = magnitude / mean(magnitude in mask), 0 outside ---
279    let mut mag_mean = 0.0_f64;
280    let mut n_mask = 0usize;
281    for i in 0..n {
282        if mask[i] != 0 {
283            mag_mean += magnitude[i];
284            n_mask += 1;
285        }
286    }
287    assert!(n_mask > 0, "mask is empty");
288    mag_mean /= n_mask as f64;
289    let wf2: Vec<f32> = (0..n)
290        .map(|i| {
291            if mask[i] == 0 {
292                0.0
293            } else {
294                let w = (magnitude[i] / mag_mean) as f32;
295                w * w
296            }
297        })
298        .collect();
299    // Wr = Wf/10 where R2' is unreliable → Wr² = Wf²/100 there.
300    let wr2: Vec<f32> = (0..n)
301        .map(|i| {
302            if mask[i] == 0 {
303                0.0
304            } else if r2prime[i] > params.r2p_max || r2prime[i] < params.r2p_min {
305                wf2[i] / 100.0
306            } else {
307                wf2[i]
308            }
309        })
310        .collect();
311
312    // --- Edge masks: M_Mag from magnitude, M_R2' from the R2' map ---
313    let mag_f32: Vec<f32> = magnitude.iter().map(|&v| v as f32).collect();
314    let r2p_f32: Vec<f32> = r2prime
315        .iter()
316        .zip(mask.iter())
317        .map(|(&v, &m)| if m != 0 { v as f32 } else { 0.0 })
318        .collect();
319    let pct = params.percentage as f32;
320    let (vsx, vsy, vsz) = (ws.vsx, ws.vsy, ws.vsz);
321    let (mmx, mmy, mmz) = gradient_mask_f32(&mag_f32, mask, nx, ny, nz, vsx, vsy, vsz, pct);
322    let (mrx, mry, mrz) = gradient_mask_f32(&r2p_f32, mask, nx, ny, nz, vsx, vsy, vsz, pct);
323
324    // Local field arrives in ppm (library-wide convention); the data term works
325    // in Hz so both residuals share 1/s units under the 2π coupling.
326    let field_f32: Vec<f32> = local_field
327        .iter()
328        .zip(mask.iter())
329        .map(|(&v, &m)| if m != 0 { (v * params.cf * 1.0e-6) as f32 } else { 0.0 })
330        .collect();
331
332    // --- Initialization: voxelwise 2×2 solve of
333    //   Dr,pos·χ+ − Dr,neg·χ− = R2'   and   χ+ + χ− = χ_QSM,
334    // then sign projection (supplementary methods). All in ppm. ---
335    let dr_sum = dr_p + dr_q;
336    let mut chi_pos = vec![0.0_f32; n];
337    let mut chi_neg = vec![0.0_f32; n];
338    for i in 0..n {
339        if mask[i] == 0 {
340            continue;
341        }
342        let q = qsm[i] as f32;
343        let r = r2prime[i] as f32;
344        chi_pos[i] = ((dr_q * q + r) / dr_sum).max(0.0);
345        chi_neg[i] = ((dr_p * q - r) / dr_sum).min(0.0);
346    }
347
348    // Field-term scale: residuals in Hz enter as i·2π·Wf·(·), so the quadratic
349    // carries (2π)²; the dipole operator maps ppm → Hz via cf_ppm·D(k).
350    let field_w = TWO_PI * TWO_PI;
351
352    let mut vr_tot = vec![0.0_f32; n];
353    let mut vr_pos = vec![0.0_f32; n];
354    let mut vr_neg = vec![0.0_f32; n];
355    let n2 = 2 * n;
356    let mut rhs = vec![0.0_f32; n2];
357    let mut dx = vec![0.0_f32; n2];
358    let mut prev_total = vec![0.0_f32; n];
359    for i in 0..n {
360        prev_total[i] = chi_pos[i] + chi_neg[i];
361    }
362    // Scratch reused across all iterations — the loops below allocate nothing.
363    let mut total = vec![0.0_f32; n];
364    let mut stage = vec![0.0_f32; n];
365    let mut cg_r = vec![0.0_f32; n2];
366    let mut cg_p = vec![0.0_f32; n2];
367    let mut cg_ap = vec![0.0_f32; n2];
368
369    for iter in 0..params.max_iter {
370        progress(iter + 1, params.max_iter);
371
372        // --- IRLS reweighting for the three TV terms ---
373        for i in 0..n {
374            total[i] = chi_pos[i] + chi_neg[i];
375        }
376        irls_weights(&mut ws, &total, &mmx, &mmy, &mmz, &mut vr_tot);
377        irls_weights(&mut ws, &chi_pos, &mrx, &mry, &mrz, &mut vr_pos);
378        irls_weights(&mut ws, &chi_neg, &mrx, &mry, &mrz, &mut vr_neg);
379
380        // --- Gradient of the cost (1/2‖·‖² convention), then b = −grad ---
381        // Field residual r_f = f − cf_ppm·D(χ+ + χ−)   [Hz]
382        // grad_field(both) = −(2π)²·cf_ppm·Dᴴ(Wf²·r_f)
383        ws.dipole_apply(&total, &d_kernel);
384        for i in 0..n {
385            stage[i] = wf2[i] * (field_f32[i] - cf_ppm * ws.dip[i]);
386        }
387        ws.dipole_apply(&stage, &d_kernel);
388        for i in 0..n {
389            let g = -field_w * cf_ppm * ws.dip[i];
390            rhs[i] = g;
391            rhs[n + i] = g;
392        }
393
394        // R2' residual r_r = R2' − Dr,pos·χ+ + Dr,neg·χ−   [Hz]
395        // grad_pos = −Dr,pos·Wr²·r_r ; grad_neg = +Dr,neg·Wr²·r_r
396        for i in 0..n {
397            if mask[i] == 0 {
398                continue;
399            }
400            let rr = r2p_f32[i] - dr_p * chi_pos[i] + dr_q * chi_neg[i];
401            let wrr = wr2[i] * rr;
402            rhs[i] -= dr_p * wrr;
403            rhs[n + i] += dr_q * wrr;
404        }
405
406        // TV gradients: 2λ1 on the total (both components), λ1 per component.
407        ws.tv_apply(&total, &mmx, &mmy, &mmz, &vr_tot);
408        for i in 0..n {
409            let g = 2.0 * lambda1 * ws.div[i];
410            rhs[i] += g;
411            rhs[n + i] += g;
412        }
413        ws.tv_apply(&chi_pos, &mrx, &mry, &mrz, &vr_pos);
414        for i in 0..n {
415            rhs[i] += lambda1 * ws.div[i];
416        }
417        ws.tv_apply(&chi_neg, &mrx, &mry, &mrz, &vr_neg);
418        for i in 0..n {
419            rhs[n + i] += lambda1 * ws.div[i];
420        }
421
422        for v in rhs.iter_mut() {
423            *v = -*v;
424        }
425
426        // --- Inner CG on the Gauss-Newton system ---
427        cg_solve(
428            &mut ws, &d_kernel, &wf2, &wr2, &mmx, &mmy, &mmz, &mrx, &mry, &mrz, &vr_tot, &vr_pos,
429            &vr_neg, lambda1, field_w, cf_ppm, dr_p, dr_q, mask, &rhs, &mut dx, &mut stage,
430            &mut cg_r, &mut cg_p, &mut cg_ap, params.cg_tol as f32, params.cg_max_iter,
431        );
432
433        // --- Update + sign projection (violations forced to zero) ---
434        for i in 0..n {
435            if mask[i] == 0 {
436                continue;
437            }
438            chi_pos[i] = (chi_pos[i] + dx[i]).max(0.0);
439            chi_neg[i] = (chi_neg[i] + dx[n + i]).min(0.0);
440        }
441
442        // --- Convergence on χ_total (paper: ‖χⁿ⁺¹ − χⁿ‖/‖χⁿ‖ < 0.01) ---
443        let mut num = 0.0_f64;
444        let mut den = 0.0_f64;
445        for i in 0..n {
446            let t = chi_pos[i] + chi_neg[i];
447            let d = (t - prev_total[i]) as f64;
448            num += d * d;
449            den += (prev_total[i] as f64) * (prev_total[i] as f64);
450            prev_total[i] = t;
451        }
452        if den > 0.0 && (num / den).sqrt() < tol as f64 {
453            break;
454        }
455    }
456
457    let chi_pos_out: Vec<f64> = chi_pos.iter().map(|&v| v as f64).collect();
458    let chi_neg_out: Vec<f64> = chi_neg.iter().map(|&v| v as f64).collect();
459    let chi_total: Vec<f64> = (0..n).map(|i| chi_pos_out[i] + chi_neg_out[i]).collect();
460    (chi_pos_out, chi_neg_out, chi_total)
461}
462
463/// Apply the Gauss-Newton operator to `dx = [d+; d−]`:
464///
465/// ```text
466/// A± = 2λ1·TVtot(d+ + d−) + λ1·TV±(d±) + (2π)²·cf²·Dᴴ Wf² D (d+ + d−)
467///      ± Dr,± · Wr² · (Dr,pos·d+ − Dr,neg·d−)
468/// ```
469#[allow(clippy::too_many_arguments)]
470fn apply_operator(
471    ws: &mut Ws,
472    d_kernel: &[f32],
473    wf2: &[f32],
474    wr2: &[f32],
475    mmx: &[f32],
476    mmy: &[f32],
477    mmz: &[f32],
478    mrx: &[f32],
479    mry: &[f32],
480    mrz: &[f32],
481    vr_tot: &[f32],
482    vr_pos: &[f32],
483    vr_neg: &[f32],
484    lambda1: f32,
485    field_w: f32,
486    cf_ppm: f32,
487    dr_p: f32,
488    dr_q: f32,
489    mask: &[u8],
490    dx: &[f32],
491    out: &mut [f32],
492    stage: &mut [f32],
493) {
494    let n = ws.n;
495    let (d_pos, d_neg) = dx.split_at(n);
496
497    // Stage the sum once; the total-TV term runs first so the same scratch can
498    // then be overwritten for the weighted dipole pass (no allocations here —
499    // this is the innermost CG hot path).
500    for i in 0..n {
501        stage[i] = d_pos[i] + d_neg[i];
502    }
503
504    // Total-TV (2λ1) on the sum, applied to both components.
505    ws.tv_apply(stage, mmx, mmy, mmz, vr_tot);
506    for i in 0..n {
507        let g = 2.0 * lambda1 * ws.div[i];
508        out[i] = g;
509        out[n + i] = g;
510    }
511
512    // Field: (2π)²·cf²·Dᴴ Wf² D applied to the sum, same for both components.
513    ws.dipole_apply(stage, d_kernel);
514    for i in 0..n {
515        stage[i] = wf2[i] * ws.dip[i];
516    }
517    ws.dipole_apply(stage, d_kernel);
518    let scale = field_w * cf_ppm * cf_ppm;
519    for i in 0..n {
520        let f = scale * ws.dip[i];
521        out[i] += f;
522        out[n + i] += f;
523    }
524
525    // Component TVs (λ1) with the R2' edge mask.
526    ws.tv_apply(d_pos, mrx, mry, mrz, vr_pos);
527    for i in 0..n {
528        out[i] += lambda1 * ws.div[i];
529    }
530    ws.tv_apply(d_neg, mrx, mry, mrz, vr_neg);
531    for i in 0..n {
532        out[n + i] += lambda1 * ws.div[i];
533    }
534
535    // R2': rank-1 per voxel, [Dr,pos, −Dr,neg]ᵀ Wr² [Dr,pos, −Dr,neg].
536    for i in 0..n {
537        if mask[i] == 0 {
538            continue;
539        }
540        let lin = wr2[i] * (dr_p * d_pos[i] - dr_q * d_neg[i]);
541        out[i] += dr_p * lin;
542        out[n + i] -= dr_q * lin;
543    }
544}
545
546/// Standard CG on the doubled system.
547#[allow(clippy::too_many_arguments)]
548fn cg_solve(
549    ws: &mut Ws,
550    d_kernel: &[f32],
551    wf2: &[f32],
552    wr2: &[f32],
553    mmx: &[f32],
554    mmy: &[f32],
555    mmz: &[f32],
556    mrx: &[f32],
557    mry: &[f32],
558    mrz: &[f32],
559    vr_tot: &[f32],
560    vr_pos: &[f32],
561    vr_neg: &[f32],
562    lambda1: f32,
563    field_w: f32,
564    cf_ppm: f32,
565    dr_p: f32,
566    dr_q: f32,
567    mask: &[u8],
568    b: &[f32],
569    x: &mut [f32],
570    stage: &mut [f32],
571    r: &mut [f32],
572    p: &mut [f32],
573    ap: &mut [f32],
574    tol: f32,
575    max_iter: usize,
576) {
577    x.fill(0.0);
578    r.copy_from_slice(b);
579    p.copy_from_slice(b);
580
581    let b_norm = dot_product_f32(b, b).sqrt();
582    if b_norm < 1e-12 {
583        return;
584    }
585    let mut rsold = dot_product_f32(r, r);
586
587    for _ in 0..max_iter {
588        apply_operator(
589            ws, d_kernel, wf2, wr2, mmx, mmy, mmz, mrx, mry, mrz, vr_tot, vr_pos, vr_neg, lambda1,
590            field_w, cf_ppm, dr_p, dr_q, mask, p, ap, stage,
591        );
592        let pap = dot_product_f32(p, ap);
593        if pap.abs() < 1e-20 {
594            break;
595        }
596        let alpha = rsold / pap;
597        axpy_f32(x, alpha, p);
598        axpy_f32(r, -alpha, ap);
599        let rsnew = dot_product_f32(r, r);
600        if rsnew.sqrt() < tol * b_norm {
601            break;
602        }
603        xpby_f32(p, r, rsnew / rsold);
604        rsold = rsnew;
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::fft::{fft3d_real, ifft3d_real};
612    use crate::kernels::dipole::dipole_kernel;
613
614    fn make_sphere(nx: usize, ny: usize, nz: usize, c: f64, r: f64) -> Vec<f64> {
615        let mut vol = vec![0.0; nx * ny * nz];
616        for k in 0..nz {
617            for j in 0..ny {
618                for i in 0..nx {
619                    let (dx, dy, dz) = (i as f64 - c, j as f64 - c, k as f64 - c);
620                    if dx * dx + dy * dy + dz * dz <= r * r {
621                        vol[i + j * nx + k * nx * ny] = 1.0;
622                    }
623                }
624            }
625        }
626        vol
627    }
628
629    /// Two-shell phantom: paramagnetic core, diamagnetic shell. With consistent
630    /// field/R2'/QSM inputs the algorithm must recover both components.
631    #[test]
632    fn test_chi_sep_ilsqr_recovers_two_shell_phantom() {
633        let (nx, ny, nz) = (32, 32, 32);
634        let n = nx * ny * nz;
635        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
636        let bdir = (0.0, 0.0, 1.0);
637        let cf = 123.2e6_f64;
638
639        let inner = make_sphere(nx, ny, nz, 16.0, 4.0);
640        let outer = make_sphere(nx, ny, nz, 16.0, 8.0);
641        let brain = make_sphere(nx, ny, nz, 16.0, 12.0);
642
643        let mut chi_pos_t = vec![0.0_f64; n];
644        let mut chi_neg_t = vec![0.0_f64; n];
645        for i in 0..n {
646            if inner[i] > 0.5 {
647                chi_pos_t[i] = 0.08;
648            } else if outer[i] > 0.5 {
649                chi_neg_t[i] = -0.05;
650            }
651        }
652        let chi_total_t: Vec<f64> = (0..n).map(|i| chi_pos_t[i] + chi_neg_t[i]).collect();
653
654        // field_ppm = D(k) * chi_total_ppm (ppm in, ppm out — library convention)
655        let d = dipole_kernel(&grid, bdir);
656        let cf_fft = fft3d_real(&chi_total_t, nx, ny, nz);
657        let f_fft: Vec<_> = cf_fft.iter().zip(d.iter()).map(|(&c, &dk)| c * dk).collect();
658        let field_ppm: Vec<f64> = ifft3d_real(&f_fft, nx, ny, nz);
659
660        let dr = 137.0_f64;
661        let r2prime: Vec<f64> = (0..n)
662            .map(|i| dr * (chi_pos_t[i].abs() + chi_neg_t[i].abs()))
663            .collect();
664        let mask: Vec<u8> = brain.iter().map(|&v| (v > 0.5) as u8).collect();
665        let magnitude: Vec<f64> = (0..n)
666            .map(|i| if mask[i] != 0 { 100.0 } else { 0.0 })
667            .collect();
668
669        let params = ChiSepIlsqrParams {
670            cf,
671            ..ChiSepIlsqrParams::default()
672        };
673        // Ideal conventional QSM = true chi_total (unit test isolates the separation).
674        let (chi_pos, chi_neg, chi_total) = chi_sep_ilsqr(
675            &field_ppm, &r2prime, &magnitude, &chi_total_t, &mask, &grid, bdir, &params, |_, _| {},
676        );
677
678        for i in 0..n {
679            assert!(chi_pos[i] >= 0.0, "chi+ must be non-negative");
680            assert!(chi_neg[i] <= 0.0, "chi- must be non-positive");
681            assert!((chi_total[i] - chi_pos[i] - chi_neg[i]).abs() < 1e-10);
682        }
683
684        // Region means should recover the assigned values well: the init is exact
685        // here, so the iterations must not walk away from the solution.
686        let mean_in = |v: &Vec<f64>, region: &Vec<f64>| {
687            let (mut s, mut c) = (0.0, 0usize);
688            for i in 0..n {
689                if region[i] > 0.5 {
690                    s += v[i];
691                    c += 1;
692                }
693            }
694            s / c as f64
695        };
696        let pos_core = mean_in(&chi_pos, &inner);
697        let shell: Vec<f64> = (0..n)
698            .map(|i| if outer[i] > 0.5 && inner[i] < 0.5 { 1.0 } else { 0.0 })
699            .collect();
700        let neg_shell = mean_in(&chi_neg, &shell);
701        assert!(
702            (pos_core - 0.08).abs() < 0.02,
703            "chi+ core mean {:.4} vs true 0.08",
704            pos_core
705        );
706        assert!(
707            (neg_shell + 0.05).abs() < 0.02,
708            "chi- shell mean {:.4} vs true -0.05",
709            neg_shell
710        );
711    }
712
713    /// Perf probe: TV stack vs dipole FFT cost at the qsmci phantom size
714    /// (164×205×205; both dims contain the prime 41, so the FFT dominates the
715    /// CG operator ~15:1 — see the perf notes in the chi-separation PR).
716    #[test]
717    #[ignore]
718    fn perf_probe() {
719        use std::time::Instant;
720        let grid = Grid::new(164, 205, 205, 1.0, 1.0, 1.0);
721        let n = 164 * 205 * 205;
722        let mut ws = Ws::new(&grid);
723        let x = vec![0.5_f32; n];
724        let m = vec![1.0_f32; n];
725        let vr = vec![1.0_f32; n];
726        let dk = crate::kernels::dipole::dipole_kernel_f32(&grid, (0.0, 0.0, 1.0));
727        let t = Instant::now();
728        for _ in 0..5 {
729            ws.dipole_apply(&x, &dk);
730        }
731        println!("dipole_apply 164x205x205 x5: {:?}", t.elapsed());
732        let t = Instant::now();
733        for _ in 0..5 {
734            ws.tv_apply(&x, &m, &m, &m, &vr);
735        }
736        println!("tv_apply x5: {:?}", t.elapsed());
737
738        // Padded to the next fast sizes (2^a·3^b·5^c): 180×216×216.
739        let pgrid = Grid::new(180, 216, 216, 1.0, 1.0, 1.0);
740        let pn = 180 * 216 * 216;
741        let mut pws = Ws::new(&pgrid);
742        let px = vec![0.5_f32; pn];
743        let pdk = crate::kernels::dipole::dipole_kernel_f32(&pgrid, (0.0, 0.0, 1.0));
744        let t = Instant::now();
745        for _ in 0..5 {
746            pws.dipole_apply(&px, &pdk);
747        }
748        println!("dipole_apply 180x216x216 x5: {:?}", t.elapsed());
749    }
750
751    /// Non-FFT-friendly dims (7 → padded to 8) must exercise the pad/crop path
752    /// and still return original-size, init-consistent outputs.
753    #[test]
754    fn test_padding_path_preserves_shape_and_values() {
755        let (nx, ny, nz) = (7, 3, 3); // 7 is not 2/3/5-smooth → pads to 8×3×3
756        let n = nx * ny * nz;
757        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
758        let dr = 137.0;
759        let qsm: Vec<f64> = (0..n).map(|i| 0.02 + 0.001 * (i % 5) as f64).collect();
760        let r2prime: Vec<f64> = qsm.iter().map(|&q| dr * q).collect(); // pure χ+
761        let field = vec![0.0_f64; n];
762        let magnitude = vec![1.0_f64; n];
763        let mask = vec![1u8; n];
764        let params = ChiSepIlsqrParams {
765            max_iter: 0,
766            ..ChiSepIlsqrParams::default()
767        };
768        let (p, q, t) = chi_sep_ilsqr(
769            &field, &r2prime, &magnitude, &qsm, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {},
770        );
771        assert_eq!(p.len(), n, "output must be cropped back to input size");
772        for i in 0..n {
773            assert!(
774                (p[i] - qsm[i]).abs() < 1e-6 && q[i].abs() < 1e-6,
775                "voxel {}: init through pad/crop path should be exact",
776                i
777            );
778            assert!((t[i] - p[i] - q[i]).abs() < 1e-10);
779        }
780    }
781
782    /// The voxelwise init must solve the 2×2 system exactly when QSM and R2'
783    /// are consistent, before any sign clipping is needed.
784    #[test]
785    fn test_chi_sep_ilsqr_init_solves_linear_system() {
786        let grid = Grid::new(2, 1, 1, 1.0, 1.0, 1.0);
787        // Voxel 0: pure paramagnetic 0.1 ppm; voxel 1: mixed +0.06 / -0.04.
788        let qsm = vec![0.1, 0.02];
789        let dr = 137.0;
790        let r2prime = vec![dr * 0.1, dr * (0.06 + 0.04)];
791        let field = vec![0.0, 0.0]; // irrelevant at max_iter = 0
792        let magnitude = vec![1.0, 1.0];
793        let mask = vec![1u8, 1];
794        let params = ChiSepIlsqrParams {
795            max_iter: 0,
796            ..ChiSepIlsqrParams::default()
797        };
798        let (p, q, _) = chi_sep_ilsqr(
799            &field, &r2prime, &magnitude, &qsm, &mask, &grid, (0.0, 0.0, 1.0), &params, |_, _| {},
800        );
801        assert!((p[0] - 0.1).abs() < 1e-6 && q[0].abs() < 1e-6, "pure para voxel");
802        assert!(
803            (p[1] - 0.06).abs() < 1e-6 && (q[1] + 0.04).abs() < 1e-6,
804            "mixed voxel: got ({:.4}, {:.4})",
805            p[1],
806            q[1]
807        );
808    }
809}