Skip to main content

qsm_core/separation/
hc_chisep.rs

1//! HC-ChiSep: hollow-cylinder χ-separation with signal-derived fiber orientation.
2//!
3//! HC-ChiSep separates paramagnetic (χ+, iron) and diamagnetic (χ−, myelin)
4//! susceptibility from a conventional QSM (χ_total), an R2' map, and multi-echo
5//! GRE magnitude — deriving white-matter fibre orientation from the magnitude's
6//! multi-compartment interference pattern (Wharton & Bowtell hollow-cylinder
7//! model) rather than requiring DTI.
8//!
9//! The pipeline (headline mode) is:
10//! 1. **Dr+ self-calibration** — the paramagnetic relaxivity is estimated from the
11//!    5th percentile of `R2'/χ_total` over confidently-paramagnetic voxels,
12//!    falling back to the field-scaled empirical `137·B0/3` Hz/ppm.
13//! 2. **Closed-form two-source solve** — `χ+ = R2'/Dr+`, `χ− = χ+ − χ_total`
14//!    (Ridani convention: no diamagnetic relaxivity outside myelinated WM),
15//!    clamped to keep χ+ ≥ 0, |χ−| ≥ 0. This is the non-WM branch.
16//! 3. **WM-likeness (beat) weighting** — a soft weight `w` from a hollow-cylinder
17//!    vs mono-exponential model-selection test on the magnitude decay, gated by
18//!    χ_total sign. Voxels whose magnitude shows the multi-compartment "beat" and
19//!    are diamagnetic-leaning get `w → 1`.
20//! 4. **θ + MWF grid fit** — for supported voxels, fibre angle θ and myelin-water
21//!    fraction (MWF) are found by an R2'-anchored grid search over the hollow-
22//!    cylinder magnitude library, then spatially regularised.
23//! 5. **Separation** — in WM, |χ−| = K_χ·MWF (myelin-content ↔ MWF anchor,
24//!    0.038 ppm ↔ MWF 0.12), χ+ = χ_total + |χ−| shrunk toward a self-calibrated
25//!    median; the two branches are blended by `w`.
26//!
27//! All the reference's env-gated optional regularisers (TV/guided joint inversion,
28//! R2' denoise, hard constraint) are **off** in headline mode and are omitted here.
29//!
30//! Inputs are in the project's standard units — χ_total in ppm, R2' in Hz, TE in
31//! seconds, B0 in Tesla — and outputs follow the χ-separation sign convention
32//! (χ+ ≥ 0, χ− ≤ 0, χ_total = χ+ + χ−).
33//!
34//! # Reference pipeline
35//!
36//! There is no published paper. The reference is the QSM-CI submission
37//! (`algorithms/hc-chisep-qsmci/recon.py` and `algorithm.yml` in QSMxT/QSM-CI), which runs in
38//! QSM-CI's isolated χ-separation stage on a provided χ_total, R2' and multi-echo magnitude.
39//! That stage prescribes no masking, unwrapping, background removal or QSM method.
40//!
41//! **This port predates a change to the reference.** QSM-CI `db79def` (2026-09-14, "declare
42//! Dr+/Dr- and gate the WM branch on protocol adequacy") changed the reference in three ways that
43//! this module does not yet follow:
44//! - Dr+ defaults to the fixed `137·B0/3` Hz/ppm. Self-calibration is now opt-in
45//!   (`HCCHISEP_DRP=auto`), because on noiseless phantoms the 5th-percentile estimator returns
46//!   the generator's constant exactly, and with an estimated R2' it spans 241–755 Hz/ppm. Here
47//!   (stage 1 above) self-calibration is always on.
48//! - Dr− defaults to Dr+, with closed form `χ+ = (R2' + Dr−·χ_total)/(Dr+ + Dr−)`. The Ridani
49//!   convention used here (Dr− = 0, stage 2) is now the opt-in `HCCHISEP_DRN=0`.
50//! - The WM branch runs only when the echo train can resolve a multi-compartment signature,
51//!   `TE_max − TE_min ≥ 30 ms` and `TE_max ≥ 35 ms` (`recon.py`, `SPAN_MIN_T2M`). Otherwise the
52//!   method reduces to the closed form. This port has no such gate.
53//!
54//! Reference:
55//! Wharton, S. & Bowtell, R. (2012). "Fiber orientation-dependent white matter
56//! contrast in gradient echo MRI." PNAS 109(45):18559-18564. HC-ChiSep is the
57//! QSM-CI submission building on this biophysical model.
58
59use super::DR_SHIN_2021;
60use crate::Grid;
61#[cfg(feature = "parallel")]
62use rayon::prelude::*;
63
64use std::f64::consts::PI;
65
66/// Reduced gyromagnetic ratio (Hz/T) used by the hollow-cylinder model: the reference
67/// implementation's value (qsm-forward's `GAMMA_BAR`), not `crate::constants`. Keep it.
68const GAMMA_BAR: f64 = 42.577e6;
69
70// --- Hollow-cylinder white-matter model constants (Wharton & Bowtell Table 3) ---
71const CHI_I: f64 = -0.06e-6; // isotropic susceptibility
72const CHI_A: f64 = -0.10e-6; // anisotropic susceptibility
73const E_EXCH: f64 = 0.02e-6; // isotropic exchange offset
74const G_RATIO: f64 = 0.7;
75const T2_M: f64 = 10e-3; // myelin-water T2 (s)
76const T2_A: f64 = 64e-3; // axonal-water T2 (s)
77const T2_E: f64 = 48e-3; // extra-axonal-water T2 (s)
78const F_AXON: f64 = 0.55; // axonal fraction of non-myelin water
79
80// --- Myelin-content anchor & MWF bounds ---
81const CHI_NEG_REF: f64 = 0.038; // ppm |χ−| at reference MWF
82const MWF_REF: f64 = 0.12;
83const MWF_MIN: f64 = 0.03;
84const MWF_MAX: f64 = 0.25;
85
86// --- Locked hyperparameters (headline mode) ---
87const SMOOTH_CLS: [f64; 2] = [0.75, 1.0]; // classification smoothing sigmas
88const SMOOTH_FIT: f64 = 1.0;
89const W_CENTER: f64 = 1.5;
90const W_SCALE: f64 = 0.3;
91const CHI_GATE_C: f64 = 0.01;
92const CHI_GATE_S: f64 = 0.01;
93const LAM: f64 = 0.7;
94const NCONV_SIGMA: f64 = 1.5;
95const W_SUPPORT: f64 = 0.02;
96const NO_BEAT_FRAC: f64 = 0.01;
97
98/// `K_χ`: ppm of |χ−| per unit MWF (= CHI_NEG_REF / MWF_REF).
99const K_CHI: f64 = CHI_NEG_REF / MWF_REF;
100
101// θ and MWF grids.
102const NT: usize = 61; // 0 .. 90 step 1.5
103const NM: usize = 45; // 0.03 .. 0.25 step 0.005
104
105/// Parameters for [`hc_chisep`].
106#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
107#[derive(Clone, Debug)]
108pub struct HcChisepParams {
109    /// Main field strength in Tesla.
110    pub b0: f64,
111    /// Spin-echo echo times in **seconds** (empty = no SE evidence used).
112    pub se_echo_times: Vec<f64>,
113    /// Paramagnetic relaxivity at 3 T in Hz/ppm. Defaults to [`DR_SHIN_2021`].
114    pub dr_pos_3t: f64,
115    /// R2' bin width (Hz) for the anchored grid search (reference default 0.25).
116    pub bin_hz: f64,
117}
118
119impl Default for HcChisepParams {
120    fn default() -> Self {
121        Self {
122            b0: 7.0,
123            se_echo_times: Vec::new(),
124            dr_pos_3t: DR_SHIN_2021,
125            bin_hz: 0.25,
126        }
127    }
128}
129
130/// HC-ChiSep source separation from a QSM, an R2' map and multi-echo magnitude.
131///
132/// # Arguments
133/// * `chi_total` — Conventional QSM χ_total in **ppm** (`nx·ny·nz`, column-major).
134/// * `r2prime` — R2' map in **Hz** (`nx·ny·nz`).
135/// * `magnitude` — Multi-echo GRE magnitude, flattened as `(n_voxels, n_echoes)`
136///   in row-major order (echo fastest per voxel). Normalised per voxel internally.
137/// * `echo_times` — GRE echo times in **seconds** (`n_echoes`).
138/// * `se_magnitude` — Optional multi-echo spin-echo magnitude, `(n_voxels,
139///   n_se_echoes)`; used as soft MWF/pool-T2 evidence when present (with
140///   `params.se_echo_times`).
141/// * `mask` — Binary brain mask (`nx·ny·nz`, 1 = inside).
142/// * `grid` — Volume dimensions and voxel sizes.
143/// * `params` — See [`HcChisepParams`].
144/// * `progress` — Progress callback `(stage_done, stage_total)`.
145///
146/// # Returns
147/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
148/// χ-separation convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0
149/// (diamagnetic, signed), and `chi_total = chi_pos + chi_neg`.
150#[allow(clippy::too_many_arguments)]
151pub fn hc_chisep(
152    chi_total: &[f64],
153    r2prime: &[f64],
154    magnitude: &[f64],
155    echo_times: &[f64],
156    se_magnitude: Option<&[f64]>,
157    mask: &[u8],
158    grid: &Grid,
159    params: &HcChisepParams,
160    mut progress: impl FnMut(usize, usize),
161) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
162    let dims = grid.dims;
163    let n = dims.0 * dims.1 * dims.2;
164    let ne = echo_times.len();
165    assert_eq!(chi_total.len(), n, "chi_total length must match grid");
166    assert_eq!(r2prime.len(), n, "r2prime length must match grid");
167    assert_eq!(
168        magnitude.len(),
169        n * ne,
170        "magnitude must be n_voxels * n_echoes"
171    );
172    assert_eq!(mask.len(), n, "mask length must match grid");
173
174    let b0 = params.b0;
175
176    // Convert the voxel-major public inputs (i*ne + e) to echo-major contiguous
177    // per-echo volumes (e*n + i) so the spatial filters and gathers are simple.
178    let mag_em = to_echo_major(magnitude, n, ne);
179    let se_e_len = params.se_echo_times.len();
180    let se_em: Option<Vec<f64>> = match se_magnitude {
181        Some(s) if se_e_len >= 2 && s.len() == n * se_e_len => Some(to_echo_major(s, n, se_e_len)),
182        _ => None,
183    };
184
185    let n_stages = 5;
186    let mut stage = 0usize;
187    let tick = |s: &mut usize, p: &mut dyn FnMut(usize, usize)| {
188        *s += 1;
189        p(*s, n_stages);
190    };
191
192    // --- Stage 1: Dr+ self-calibration ---------------------------------------
193    let dr_default = params.dr_pos_3t * b0 / 3.0;
194    let dr_pos = {
195        let ratios: Vec<f64> = (0..n)
196            .filter(|&i| mask[i] != 0 && chi_total[i] > 0.02)
197            .map(|i| r2prime[i] / chi_total[i])
198            .collect();
199        if ratios.len() > 5000 {
200            let p5 = percentile(&ratios, 5.0);
201            if (0.3 * dr_default..=3.0 * dr_default).contains(&p5) {
202                p5
203            } else {
204                dr_default
205            }
206        } else {
207            dr_default
208        }
209    };
210    tick(&mut stage, &mut progress);
211
212    // --- Stage 2: closed-form two-source solve (non-WM branch) ---------------
213    // cf_pos = clip(R2'/Dr+, 0), cf_neg = clip(cf_pos - χ_total, 0) with the
214    // χ_total-consistency fix where the pair is inconsistent. cf_neg is a
215    // positive magnitude here.
216    let mut cf_pos = vec![0.0_f64; n];
217    let mut cf_neg = vec![0.0_f64; n];
218    for i in 0..n {
219        let p = (r2prime[i] / dr_pos).max(0.0);
220        let neg = p - chi_total[i];
221        if neg < 0.0 {
222            cf_pos[i] = chi_total[i].max(0.0);
223        } else {
224            cf_pos[i] = p;
225        }
226        cf_neg[i] = (cf_pos[i] - chi_total[i]).max(0.0);
227    }
228    tick(&mut stage, &mut progress);
229
230    // Build the anchored fitter (hollow-cylinder magnitude library).
231    let fitter = AnchoredFitter::new(echo_times, b0, &params.se_echo_times);
232    let have_se = se_em.is_some() && fitter.seml.is_some();
233    let se_e = params.se_echo_times.len();
234
235    // Brain voxel indices.
236    let bii: Vec<usize> = (0..n).filter(|&i| mask[i] != 0).collect();
237    if bii.is_empty() {
238        return finish(&cf_pos, &cf_neg, mask, n);
239    }
240    let rho_b: Vec<f64> = bii.iter().map(|&i| r2prime[i]).collect();
241
242    // --- Stage 3: WM-likeness (beat) weighting -------------------------------
243    // For each classification smoothing scale, fit the HC library and compare its
244    // SSE to a mono-exponential fit; take the elementwise-best log-ratio.
245    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
246    let mut lr_best: Option<Vec<f64>> = None;
247    for &s in &SMOOTH_CLS {
248        let m_s = smooth_stack(&mag_em, dims, ne, s);
249        let sn = gather_normalised(&m_s, &bii, ne);
250        let (_t, _m, sse_hc) = fitter.fit(&sn, &rho_b, None, None, params.bin_hz);
251        let mut num = sse_hc;
252        let mut den: Vec<f64> = (0..bii.len())
253            .map(|v| mono_sse(&sn[v * ne..v * ne + ne], echo_times))
254            .collect();
255        if have_se {
256            let se_s = smooth_stack(se_em.as_ref().unwrap(), dims, se_e, s);
257            let sen = gather_normalised(&se_s, &bii, se_e);
258            let seml = fitter.seml.as_ref().unwrap();
259            for v in 0..bii.len() {
260                // min over MWF of SE pool SSE
261                let mut best = f64::INFINITY;
262                for mi in 0..NM {
263                    let mut acc = 0.0;
264                    for e in 0..se_e {
265                        let d = sen[v * se_e + e] - seml[mi * se_e + e];
266                        acc += d * d;
267                    }
268                    best = best.min(acc);
269                }
270                num[v] += best;
271                den[v] += mono_sse(&sen[v * se_e..v * se_e + se_e], &params.se_echo_times);
272            }
273        }
274        let lr: Vec<f64> = (0..bii.len())
275            .map(|v| (num[v] / den[v].max(1e-12)).max(1e-6).log10())
276            .collect();
277        lr_best = Some(match lr_best {
278            None => lr,
279            Some(prev) => (0..bii.len()).map(|v| prev[v].min(lr[v])).collect(),
280        });
281    }
282    let lr_best = lr_best.unwrap();
283
284    // lr_med = median_filter(volmap(lr_best, fill=10.0), 3)
285    let lr_vol = volmap(&lr_best, &bii, n, 10.0);
286    let lr_med = median_filter_3(&lr_vol, dims);
287
288    // chs = NC-smoothed χ_total (gaussian(χ·mask,1)/gaussian(mask,1)).
289    let chi_masked: Vec<f64> = (0..n).map(|i| chi_total[i] * maskf[i]).collect();
290    let chs_num = gaussian_filter_3d(&chi_masked, dims, 1.0);
291    let chs_den = gaussian_filter_3d(&maskf, dims, 1.0);
292    let chs: Vec<f64> = (0..n).map(|i| chs_num[i] / chs_den[i].max(1e-6)).collect();
293
294    // w = sigmoid(lr) * sigmoid(chi), masked.
295    let mut w = vec![0.0_f64; n];
296    for i in 0..n {
297        let s1 = 1.0 / (1.0 + ((lr_med[i] - W_CENTER) / W_SCALE).exp());
298        let s2 = 1.0 / (1.0 + ((chs[i] - CHI_GATE_C) / CHI_GATE_S).exp());
299        w[i] = s1 * s2 * maskf[i];
300    }
301    let beat_frac = {
302        let cnt = bii.iter().filter(|&&i| w[i] > 0.5).count();
303        cnt as f64 / bii.len() as f64
304    };
305    tick(&mut stage, &mut progress);
306    if beat_frac < NO_BEAT_FRAC {
307        // No detectable beat anywhere → closed-form everywhere.
308        return finish(&cf_pos, &cf_neg, mask, n);
309    }
310
311    // --- Stage 4: θ + MWF grid fit on supported voxels -----------------------
312    let sii: Vec<usize> = bii.iter().cloned().filter(|&i| w[i] > W_SUPPORT).collect();
313    let rho_s: Vec<f64> = sii.iter().map(|&i| r2prime[i]).collect();
314
315    let m_fit = smooth_stack(&mag_em, dims, ne, SMOOTH_FIT);
316    let sn_sm = gather_normalised(&m_fit, &sii, ne);
317    let sn_raw = gather_normalised(&mag_em, &sii, ne);
318    let (sen_sm, sen_raw) = if have_se {
319        let se = se_em.as_ref().unwrap();
320        let se_fit = smooth_stack(se, dims, se_e, SMOOTH_FIT);
321        (
322            Some(gather_normalised(&se_fit, &sii, se_e)),
323            Some(gather_normalised(se, &sii, se_e)),
324        )
325    } else {
326        (None, None)
327    };
328
329    // θ from the smoothed magnitude, spatially regularised (median, fill 45°).
330    let (th_sm, _m_sm, _s_sm) = fitter.fit(&sn_sm, &rho_s, sen_sm.as_deref(), None, params.bin_hz);
331    // Non-supported voxels filled with 45° before the median (matches the ref).
332    let th_fill = volmap(&th_sm, &sii, n, 45.0);
333    let th_reg = median_filter_3(&th_fill, dims);
334    let th_pin: Vec<f64> = sii.iter().map(|&i| th_reg[i]).collect();
335
336    // MWF from raw and smoothed magnitude, θ pinned; average with the physics bound.
337    let (_t1, mw_raw, _s1) = fitter.fit(
338        &sn_raw,
339        &rho_s,
340        sen_raw.as_deref(),
341        Some(&th_pin),
342        params.bin_hz,
343    );
344    let (_t2, mw_sm, _s2) = fitter.fit(
345        &sn_sm,
346        &rho_s,
347        sen_sm.as_deref(),
348        Some(&th_pin),
349        params.bin_hz,
350    );
351    let mw_est: Vec<f64> = (0..sii.len())
352        .map(|v| {
353            let bnd = (-chi_total[sii[v]] / K_CHI).clamp(MWF_MIN, MWF_MAX);
354            0.5 * (mw_raw[v].max(bnd) + mw_sm[v].max(bnd))
355        })
356        .collect();
357    let mw_vol = volmap(&mw_est, &sii, n, 0.0);
358
359    // Confidence-weighted normalised convolution of MWF.
360    let wv: Vec<f64> = w.iter().map(|&x| x.clamp(0.0, 1.0)).collect();
361    let mw_w: Vec<f64> = (0..n).map(|i| mw_vol[i] * wv[i]).collect();
362    let mw_num = gaussian_filter_3d(&mw_w, dims, NCONV_SIGMA);
363    let mw_den = gaussian_filter_3d(&wv, dims, NCONV_SIGMA);
364    let mw_reg: Vec<f64> = (0..n).map(|i| mw_num[i] / mw_den[i].max(1e-3)).collect();
365    tick(&mut stage, &mut progress);
366
367    // --- Stage 5: separation + blend -----------------------------------------
368    let route: Vec<f64> = (0..n).map(|i| chi_total[i] + K_CHI * mw_reg[i]).collect();
369    let conf: Vec<usize> = (0..n).filter(|&i| w[i] > 0.5).collect();
370    let c0 = if conf.len() > 100 {
371        let vals: Vec<f64> = conf.iter().map(|&i| route[i].max(0.0)).collect();
372        median(&vals)
373    } else {
374        0.005
375    };
376    let mut chi_pos = vec![0.0_f64; n];
377    let mut chi_neg = vec![0.0_f64; n];
378    let mut chi_out = vec![0.0_f64; n];
379    for i in 0..n {
380        if mask[i] == 0 {
381            continue;
382        }
383        let wm_pos = (LAM * route[i] + (1.0 - LAM) * c0).max(0.0);
384        let wm_neg = (wm_pos - chi_total[i]).max(0.0);
385        let pos = w[i] * wm_pos + (1.0 - w[i]) * cf_pos[i];
386        let neg = w[i] * wm_neg + (1.0 - w[i]) * cf_neg[i];
387        chi_pos[i] = pos;
388        chi_neg[i] = -neg; // signed χ− ≤ 0
389        chi_out[i] = pos - neg;
390    }
391    tick(&mut stage, &mut progress);
392    (chi_pos, chi_neg, chi_out)
393}
394
395/// Emit the closed-form branch as the final result (χ+ ≥ 0, χ− ≤ 0 signed).
396fn finish(cf_pos: &[f64], cf_neg: &[f64], mask: &[u8], n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
397    let mut chi_pos = vec![0.0_f64; n];
398    let mut chi_neg = vec![0.0_f64; n];
399    let mut chi_out = vec![0.0_f64; n];
400    for i in 0..n {
401        if mask[i] != 0 {
402            chi_pos[i] = cf_pos[i];
403            chi_neg[i] = -cf_neg[i];
404            chi_out[i] = cf_pos[i] - cf_neg[i];
405        }
406    }
407    (chi_pos, chi_neg, chi_out)
408}
409
410// ---------------------------------------------------------------------------
411// Hollow-cylinder model + anchored grid-search fitter.
412// ---------------------------------------------------------------------------
413
414/// Compartment frequencies (Hz) for fibre-to-B0 angle `theta` (radians).
415/// Returns `(Δf_myelin, Δf_axon, Δf_extra)`; extra-axonal is exactly 0.
416fn hc_compartment_freqs(theta: f64, b0: f64) -> (f64, f64) {
417    let s2 = theta.sin() * theta.sin();
418    let w0 = GAMMA_BAR * b0;
419    let ln_term = 0.75 * CHI_A * (1.0 / G_RATIO).ln() * s2;
420    let f_my = w0
421        * (CHI_I * (2.0 / 3.0 - s2) / 2.0
422            + CHI_A * (1.0 / 12.0 - 5.0 / 12.0 * s2)
423            + ln_term
424            + E_EXCH);
425    let f_ax = w0 * ln_term;
426    (f_my, f_ax)
427}
428
429/// |complex hollow-cylinder GRE magnitude| at echo time `te` (s), angle `theta`
430/// (rad), myelin-water fraction `mwf`, and mesoscopic reversible rate `r2p_meso`.
431fn hc_wm_signal_mag(te: f64, theta: f64, b0: f64, mwf: f64, r2p_meso: f64) -> f64 {
432    let f_m = mwf;
433    let rest = 1.0 - f_m;
434    let f_a = rest * F_AXON;
435    let f_e = rest * (1.0 - F_AXON);
436    let (dfm, dfa) = hc_compartment_freqs(theta, b0);
437    let (mut re, mut im) = (0.0, 0.0);
438    let terms = [(f_m, T2_M, dfm), (f_a, T2_A, dfa), (f_e, T2_E, 0.0)];
439    for (f, t2, df) in terms {
440        let amp = f * (-te / t2).exp();
441        let ph = 2.0 * PI * df * te;
442        re += amp * ph.cos();
443        im += amp * ph.sin();
444    }
445    let mag = (re * re + im * im).sqrt();
446    mag * (-r2p_meso * te).exp()
447}
448
449/// Spin-echo WM magnitude factor (offsets refocused → real pool-T2 mixture).
450fn hc_wm_se_signal(te: f64, mwf: f64) -> f64 {
451    let rest = 1.0 - mwf;
452    mwf * (-te / T2_M).exp()
453        + rest * F_AXON * (-te / T2_A).exp()
454        + rest * (1.0 - F_AXON) * (-te / T2_E).exp()
455}
456
457/// Mono-exp-equivalent reversible rate the beat itself contributes (Hz):
458/// weighted-LS slope of `log(|GRE|/SE)` vs centred TE, negated and clamped ≥ 0.
459fn hc_wm_r2prime(theta: f64, mwf: f64, tes: &[f64], b0: f64) -> f64 {
460    let tbar = tes.iter().sum::<f64>() / tes.len() as f64;
461    let denom: f64 = tes.iter().map(|&t| (t - tbar) * (t - tbar)).sum();
462    let mut acc = 0.0;
463    for &te in tes {
464        let gre = hc_wm_signal_mag(te, theta, b0, mwf, 0.0);
465        let se = hc_wm_se_signal(te, mwf).max(1e-12);
466        let ratio = (gre / se).max(1e-12);
467        acc += ratio.ln() * (te - tbar);
468    }
469    (-acc / denom).max(0.0)
470}
471
472/// Precomputed hollow-cylinder magnitude library over the (θ, MWF) grid.
473struct AnchoredFitter {
474    e: usize,
475    /// `Pn[ (ti*NM + mi)*E + e ]` — first-echo-normalised magnitude library.
476    pn: Vec<f64>,
477    /// `H[ ti*NM + mi ]` — R2'_hc per grid point (Hz).
478    h: Vec<f64>,
479    /// `dte[e] = TE[e] - TE[0]`.
480    dte: Vec<f64>,
481    /// Optional SE library `SEml[ mi*se_e + e ]` (first-SE-normalised).
482    seml: Option<Vec<f64>>,
483}
484
485impl AnchoredFitter {
486    fn new(tes: &[f64], b0: f64, se_tes: &[f64]) -> Self {
487        let e = tes.len();
488        let mut pn = vec![0.0_f64; NT * NM * e];
489        let mut h = vec![0.0_f64; NT * NM];
490        for ti in 0..NT {
491            let theta = (ti as f64 * 1.5).to_radians();
492            for mi in 0..NM {
493                let mwf = MWF_MIN + mi as f64 * 0.005;
494                let base = (ti * NM + mi) * e;
495                let first = hc_wm_signal_mag(tes[0], theta, b0, mwf, 0.0).max(1e-30);
496                for (k, &te) in tes.iter().enumerate() {
497                    pn[base + k] = hc_wm_signal_mag(te, theta, b0, mwf, 0.0) / first;
498                }
499                h[ti * NM + mi] = hc_wm_r2prime(theta, mwf, tes, b0);
500            }
501        }
502        let dte: Vec<f64> = tes.iter().map(|&t| t - tes[0]).collect();
503        let seml = if se_tes.len() >= 2 {
504            let se_e = se_tes.len();
505            let mut m = vec![0.0_f64; NM * se_e];
506            for mi in 0..NM {
507                let mwf = MWF_MIN + mi as f64 * 0.005;
508                let first = hc_wm_se_signal(se_tes[0], mwf).max(1e-30);
509                for (k, &te) in se_tes.iter().enumerate() {
510                    m[mi * se_e + k] = hc_wm_se_signal(te, mwf) / first;
511                }
512            }
513            Some(m)
514        } else {
515            None
516        };
517        Self {
518            e,
519            pn,
520            h,
521            dte,
522            seml,
523        }
524    }
525
526    /// Anchored grid search. `sig_n` is `(N, E)` first-echo-normalised; `rho` is
527    /// R2' per voxel (Hz). Returns `(theta_deg, mwf, sse)` per voxel.
528    fn fit(
529        &self,
530        sig_n: &[f64],
531        rho: &[f64],
532        se_n: Option<&[f64]>,
533        theta_pin: Option<&[f64]>,
534        bin_hz: f64,
535    ) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
536        let e = self.e;
537        let n = rho.len();
538        let l = NT * NM;
539
540        // Per-voxel SE SSE (N, NM), if SE evidence is present.
541        let se_e = se_n.map(|s| s.len() / n).unwrap_or(0);
542        let sse_se: Option<Vec<f64>> = match (se_n, &self.seml) {
543            (Some(sn), Some(seml)) => {
544                let mut out = vec![0.0_f64; n * NM];
545                for v in 0..n {
546                    for mi in 0..NM {
547                        let mut acc = 0.0;
548                        for k in 0..se_e {
549                            let d = sn[v * se_e + k] - seml[mi * se_e + k];
550                            acc += d * d;
551                        }
552                        out[v * NM + mi] = acc;
553                    }
554                }
555                Some(out)
556            }
557            _ => None,
558        };
559
560        // Group voxel indices by rho bin.
561        use std::collections::HashMap;
562        let mut groups: HashMap<i64, Vec<usize>> = HashMap::new();
563        for (v, &r) in rho.iter().enumerate() {
564            let b = (r.clamp(0.0, 200.0) / bin_hz).round() as i64;
565            groups.entry(b).or_default().push(v);
566        }
567
568        let group_vec: Vec<(i64, Vec<usize>)> = groups.into_iter().collect();
569        // Solve each bin (parallel), scatter results back.
570        let per_bin: Vec<Vec<(usize, f64, f64, f64)>> = maybe_par_iter!(group_vec)
571            .map(|(b, vs)| {
572                // Candidate curves for this bin: Pn · exp(-max(b·bin_hz - H, 0)·dte).
573                let mut flat = vec![0.0_f64; l * e];
574                let mut m2 = vec![0.0_f64; l];
575                let mut bad = vec![false; l];
576                let rho_bin = *b as f64 * bin_hz;
577                for li in 0..l {
578                    let meso = rho_bin - self.h[li];
579                    bad[li] = meso < -1.0;
580                    let decay = meso.max(0.0);
581                    let mut acc = 0.0;
582                    for k in 0..e {
583                        let val = self.pn[li * e + k] * (-decay * self.dte[k]).exp();
584                        flat[li * e + k] = val;
585                        acc += val * val;
586                    }
587                    m2[li] = 0.5 * acc;
588                }
589                let all_bad = bad.iter().all(|&x| x);
590
591                let mut out = Vec::with_capacity(vs.len());
592                for &v in vs {
593                    let sig = &sig_n[v * e..v * e + e];
594                    let se_row = sse_se.as_ref().map(|s| &s[v * NM..v * NM + NM]);
595                    let pin_row = theta_pin.map(|tp| {
596                        ((tp[v] / 1.5).round() as isize).clamp(0, NT as isize - 1) as usize
597                    });
598
599                    let (mut best_score, mut best_l) = (f64::NEG_INFINITY, 0usize);
600                    let (lo, hi) = match pin_row {
601                        Some(r) => (r * NM, r * NM + NM),
602                        None => (0, l),
603                    };
604                    for li in lo..hi {
605                        if bad[li] && !all_bad {
606                            continue;
607                        }
608                        let mut s = -m2[li];
609                        for k in 0..e {
610                            s += sig[k] * flat[li * e + k];
611                        }
612                        if let Some(se_row) = se_row {
613                            s -= 0.5 * se_row[li % NM];
614                        }
615                        if s > best_score {
616                            best_score = s;
617                            best_l = li;
618                        }
619                    }
620                    // True SSE at the winner.
621                    let mut sse = 0.0;
622                    for k in 0..e {
623                        let d = sig[k] - flat[best_l * e + k];
624                        sse += d * d;
625                    }
626                    let theta_deg = (best_l / NM) as f64 * 1.5;
627                    let mwf = MWF_MIN + (best_l % NM) as f64 * 0.005;
628                    out.push((v, theta_deg, mwf, sse));
629                }
630                out
631            })
632            .collect();
633
634        let mut out_t = vec![0.0_f64; n];
635        let mut out_m = vec![MWF_REF; n];
636        let mut out_s = vec![f64::INFINITY; n];
637        for bin in per_bin {
638            for (v, t, m, s) in bin {
639                out_t[v] = t;
640                out_m[v] = m;
641                out_s[v] = s;
642            }
643        }
644        (out_t, out_m, out_s)
645    }
646}
647
648/// Mono-exponential fit SSE for one voxel's first-echo-normalised signal.
649fn mono_sse(sig: &[f64], tes: &[f64]) -> f64 {
650    let e = tes.len();
651    let tbar = tes.iter().sum::<f64>() / e as f64;
652    let logs: Vec<f64> = sig.iter().map(|&s| s.max(1e-9).ln()).collect();
653    let den: f64 = tes.iter().map(|&t| (t - tbar) * (t - tbar)).sum();
654    let b: f64 = (0..e).map(|k| logs[k] * (tes[k] - tbar)).sum::<f64>() / den;
655    let a: f64 = logs.iter().sum::<f64>() / e as f64;
656    (0..e)
657        .map(|k| {
658            let pred = (a + b * (tes[k] - tbar)).exp();
659            let d = sig[k] - pred;
660            d * d
661        })
662        .sum()
663}
664
665/// Convert a voxel-major multi-echo stack `vm[i*ne + e]` to echo-major
666/// (contiguous per-echo volumes) `em[e*n + i]`.
667fn to_echo_major(vm: &[f64], n: usize, ne: usize) -> Vec<f64> {
668    let mut out = vec![0.0_f64; n * ne];
669    for i in 0..n {
670        for e in 0..ne {
671            out[e * n + i] = vm[i * ne + e];
672        }
673    }
674    out
675}
676
677/// Gather `(len, E)` first-echo-normalised signals for the given voxel indices
678/// from an echo-major volume stack `stack[e*n + i]`.
679fn gather_normalised(stack: &[f64], idx: &[usize], ne: usize) -> Vec<f64> {
680    let n = stack.len() / ne;
681    let mut out = vec![0.0_f64; idx.len() * ne];
682    for (k, &i) in idx.iter().enumerate() {
683        let first = stack[i].max(1e-9); // e = 0
684        for e in 0..ne {
685            out[k * ne + e] = stack[e * n + i] / first;
686        }
687    }
688    out
689}
690
691/// Scatter per-voxel values back into a full volume, filling the rest with `fill`.
692fn volmap(vals: &[f64], idx: &[usize], n: usize, fill: f64) -> Vec<f64> {
693    let mut out = vec![fill; n];
694    for (k, &i) in idx.iter().enumerate() {
695        out[i] = vals[k];
696    }
697    out
698}
699
700// ---------------------------------------------------------------------------
701// Spatial filters (scipy-matching: reflect boundary).
702// ---------------------------------------------------------------------------
703
704/// Reflect an index into `[0, len)` using scipy's `reflect` mode (edge repeated).
705#[inline]
706fn reflect_index(mut p: isize, len: usize) -> usize {
707    let l = len as isize;
708    loop {
709        if p < 0 {
710            p = -p - 1;
711        } else if p >= l {
712            p = 2 * l - p - 1;
713        } else {
714            return p as usize;
715        }
716    }
717}
718
719/// Smooth each echo of an echo-major stack `stack[e*n + i]` by a 3D Gaussian.
720fn smooth_stack(stack: &[f64], dims: (usize, usize, usize), ne: usize, sigma: f64) -> Vec<f64> {
721    let n = dims.0 * dims.1 * dims.2;
722    let mut out = vec![0.0_f64; n * ne];
723    for e in 0..ne {
724        let sm = gaussian_filter_3d(&stack[e * n..e * n + n], dims, sigma);
725        out[e * n..e * n + n].copy_from_slice(&sm);
726    }
727    out
728}
729
730/// 3D separable Gaussian filter, scipy `gaussian_filter` semantics: `reflect`
731/// boundary, `truncate = 4.0`.
732fn gaussian_filter_3d(data: &[f64], dims: (usize, usize, usize), sigma: f64) -> Vec<f64> {
733    if sigma <= 0.0 {
734        return data.to_vec();
735    }
736    let (nx, ny, nz) = dims;
737    let radius = (4.0 * sigma + 0.5) as usize;
738    let mut kernel = vec![0.0_f64; 2 * radius + 1];
739    let mut ksum = 0.0;
740    for (k, w) in kernel.iter_mut().enumerate() {
741        let x = k as f64 - radius as f64;
742        *w = (-0.5 * (x / sigma) * (x / sigma)).exp();
743        ksum += *w;
744    }
745    for w in kernel.iter_mut() {
746        *w /= ksum;
747    }
748
749    let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
750    let mut a = data.to_vec();
751    let mut b = vec![0.0_f64; a.len()];
752
753    // X axis.
754    for z in 0..nz {
755        for y in 0..ny {
756            for x in 0..nx {
757                let mut s = 0.0;
758                for (kk, &w) in kernel.iter().enumerate() {
759                    let xi = reflect_index(x as isize + kk as isize - radius as isize, nx);
760                    s += w * a[idx(xi, y, z)];
761                }
762                b[idx(x, y, z)] = s;
763            }
764        }
765    }
766    std::mem::swap(&mut a, &mut b);
767    // Y axis.
768    for z in 0..nz {
769        for y in 0..ny {
770            for x in 0..nx {
771                let mut s = 0.0;
772                for (kk, &w) in kernel.iter().enumerate() {
773                    let yi = reflect_index(y as isize + kk as isize - radius as isize, ny);
774                    s += w * a[idx(x, yi, z)];
775                }
776                b[idx(x, y, z)] = s;
777            }
778        }
779    }
780    std::mem::swap(&mut a, &mut b);
781    // Z axis.
782    for z in 0..nz {
783        for y in 0..ny {
784            for x in 0..nx {
785                let mut s = 0.0;
786                for (kk, &w) in kernel.iter().enumerate() {
787                    let zi = reflect_index(z as isize + kk as isize - radius as isize, nz);
788                    s += w * a[idx(x, y, zi)];
789                }
790                b[idx(x, y, z)] = s;
791            }
792        }
793    }
794    b
795}
796
797/// 3D median filter with a 3×3×3 window (scipy `median_filter` size 3, `reflect`).
798fn median_filter_3(data: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
799    let (nx, ny, nz) = dims;
800    let idx = |x: usize, y: usize, z: usize| (z * ny + y) * nx + x;
801    let mut out = vec![0.0_f64; data.len()];
802    for z in 0..nz {
803        for y in 0..ny {
804            for x in 0..nx {
805                let mut win = [0.0_f64; 27];
806                let mut c = 0;
807                for dz in -1..=1_isize {
808                    let zi = reflect_index(z as isize + dz, nz);
809                    for dy in -1..=1_isize {
810                        let yi = reflect_index(y as isize + dy, ny);
811                        for dx in -1..=1_isize {
812                            let xi = reflect_index(x as isize + dx, nx);
813                            win[c] = data[idx(xi, yi, zi)];
814                            c += 1;
815                        }
816                    }
817                }
818                win.sort_by(|a, b| a.partial_cmp(b).unwrap());
819                out[idx(x, y, z)] = win[13];
820            }
821        }
822    }
823    out
824}
825
826// ---------------------------------------------------------------------------
827// Small statistics helpers.
828// ---------------------------------------------------------------------------
829
830/// numpy-style linear-interpolation percentile (`q` in [0, 100]).
831fn percentile(vals: &[f64], q: f64) -> f64 {
832    if vals.is_empty() {
833        return 0.0;
834    }
835    let mut v = vals.to_vec();
836    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
837    let rank = q / 100.0 * (v.len() as f64 - 1.0);
838    let lo = rank.floor() as usize;
839    let hi = rank.ceil() as usize;
840    if lo == hi {
841        v[lo]
842    } else {
843        let frac = rank - lo as f64;
844        v[lo] * (1.0 - frac) + v[hi] * frac
845    }
846}
847
848fn median(vals: &[f64]) -> f64 {
849    if vals.is_empty() {
850        return 0.0;
851    }
852    let mut v = vals.to_vec();
853    v.sort_by(|a, b| a.partial_cmp(b).unwrap());
854    let m = v.len() / 2;
855    if v.len() % 2 == 1 {
856        v[m]
857    } else {
858        0.5 * (v[m - 1] + v[m])
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn reflect_index_matches_scipy() {
868        // scipy 'reflect' (edge repeated): [-2,-1,0,1,2,3,4,5] over len=4
869        // -> [1,0,0,1,2,3,3,2]
870        let len = 4;
871        let got: Vec<usize> = (-2..6).map(|p| reflect_index(p, len)).collect();
872        assert_eq!(got, vec![1, 0, 0, 1, 2, 3, 3, 2]);
873    }
874
875    #[test]
876    fn gaussian_preserves_constant() {
877        let dims = (8, 8, 8);
878        let n = 8 * 8 * 8;
879        let data = vec![3.0_f64; n];
880        let out = gaussian_filter_3d(&data, dims, 1.0);
881        for &v in &out {
882            assert!((v - 3.0).abs() < 1e-9, "constant not preserved: {v}");
883        }
884    }
885
886    #[test]
887    fn median_removes_impulse() {
888        let dims = (5, 5, 5);
889        let n = 125;
890        let mut data = vec![1.0_f64; n];
891        let idx = |x: usize, y: usize, z: usize| (z * 5 + y) * 5 + x;
892        data[idx(2, 2, 2)] = 100.0; // single impulse
893        let out = median_filter_3(&data, dims);
894        assert!(
895            (out[idx(2, 2, 2)] - 1.0).abs() < 1e-9,
896            "impulse survived median"
897        );
898    }
899
900    #[test]
901    fn percentile_and_median_basic() {
902        let v = [1.0, 2.0, 3.0, 4.0];
903        assert!((percentile(&v, 0.0) - 1.0).abs() < 1e-12);
904        assert!((percentile(&v, 100.0) - 4.0).abs() < 1e-12);
905        assert!((median(&v) - 2.5).abs() < 1e-12);
906        let v2 = [1.0, 2.0, 3.0];
907        assert!((median(&v2) - 2.0).abs() < 1e-12);
908    }
909
910    /// The R2'-anchored fitter should recover a planted (θ, MWF) from a synthetic
911    /// hollow-cylinder magnitude, given the matching R2'.
912    #[test]
913    fn fitter_recovers_planted_theta_mwf() {
914        let tes = [0.004, 0.012, 0.020, 0.028];
915        let b0 = 7.0;
916        let fitter = AnchoredFitter::new(&tes, b0, &[]);
917
918        let theta_deg = 30.0_f64;
919        let mwf = 0.10_f64;
920        let theta = theta_deg.to_radians();
921        // R2' = H(θ,mwf) + a chosen mesoscopic rate.
922        let meso = 8.0;
923        let h = hc_wm_r2prime(theta, mwf, &tes, b0);
924        let rho = h + meso;
925        // Build the first-echo-normalised magnitude with that meso decay.
926        let first = hc_wm_signal_mag(tes[0], theta, b0, mwf, meso);
927        let sig: Vec<f64> = tes
928            .iter()
929            .map(|&t| hc_wm_signal_mag(t, theta, b0, mwf, meso) / first)
930            .collect();
931
932        let (t, m, _s) = fitter.fit(&sig, &[rho], None, None, 0.25);
933        // MWF is well-determined; θ is only weakly identifiable from magnitude at a
934        // few echoes (near-ties across neighbouring angles), which is why the full
935        // algorithm heavily regularises and pins θ — so tolerate a loose θ here.
936        assert!((m[0] - mwf).abs() <= 0.02, "mwf {} vs {}", m[0], mwf);
937        assert!(
938            (t[0] - theta_deg).abs() <= 12.0,
939            "theta {} vs {}",
940            t[0],
941            theta_deg
942        );
943    }
944    /// `(chi_total, r2prime, mag_vm, se_mag_vm, mask)`, magnitudes voxel-major `(n, ne)`.
945    type SynthPhantom = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<u8>);
946
947
948    /// Build a synthetic phantom: voxels with `x < wm_x_max` are WM-like (hollow-
949    /// cylinder beat magnitude, diamagnetic χ_total, R2' = H+meso so the anchored
950    /// fit matches), the rest GM-like (mono-exponential magnitude, paramagnetic
951    /// χ_total, R2' = Dr+·χ). Returns `(chi_total, r2prime, mag_vm, se_mag_vm, mask)`
952    /// with magnitudes in voxel-major `(n, ne)` layout.
953    fn synth_phantom(
954        dims: (usize, usize, usize),
955        wm_x_max: usize,
956        b0: f64,
957        tes: &[f64],
958        se_tes: &[f64],
959    ) -> SynthPhantom {
960        let (nx, ny, nz) = dims;
961        let n = nx * ny * nz;
962        let ne = tes.len();
963        let se_e = se_tes.len();
964        let dr = 137.0 * b0 / 3.0;
965        let theta = 40.0_f64.to_radians();
966        let mwf = 0.12;
967        let meso = 6.0;
968        let h = hc_wm_r2prime(theta, mwf, tes, b0);
969        let r2star = 25.0;
970
971        let mut chi = vec![0.0_f64; n];
972        let mut r2p = vec![0.0_f64; n];
973        let mut mag = vec![0.0_f64; n * ne];
974        let mut se = vec![0.0_f64; n * se_e];
975        let mask = vec![1u8; n];
976        for k in 0..nz {
977            for j in 0..ny {
978                for i in 0..nx {
979                    let idx = (k * ny + j) * nx + i;
980                    if i < wm_x_max {
981                        chi[idx] = -0.03;
982                        r2p[idx] = h + meso;
983                        for (e, &t) in tes.iter().enumerate() {
984                            mag[idx * ne + e] = hc_wm_signal_mag(t, theta, b0, mwf, meso);
985                        }
986                        for (e, &t) in se_tes.iter().enumerate() {
987                            se[idx * se_e + e] = hc_wm_se_signal(t, mwf);
988                        }
989                    } else {
990                        chi[idx] = 0.05;
991                        r2p[idx] = dr * 0.05;
992                        for (e, &t) in tes.iter().enumerate() {
993                            mag[idx * ne + e] = (-r2star * t).exp();
994                        }
995                        for (e, &t) in se_tes.iter().enumerate() {
996                            se[idx * se_e + e] = (-r2star * t).exp();
997                        }
998                    }
999                }
1000            }
1001        }
1002        (chi, r2p, mag, se, mask)
1003    }
1004
1005    /// Full pipeline (with SE evidence): WM slab triggers the beat branch (stages
1006    /// 3-5), GM uses the closed form; exercises Dr+ self-calibration on a >5000
1007    /// paramagnetic-voxel pool.
1008    #[test]
1009    fn hc_chisep_end_to_end_wm_and_gm() {
1010        let tes = [0.004, 0.012, 0.020, 0.028];
1011        let se_tes = [0.01, 0.03, 0.05, 0.07];
1012        let dims = (20, 20, 20);
1013        let n = dims.0 * dims.1 * dims.2;
1014        let (chi, r2p, mag, se, mask) = synth_phantom(dims, 6, 7.0, &tes, &se_tes);
1015        let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1016        let params = HcChisepParams { b0: 7.0, se_echo_times: se_tes.to_vec(), ..Default::default() };
1017
1018        let mut stages = 0;
1019        let (pos, neg, tot) = hc_chisep(
1020            &chi, &r2p, &mag, &tes, Some(&se), &mask, &grid, &params, |i, _| stages = i,
1021        );
1022        assert_eq!(stages, 5, "all pipeline stages ran (beat detected)");
1023
1024        // Sign convention + invariant everywhere.
1025        for i in 0..n {
1026            assert!(pos[i] >= 0.0 && neg[i] <= 0.0, "signs at {i}");
1027            assert!((tot[i] - (pos[i] + neg[i])).abs() < 1e-9, "invariant at {i}");
1028        }
1029        // Separation happened: paramagnetic in GM, diamagnetic in WM.
1030        assert!(pos.iter().any(|&v| v > 1e-3), "some χ+ present");
1031        assert!(neg.iter().any(|&v| v < -1e-3), "some χ− present (WM)");
1032    }
1033
1034    /// All-GM phantom: no magnitude beat → `beat_frac` below threshold → the
1035    /// closed-form branch is returned for every voxel.
1036    #[test]
1037    fn hc_chisep_no_beat_falls_back_to_closed_form() {
1038        let tes = [0.004, 0.012, 0.020, 0.028];
1039        let dims = (10, 10, 10);
1040        let n = dims.0 * dims.1 * dims.2;
1041        let (chi, r2p, mag, _se, mask) = synth_phantom(dims, 0, 7.0, &tes, &[]);
1042        let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1043        let params = HcChisepParams { b0: 7.0, ..Default::default() };
1044
1045        let (pos, neg, _tot) =
1046            hc_chisep(&chi, &r2p, &mag, &tes, None, &mask, &grid, &params, |_, _| {});
1047        // Closed form on this phantom: χ+ = R2'/Dr+ = 0.05, χ− = 0.
1048        for i in 0..n {
1049            assert!((pos[i] - 0.05).abs() < 1e-6, "closed-form χ+ at {i}: {}", pos[i]);
1050            assert!(neg[i].abs() < 1e-9, "closed-form χ− at {i}: {}", neg[i]);
1051        }
1052    }
1053
1054    /// Empty mask returns all zeros via the early exit.
1055    #[test]
1056    fn hc_chisep_empty_mask_returns_zero() {
1057        let tes = [0.004, 0.012, 0.020, 0.028];
1058        let dims = (6, 6, 6);
1059        let n = dims.0 * dims.1 * dims.2;
1060        let (chi, r2p, mag, _se, _m) = synth_phantom(dims, 0, 7.0, &tes, &[]);
1061        let mask = vec![0u8; n];
1062        let grid = Grid::new(dims.0, dims.1, dims.2, 1.0, 1.0, 1.0);
1063        let params = HcChisepParams { b0: 7.0, ..Default::default() };
1064
1065        let (pos, neg, tot) =
1066            hc_chisep(&chi, &r2p, &mag, &tes, None, &mask, &grid, &params, |_, _| {});
1067        assert!(pos.iter().all(|&v| v == 0.0), "χ+ all zero");
1068        assert!(neg.iter().all(|&v| v == 0.0), "χ− all zero");
1069        assert!(tot.iter().all(|&v| v == 0.0), "χ_total all zero");
1070    }
1071}