Skip to main content

qsm_core/utils/
epg.rs

1//! EPG-based T2/R2 mapping from multi-echo spin-echo (MESE) magnitude data.
2//!
3//! A multi-echo spin-echo (CPMG) train uses repeated refocusing pulses. Real
4//! refocusing pulses are not perfect 180° flips (B1 inhomogeneity, slice
5//! profile), so they generate stimulated and indirect echoes that add signal at
6//! later echoes. A naive mono-exponential fit (e.g. [`arlo`](crate::utils::r2star::arlo)) reads that
7//! extra signal as slower decay and therefore *overestimates* T2 (underestimates
8//! R2). The Extended Phase Graph (EPG) formalism models the full echo train,
9//! including the stimulated-echo pathways, as a function of `(T2, T1, B1)` and so
10//! removes that bias.
11//!
12//! This module provides:
13//! - [`epg_cpmg_echoes`]: the forward model — simulated CPMG echo amplitudes.
14//! - [`r2_epg`]: per-voxel R2 mapping by EPG dictionary matching.
15//! - [`r2prime`]: R2' = R2* − R2 (the input chi-separation needs).
16//!
17//! When the refocusing flip angle is exactly 180° (`b1 = 1.0`), the EPG echo
18//! train reduces exactly to the mono-exponential `S(TE_n) = exp(-TE_n / T2)`,
19//! so on perfectly-refocused data EPG and ARLO agree (see the unit tests).
20//!
21//! **What the χ-separation references used for R2.** χ-sepnet and R2PRIMEnet (Kim et al. 2025,
22//! arXiv:2409.14030, §2.1.1–2.1.2) matched multi-echo SE (or dual-echo TSE) magnitude against a
23//! StimFit dictionary with stimulated-echo correction, the approach [`r2_epg`] takes, assuming
24//! B1+ = 1 for the patient data. R2' = R2* − R2 was then clamped at 0, as [`r2prime`] does.
25//! SUSEP-Net's in-vivo R2 was a plain mono-exponential STI Suite fit (arXiv:2506.13293, §3.2).
26//!
27//! References:
28//! - Weigel, M. (2015). "Extended phase graphs: dephasing, RF pulses, and echoes
29//!   — pure and simple." JMRI 41(2):266-295.
30//! - Ben-Eliezer, N., et al. (2015). "Rapid and accurate T2 mapping from
31//!   multi-spin-echo data using Bloch-simulation-based reconstruction." MRM
32//!   73(2):809-817.
33
34use num_complex::Complex64;
35use crate::error::{check_len, InputError};
36#[cfg(feature = "parallel")]
37use rayon::prelude::*;
38
39const PI: f64 = std::f64::consts::PI;
40
41/// EPG configuration state: three complex vectors indexed by dephasing order
42/// `k = 0..=k_max`. `fp[k]` is the F+ (positive dephasing) coherence at order
43/// `k`, `fm[k]` the F- (negative dephasing) coherence, and `z[k]` the
44/// longitudinal coherence.
45struct EpgState {
46    fp: Vec<Complex64>,
47    fm: Vec<Complex64>,
48    z: Vec<Complex64>,
49}
50
51impl EpgState {
52    fn new(k_max: usize) -> Self {
53        let zero = Complex64::new(0.0, 0.0);
54        EpgState {
55            fp: vec![zero; k_max + 1],
56            fm: vec![zero; k_max + 1],
57            z: vec![zero; k_max + 1],
58        }
59    }
60
61    /// Relaxation + longitudinal regrowth (toward M0 = 1) over one interval.
62    /// `e1 = exp(-dt/T1)`, `e2 = exp(-dt/T2)`.
63    #[inline]
64    fn relax(&mut self, e1: f64, e2: f64) {
65        for v in self.fp.iter_mut() {
66            *v *= e2;
67        }
68        for v in self.fm.iter_mut() {
69            *v *= e2;
70        }
71        for v in self.z.iter_mut() {
72            *v *= e1;
73        }
74        self.z[0] += 1.0 - e1;
75    }
76
77    /// Unit positive dephasing gradient: shift coherence orders. F+ moves to
78    /// higher orders, F- to lower orders, and the observable F+(0) is refilled
79    /// from conj(F-(0)) (Weigel 2015; Hargreaves EPG `epg_grad`).
80    #[inline]
81    fn grad(&mut self) {
82        let k_max = self.fp.len() - 1;
83        // F+ up: fp[k] = fp[k-1]
84        for k in (1..=k_max).rev() {
85            self.fp[k] = self.fp[k - 1];
86        }
87        // F- down: fm[k] = fm[k+1]
88        for k in 0..k_max {
89            self.fm[k] = self.fm[k + 1];
90        }
91        self.fm[k_max] = Complex64::new(0.0, 0.0);
92        // Refill the observable from the conjugate-symmetric partner.
93        self.fp[0] = self.fm[0].conj();
94    }
95
96    /// Instantaneous RF pulse of flip angle `alpha` (rad) and phase `phi` (rad),
97    /// applied to every coherence order (Weigel 2015, Hargreaves `epg_rf`).
98    #[inline]
99    fn rf(&mut self, alpha: f64, phi: f64) {
100        let c = (alpha / 2.0).cos();
101        let s = (alpha / 2.0).sin();
102        let c2 = c * c;
103        let s2 = s * s;
104        let sa = alpha.sin();
105        let ca = alpha.cos();
106        let i = Complex64::i();
107        let eip = Complex64::from_polar(1.0, phi); // e^{i phi}
108        let ei2p = Complex64::from_polar(1.0, 2.0 * phi); // e^{2i phi}
109
110        let m00 = Complex64::new(c2, 0.0);
111        let m01 = ei2p * s2;
112        let m02 = -i * eip * sa;
113        let m10 = ei2p.conj() * s2;
114        let m11 = Complex64::new(c2, 0.0);
115        let m12 = i * eip.conj() * sa;
116        let m20 = -i * 0.5 * eip.conj() * sa;
117        let m21 = i * 0.5 * eip * sa;
118        let m22 = Complex64::new(ca, 0.0);
119
120        for k in 0..self.fp.len() {
121            let a = self.fp[k];
122            let b = self.fm[k];
123            let d = self.z[k];
124            self.fp[k] = m00 * a + m01 * b + m02 * d;
125            self.fm[k] = m10 * a + m11 * b + m12 * d;
126            self.z[k] = m20 * a + m21 * b + m22 * d;
127        }
128    }
129}
130
131/// Simulate the CPMG multi-echo spin-echo magnitude train.
132///
133/// Models a 90° excitation followed by `n_echoes` refocusing pulses of nominal
134/// 180° scaled by `b1` (so the actual refocusing flip is `b1 * 180°`), spaced by
135/// echo spacing `esp` (seconds). Echoes form at `TE_n = n * esp`.
136///
137/// # Arguments
138/// * `t2` - Transverse relaxation time (seconds)
139/// * `t1` - Longitudinal relaxation time (seconds)
140/// * `b1` - Refocusing efficiency: fraction of the nominal 180° flip (1.0 = perfect)
141/// * `esp` - Echo spacing (seconds)
142/// * `n_echoes` - Number of echoes to simulate
143///
144/// # Returns
145/// `n_echoes` echo magnitudes, normalized so equilibrium magnetization M0 = 1.
146///
147/// With `b1 = 1.0` this returns exactly `exp(-n * esp / t2)` (perfect refocusing).
148pub fn epg_cpmg_echoes(t2: f64, t1: f64, b1: f64, esp: f64, n_echoes: usize) -> Vec<f64> {
149    // Coherence can reach up to one order per gradient (two per echo interval).
150    let k_max = 2 * n_echoes + 2;
151    let mut st = EpgState::new(k_max);
152
153    // Equilibrium then 90° excitation about y (phi = pi/2). CPMG requires the
154    // excitation and refocusing pulses to be 90° out of phase.
155    st.z[0] = Complex64::new(1.0, 0.0);
156    st.rf(PI / 2.0, PI / 2.0);
157
158    let e1 = (-esp / 2.0 / t1).exp();
159    let e2 = (-esp / 2.0 / t2).exp();
160    let alpha = b1 * PI; // refocusing flip angle (rad)
161
162    let mut echoes = Vec::with_capacity(n_echoes);
163    for _ in 0..n_echoes {
164        // First half-interval: relax + dephase.
165        st.relax(e1, e2);
166        st.grad();
167        // Refocusing pulse about x (phi = 0).
168        st.rf(alpha, 0.0);
169        // Second half-interval: relax + rephase.
170        st.relax(e1, e2);
171        st.grad();
172        // Echo forms at the observable coherence order 0.
173        echoes.push(st.fp[0].norm());
174    }
175    echoes
176}
177
178/// Parameters for [`r2_epg`] dictionary matching.
179pub struct R2EpgParams {
180    /// Assumed T1 in seconds (weak influence on the T2 estimate).
181    pub t1: f64,
182    /// Candidate T2 values (seconds) — the dictionary's T2 axis.
183    pub t2_grid: Vec<f64>,
184    /// Candidate B1 (refocusing efficiency) values — the dictionary's B1 axis.
185    /// When a B1 map is supplied to [`r2_epg`], map values are snapped to the
186    /// nearest entry of this grid (densify it for finer quantization).
187    pub b1_grid: Vec<f64>,
188}
189
190impl Default for R2EpgParams {
191    fn default() -> Self {
192        // T2 log-spaced 5 ms .. 500 ms (R2 ~ 2 .. 200 Hz).
193        let n_t2 = 150;
194        let (t2_lo, t2_hi) = (0.005_f64, 0.5_f64);
195        let ln_lo = t2_lo.ln();
196        let ln_hi = t2_hi.ln();
197        let t2_grid: Vec<f64> = (0..n_t2)
198            .map(|i| (ln_lo + (ln_hi - ln_lo) * i as f64 / (n_t2 - 1) as f64).exp())
199            .collect();
200
201        // B1 (refocusing efficiency) 0.6 .. 1.0 in steps of 0.02.
202        let n_b1 = 21;
203        let (b1_lo, b1_hi) = (0.6_f64, 1.0_f64);
204        let b1_grid: Vec<f64> = (0..n_b1)
205            .map(|i| b1_lo + (b1_hi - b1_lo) * i as f64 / (n_b1 - 1) as f64)
206            .collect();
207
208        R2EpgParams {
209            t1: 1.0,
210            t2_grid,
211            b1_grid,
212        }
213    }
214}
215
216/// One dictionary atom: a normalized echo train and its `(T2, B1)` labels.
217struct DictAtom {
218    signal: Vec<f64>, // L2-normalized echo amplitudes
219    t2: f64,
220    b1: f64,
221}
222
223/// Build the EPG dictionary for a given echo spacing and echo count, grouped by
224/// B1: `dict[i]` holds the atoms for `params.b1_grid[i]` (over all T2 values),
225/// so a fixed-B1 search can restrict itself to a single group.
226fn build_dictionary(params: &R2EpgParams, esp: f64, n_echoes: usize) -> Vec<Vec<DictAtom>> {
227    params
228        .b1_grid
229        .iter()
230        .map(|&b1| {
231            let mut group = Vec::with_capacity(params.t2_grid.len());
232            for &t2 in &params.t2_grid {
233                let mut sig = epg_cpmg_echoes(t2, params.t1, b1, esp, n_echoes);
234                let norm: f64 = sig.iter().map(|&v| v * v).sum::<f64>().sqrt();
235                if norm > 1e-30 {
236                    for v in sig.iter_mut() {
237                        *v /= norm;
238                    }
239                    group.push(DictAtom { signal: sig, t2, b1 });
240                }
241            }
242            group
243        })
244        .collect()
245}
246
247/// How far, in seconds, [`r2_epg`]'s echo times may be from `TEₙ = n·ESP` (each spacing from
248/// the first, and the first echo from the spacing): 0.1 ms, which covers the rounding of echo
249/// times in DICOM and BIDS sidecars.
250pub const EPG_TE_TOLERANCE_S: f64 = 1e-4;
251
252/// R2 mapping from multi-echo spin-echo magnitude via EPG dictionary matching.
253///
254/// For each masked voxel the (normalized) measured echo train is matched against
255/// a dictionary of EPG-simulated trains over a `(T2, B1)` grid; the R2 = 1/T2 of
256/// the best match is returned. Normalization removes the proton-density / receive
257/// gain, so only the decay *shape* is fit. This corrects the stimulated-echo bias
258/// that a mono-exponential fit ([`arlo`](crate::utils::r2star::arlo)) suffers on imperfectly-refocused
259/// (B1 < 1) data.
260///
261/// When a per-voxel `b1_map` is supplied (e.g. from a separate B1 acquisition),
262/// the B1 dimension of the search is fixed: each voxel's B1 is snapped to the
263/// nearest `params.b1_grid` entry and only that column of the dictionary is
264/// searched (a T2-only fit). This keeps EPG's stimulated-echo bias correction
265/// while removing the free B1 parameter that overfits noise at low SNR.
266///
267/// # Arguments
268/// * `magnitude` - MESE magnitude, flattened `[v0_e0, v0_e1, ..., v1_e0, ...]`
269///   (row-major `(n_voxels, n_echoes)`)
270/// * `mask` - Binary brain mask `[nx*ny*nz]` (1 = process, 0 = skip)
271/// * `echo_times` - Spin-echo times in seconds `[n_echoes]` (equi-spaced, ≥3)
272/// * `grid` - Volume grid (dimensions and voxel sizes)
273/// * `params` - Dictionary grids and assumed T1
274/// * `b1_map` - Optional known refocusing-efficiency map `[nx*ny*nz]`; `None`
275///   fits B1 per voxel over the full `b1_grid`
276///
277/// # Returns
278/// `(r2_map, b1_map)` - R2 in Hz and the B1 (refocusing efficiency) used per
279/// voxel — fitted, or the snapped input when `b1_map` was given — both
280/// `[nx*ny*nz]`.
281///
282/// # Errors
283/// - fewer than 3 echoes;
284/// - echo times that are not equally spaced, or whose first echo is not at the echo spacing
285///   (`TE₁ = ESP`), each to within [`EPG_TE_TOLERANCE_S`]. The dictionary simulates a CPMG
286///   train with echoes at `n·ESP`; a train that starts elsewhere has a different first
287///   interval, and fitting it against this dictionary gives a wrong R2 rather than a noisy one;
288/// - an empty dictionary (`params` grids with no usable `(T2, B1)` pair);
289/// - `magnitude`/`mask`/`b1_map` lengths inconsistent with `grid`.
290pub fn r2_epg(
291    magnitude: &[f64],
292    mask: &[u8],
293    echo_times: &[f64],
294    grid: &crate::Grid,
295    params: &R2EpgParams,
296    b1_map: Option<&[f64]>,
297) -> Result<(Vec<f64>, Vec<f64>), InputError> {
298    let n_echoes = echo_times.len();
299    if n_echoes < 3 {
300        return Err(InputError(format!("EPG R2 fitting needs at least 3 echoes, got {n_echoes}")));
301    }
302    if echo_times.iter().any(|t| !t.is_finite()) {
303        return Err(InputError(format!("EPG R2 fitting needs finite echo times, got {echo_times:?}")));
304    }
305    let n_voxels = grid.n_total();
306    check_len("magnitude (n_voxels * n_echoes)", magnitude.len(), n_voxels * n_echoes)?;
307    check_len("mask", mask.len(), n_voxels)?;
308    if let Some(b1) = b1_map {
309        check_len("b1_map", b1.len(), n_voxels)?;
310    }
311
312    // Sort echoes by time and require (approximately) uniform spacing.
313    let sort_indices: Vec<usize> = {
314        let mut idx: Vec<usize> = (0..n_echoes).collect();
315        idx.sort_by(|&a, &b| echo_times[a].total_cmp(&echo_times[b]));
316        idx
317    };
318    let mut te_sorted: Vec<f64> = echo_times.to_vec();
319    te_sorted.sort_by(f64::total_cmp);
320    let esp = te_sorted[1] - te_sorted[0];
321    let diffs: Vec<f64> = te_sorted.windows(2).map(|w| w[1] - w[0]).collect();
322    let max_dev = diffs.iter().map(|&d| (d - esp).abs()).fold(0.0_f64, f64::max);
323    if esp <= 0.0 || max_dev > EPG_TE_TOLERANCE_S {
324        return Err(InputError(format!(
325            "EPG R2 fitting needs distinct, equally spaced echo times, got {echo_times:?} s"
326        )));
327    }
328    // EPG forms echoes at TE_n = n*esp; require the acquisition to start at esp.
329    // (A constant offset would need a different first-interval model.)
330    if (te_sorted[0] - esp).abs() > EPG_TE_TOLERANCE_S {
331        return Err(InputError(format!(
332            "EPG R2 fitting needs the first echo at the echo spacing (TE1 = ESP), got \
333             TE1 = {:.3} ms, ESP = {:.3} ms",
334            te_sorted[0] * 1e3, esp * 1e3
335        )));
336    }
337
338    let dict = build_dictionary(params, esp, n_echoes);
339    if dict.iter().all(|g| g.is_empty()) {
340        return Err(InputError(
341            "EPG dictionary is empty: no (T2, B1) pair in the parameter grids is usable".into(),
342        ));
343    }
344
345    let mut r2_out = vec![0.0_f64; n_voxels];
346    let mut b1_out = vec![0.0_f64; n_voxels];
347
348    // Match each voxel against the dictionary (parallel over voxels).
349    let mut out: Vec<(f64, f64)> = vec![(0.0, 0.0); n_voxels];
350    maybe_par_chunks_mut!(out.as_mut_slice(), 1)
351        .enumerate()
352        .for_each(|(v, slot)| {
353            if mask[v] == 0 {
354                return;
355            }
356            // Extract and normalize the measured echo train.
357            let mut sig: Vec<f64> = sort_indices
358                .iter()
359                .map(|&ei| magnitude[v * n_echoes + ei])
360                .collect();
361            let norm: f64 = sig.iter().map(|&x| x * x).sum::<f64>().sqrt();
362            if norm < 1e-20 {
363                return;
364            }
365            for x in sig.iter_mut() {
366                *x /= norm;
367            }
368
369            // With a known B1, restrict the search to the nearest B1 column.
370            let groups: &[Vec<DictAtom>] = match b1_map {
371                Some(b1) => {
372                    let gi = params
373                        .b1_grid
374                        .iter()
375                        .enumerate()
376                        .min_by(|(_, a), (_, b)| {
377                            (*a - b1[v]).abs().partial_cmp(&(*b - b1[v]).abs()).unwrap()
378                        })
379                        .map(|(i, _)| i)
380                        .unwrap();
381                    std::slice::from_ref(&dict[gi])
382                }
383                None => &dict,
384            };
385
386            // Best match = maximum normalized dot product (cosine similarity).
387            let mut best_dot = f64::NEG_INFINITY;
388            let mut best = (0.0_f64, 0.0_f64);
389            for atom in groups.iter().flatten() {
390                let dot: f64 = sig
391                    .iter()
392                    .zip(atom.signal.iter())
393                    .map(|(&a, &b)| a * b)
394                    .sum();
395                if dot > best_dot {
396                    best_dot = dot;
397                    best = (1.0 / atom.t2, atom.b1);
398                }
399            }
400            slot[0] = best;
401        });
402
403    for v in 0..n_voxels {
404        r2_out[v] = out[v].0;
405        b1_out[v] = out[v].1;
406    }
407
408    Ok((r2_out, b1_out))
409}
410
411/// R2' and the region over which it is a measurement.
412///
413/// The two are returned together because they cannot be recovered from one another: a zero in
414/// [`r2prime`](Self::r2prime) means either "R2* equalled R2 here" or "nothing was measured here",
415/// and only [`coverage`](Self::coverage) tells them apart.
416#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
417#[derive(Clone, Debug)]
418pub struct R2PrimeMap {
419    /// R2' = R2* − R2 in Hz, clamped at zero, and zero wherever `coverage` is zero.
420    pub r2prime: Vec<f64>,
421    /// 1 where R2 was measured — and R2' is therefore a measurement — 0 elsewhere.
422    pub coverage: Vec<u8>,
423}
424
425/// Compute R2' = R2* − R2, clamped at zero, over the region where R2 was measured.
426///
427/// R2* (from a gradient-echo acquisition) captures reversible + irreversible dephasing; R2 (from a
428/// spin-echo acquisition) captures only the irreversible part. Their difference R2' is the
429/// reversible dephasing that chi-separation uses to constrain the paramagnetic/diamagnetic split.
430/// Noise can make R2* < R2 in some voxels, so negative values are clamped to zero, as
431/// [`r2primenet`](super::r2primenet::r2primenet) does: every R2′ QSM.rs makes is non-negative,
432/// and the χ-separation methods are given that map.
433///
434/// **R2' is only defined where R2 exists.** R2 comes from a multi-echo spin-echo acquisition, and
435/// a MESE is routinely a *slab* — a band of slices rather than the whole head — because
436/// whole-brain MESE is expensive. Subtracting over the whole brain mask would then compute
437/// `R2' = R2* − 0 = R2*` outside the slab, overstating R2' by roughly the tissue R2 (~10-20 Hz in
438/// brain at 3 T) with nothing in the output to show it: the map looks plausible, and every
439/// R2'-consuming method inherits the error. So this returns the coverage alongside the map, and
440/// leaves R2' at zero outside it; what to do with the remainder — restrict the downstream outputs
441/// to it, or fill it with [`r2primenet`](super::r2primenet::r2primenet) — is a policy decision for
442/// the caller, not this function.
443///
444/// Coverage is the intersection of three things:
445/// - `mask` — where an R2' is wanted at all (the brain mask);
446/// - `r2_coverage`, when given — where the R2 acquisition reached. Only the caller knows this; it
447///   is a geometric fact about the acquisition, not something the R2 map reveals;
448/// - `r2 > 0` — a finite, positive R2. R2 = 0 means T2 = ∞, which no tissue has, so a zero is an
449///   absent measurement rather than a measured zero. This is exact for an R2 map from
450///   [`r2_epg`], which returns exactly zero outside its own fit mask; for an R2 map resampled
451///   from another grid, interpolation can leave small non-zero values just outside the acquired
452///   FOV, and only an explicit `r2_coverage` excludes those.
453///
454/// Passing `r2_coverage = None` therefore falls back to the `r2 > 0` heuristic — conservative, and
455/// right for an EPG-derived R2, but weaker than telling the function where the acquisition was.
456///
457/// # Arguments
458/// * `r2star` - R2* map in Hz `[nx*ny*nz]`
459/// * `r2` - R2 map in Hz `[nx*ny*nz]` (co-registered to `r2star`)
460/// * `mask` - Binary brain mask `[nx*ny*nz]`
461/// * `r2_coverage` - Optional binary mask of where the R2 acquisition reached `[nx*ny*nz]`
462///
463/// # Returns
464/// [`R2PrimeMap`]: the R2' map in Hz and the coverage it is valid over.
465///
466/// # Errors
467/// If `r2`, `mask` or `r2_coverage` is not the same length as `r2star`: R2 from a separate
468/// spin-echo acquisition has to be resampled onto the R2\* grid first.
469pub fn r2prime(
470    r2star: &[f64],
471    r2: &[f64],
472    mask: &[u8],
473    r2_coverage: Option<&[u8]>,
474) -> Result<R2PrimeMap, InputError> {
475    check_len("r2 (must be on the r2star grid)", r2.len(), r2star.len())?;
476    check_len("mask", mask.len(), r2star.len())?;
477    if let Some(c) = r2_coverage {
478        check_len("r2_coverage", c.len(), r2star.len())?;
479    }
480
481    let n = r2star.len();
482    let mut out = vec![0.0_f64; n];
483    let mut coverage = vec![0_u8; n];
484    for i in 0..n {
485        let measured = mask[i] != 0
486            && r2_coverage.is_none_or(|c| c[i] != 0)
487            && r2[i].is_finite()
488            && r2[i] > 0.0;
489        if measured {
490            coverage[i] = 1;
491            out[i] = (r2star[i] - r2[i]).max(0.0);
492        }
493    }
494    Ok(R2PrimeMap { r2prime: out, coverage })
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::Grid;
501
502    /// With perfect 180° refocusing (b1 = 1), the EPG train must reduce exactly
503    /// to the mono-exponential S(TE_n) = exp(-TE_n / T2).
504    #[test]
505    fn test_epg_perfect_refocus_is_monoexponential() {
506        let t2 = 0.060; // 60 ms
507        let t1 = 1.2;
508        let esp = 0.010; // 10 ms
509        let n = 8;
510        let echoes = epg_cpmg_echoes(t2, t1, 1.0, esp, n);
511        for k in 0..n {
512            let te = (k + 1) as f64 * esp;
513            let expected = (-te / t2).exp();
514            let err = (echoes[k] - expected).abs() / expected;
515            assert!(
516                err < 1e-6,
517                "echo {} EPG {} vs mono-exp {} (rel err {:.2e})",
518                k,
519                echoes[k],
520                expected,
521                err
522            );
523        }
524    }
525
526    /// With imperfect refocusing (b1 < 1), stimulated-echo pathways make later
527    /// echoes DECAY MORE SLOWLY than a mono-exponential — i.e. the apparent T2 is
528    /// biased upward. Verify the later echoes sit above the mono-exp curve.
529    #[test]
530    fn test_epg_imperfect_refocus_slower_decay() {
531        let t2 = 0.060;
532        let t1 = 1.2;
533        let esp = 0.010;
534        let n = 8;
535        let echoes = epg_cpmg_echoes(t2, t1, 0.7, esp, n);
536        // Normalize both to first echo for a fair shape comparison.
537        let first = echoes[0];
538        for k in 2..n {
539            let te = (k + 1) as f64 * esp;
540            let te1 = esp;
541            let mono = (-(te - te1) / t2).exp(); // relative to echo 1
542            let epg_rel = echoes[k] / first;
543            assert!(
544                epg_rel > mono,
545                "echo {}: EPG rel {} should exceed mono-exp rel {}",
546                k,
547                epg_rel,
548                mono
549            );
550        }
551    }
552
553    /// The headline result: on imperfectly-refocused data, EPG recovers the true
554    /// T2 while a mono-exponential (log-linear) fit is biased. We simulate a
555    /// b1 = 0.75 train, fit R2 with both, and check EPG is far closer to truth.
556    #[test]
557    fn test_epg_beats_monoexp_on_imperfect_refocus() {
558        let t2_true = 0.070; // 70 ms  -> R2 = 14.29 Hz
559        let r2_true = 1.0 / t2_true;
560        let t1 = 1.2;
561        let b1_true = 0.75;
562        let esp = 0.010;
563        let n = 10;
564        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
565
566        // Simulate one voxel's MESE train (scaled by an arbitrary M0).
567        let m0 = 850.0;
568        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
569        let mag: Vec<f64> = train.iter().map(|&v| v * m0).collect();
570
571        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
572        let mask = vec![1u8];
573
574        // EPG fit (dictionary includes b1 = 0.75 and t2 near 70 ms).
575        let params = R2EpgParams::default();
576        let (r2_epg_map, b1_map) = r2_epg(&mag, &mask, &te, &grid, &params, None).unwrap();
577        let r2_epg_val = r2_epg_map[0];
578        let epg_err = (r2_epg_val - r2_true).abs() / r2_true;
579
580        // Mono-exponential (ARLO) fit for comparison.
581        // one voxel: voxel-major and echo-slowest are the same
582        let r2_arlo_map = crate::utils::r2star::arlo(&mag, &te, 1).unwrap();
583        let arlo_err = (r2_arlo_map[0] - r2_true).abs() / r2_true;
584
585        // EPG should be within ~5% (grid-limited) and clearly better than ARLO.
586        assert!(
587            epg_err < 0.06,
588            "EPG R2 {:.3} Hz vs true {:.3} Hz (err {:.1}%)",
589            r2_epg_val,
590            r2_true,
591            epg_err * 100.0
592        );
593        assert!(
594            epg_err < arlo_err,
595            "EPG err {:.1}% should beat ARLO err {:.1}% (EPG {:.2} Hz, ARLO {:.2} Hz, true {:.2} Hz)",
596            epg_err * 100.0,
597            arlo_err * 100.0,
598            r2_epg_val,
599            r2_arlo_map[0],
600            r2_true
601        );
602        // The fitted B1 should land near the truth.
603        assert!(
604            (b1_map[0] - b1_true).abs() <= 0.06,
605            "fitted B1 {} vs true {}",
606            b1_map[0],
607            b1_true
608        );
609    }
610
611    /// On perfectly-refocused data, EPG and ARLO should agree (both correct).
612    #[test]
613    fn test_epg_matches_arlo_on_perfect_refocus() {
614        let t2_true = 0.050;
615        let r2_true = 1.0 / t2_true;
616        let esp = 0.012;
617        let n = 6;
618        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
619        let train = epg_cpmg_echoes(t2_true, 1.0, 1.0, esp, n);
620        let mag: Vec<f64> = train.iter().map(|&v| v * 500.0).collect();
621
622        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
623        let mask = vec![1u8];
624        let (r2_epg_map, _) = r2_epg(&mag, &mask, &te, &grid, &R2EpgParams::default(), None).unwrap();
625        let err = (r2_epg_map[0] - r2_true).abs() / r2_true;
626        assert!(err < 0.05, "EPG R2 {} vs true {}", r2_epg_map[0], r2_true);
627    }
628
629    /// With a known B1 map the fit is T2-only: R2 accuracy should match the full
630    /// (T2, B1) search and the returned B1 must be the snapped input value.
631    #[test]
632    fn test_epg_fixed_b1_map_recovers_r2() {
633        let t2_true = 0.070;
634        let r2_true = 1.0 / t2_true;
635        let t1 = 1.2;
636        let b1_true = 0.7; // on the default b1_grid (0.6 + 5*0.02)
637        let esp = 0.010;
638        let n = 10;
639        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
640        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
641        let mag: Vec<f64> = train.iter().map(|&v| v * 850.0).collect();
642
643        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
644        let mask = vec![1u8];
645        let b1_known = vec![b1_true];
646        let (r2_map, b1_out) = r2_epg(
647            &mag,
648            &mask,
649            &te,
650            &grid,
651            &R2EpgParams::default(),
652            Some(&b1_known),
653        ).unwrap();
654        let err = (r2_map[0] - r2_true).abs() / r2_true;
655        assert!(
656            err < 0.06,
657            "fixed-B1 EPG R2 {:.3} Hz vs true {:.3} Hz (err {:.1}%)",
658            r2_map[0],
659            r2_true,
660            err * 100.0
661        );
662        assert!(
663            (b1_out[0] - b1_true).abs() < 1e-12,
664            "returned B1 {} should be the snapped input {}",
665            b1_out[0],
666            b1_true
667        );
668    }
669
670    /// Off-grid B1 values must snap to the nearest b1_grid entry, and a wrong
671    /// fixed B1 must actually constrain the fit (biasing R2 versus the truth) —
672    /// proving the search really is restricted to the supplied column.
673    #[test]
674    fn test_epg_fixed_b1_snaps_and_constrains() {
675        let t2_true = 0.070;
676        let r2_true = 1.0 / t2_true;
677        let t1 = 1.2;
678        let b1_true = 0.7;
679        let esp = 0.010;
680        let n = 10;
681        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
682        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
683        let mag: Vec<f64> = train.iter().map(|&v| v * 850.0).collect();
684
685        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
686        let mask = vec![1u8];
687        let params = R2EpgParams::default();
688
689        // Off-grid input snaps to the nearest grid entry (0.695 -> 0.70).
690        let (_, b1_out) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[0.695])).unwrap();
691        assert!(
692            (b1_out[0] - 0.70).abs() < 1e-12,
693            "B1 0.695 should snap to 0.70, got {}",
694            b1_out[0]
695        );
696
697        // Forcing perfect refocusing on a b1 = 0.7 train mis-reads the
698        // stimulated-echo signal as slower decay: R2 biased low vs truth.
699        let (r2_right, _) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[b1_true])).unwrap();
700        let (r2_wrong, _) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[1.0])).unwrap();
701        let err_right = (r2_right[0] - r2_true).abs() / r2_true;
702        let err_wrong = (r2_wrong[0] - r2_true).abs() / r2_true;
703        assert!(
704            r2_wrong[0] < r2_true && err_wrong > err_right + 0.05,
705            "wrong fixed B1 should bias R2 low: right {:.2} Hz (err {:.1}%), wrong {:.2} Hz (err {:.1}%), true {:.2} Hz",
706            r2_right[0],
707            err_right * 100.0,
708            r2_wrong[0],
709            err_wrong * 100.0,
710            r2_true
711        );
712    }
713
714    #[test]
715    fn test_r2prime_subtract_and_clamp() {
716        let r2star = vec![50.0, 30.0, 10.0, 0.0];
717        let r2 = vec![20.0, 35.0, 10.0, 5.0];
718        let mask = vec![1u8, 1, 0, 1];
719        let out = r2prime(&r2star, &r2, &mask, None).unwrap();
720        assert!((out.r2prime[0] - 30.0).abs() < 1e-12); // 50 - 20
721        assert!(out.r2prime[1].abs() < 1e-12); // clamp negative (30 - 35)
722        assert!(out.r2prime[2].abs() < 1e-12); // masked out
723        assert!(out.r2prime[3].abs() < 1e-12); // clamp negative (0 - 5)
724        // Clamping to zero is still a measurement; being outside the mask is not.
725        assert_eq!(out.coverage, vec![1u8, 1, 0, 1]);
726    }
727
728    /// An explicit coverage mask must bound where R2' is reported, and the uncovered
729    /// voxels must come back as zero *and* flagged — never as R2* − 0 = R2*.
730    ///
731    /// The uncovered voxels carry a *non-zero* R2 here on purpose: that is what a slab R2 map
732    /// resampled onto the GRE grid leaves just outside its FOV, and it is the case the `r2 > 0`
733    /// heuristic cannot catch. So only the supplied mask can exclude them, and dropping the
734    /// mask from the implementation fails this test rather than slipping past it.
735    #[test]
736    fn test_r2prime_explicit_coverage_bounds_the_map() {
737        // Four in-brain voxels; the R2 acquisition reached only the first two.
738        let r2star = vec![40.0, 35.0, 30.0, 25.0];
739        let r2 = vec![15.0, 12.0, 0.4, 0.1]; // interpolation bleed outside the slab
740        let mask = vec![1u8; 4];
741        let coverage = vec![1u8, 1, 0, 0];
742
743        let out = r2prime(&r2star, &r2, &mask, Some(&coverage)).unwrap();
744        assert_eq!(out.coverage, coverage, "coverage should be the supplied one");
745        // Inside: R2* − R2, computed independently of the function under test.
746        assert!((out.r2prime[0] - 25.0).abs() < 1e-12);
747        assert!((out.r2prime[1] - 23.0).abs() < 1e-12);
748        // Outside: zero. Subtracting the bleed-through would have given 29.6 and 24.9 —
749        // essentially the whole of R2* reported as reversible.
750        assert_eq!(out.r2prime[2], 0.0);
751        assert_eq!(out.r2prime[3], 0.0);
752    }
753
754    /// Explicit coverage must *narrow*, not widen: a voxel the acquisition reached whose R2
755    /// came back as zero is still an absent measurement, so it stays uncovered.
756    #[test]
757    fn test_r2prime_coverage_never_admits_a_zero_r2() {
758        let r2star = vec![40.0, 40.0, 40.0];
759        let r2 = vec![15.0, 0.0, f64::NAN];
760        let mask = vec![1u8; 3];
761        // The caller claims the acquisition covered everything.
762        let out = r2prime(&r2star, &r2, &mask, Some(&[1u8, 1, 1])).unwrap();
763        assert_eq!(out.coverage, vec![1u8, 0, 0], "a zero or non-finite R2 is not a measurement");
764        assert!((out.r2prime[0] - 25.0).abs() < 1e-12);
765        assert_eq!(out.r2prime[1], 0.0);
766        assert_eq!(out.r2prime[2], 0.0);
767    }
768
769    /// With no coverage mask the `r2 > 0` heuristic has to stand in for it — which is exactly
770    /// what an EPG-derived R2 supports, since [`r2_epg`] leaves un-fitted voxels at zero.
771    #[test]
772    fn test_r2prime_heuristic_coverage_without_a_mask() {
773        let r2star = vec![40.0, 35.0, 30.0];
774        let r2 = vec![15.0, 12.0, 0.0]; // third voxel never fitted
775        let mask = vec![1u8; 3];
776        let out = r2prime(&r2star, &r2, &mask, None).unwrap();
777        assert_eq!(out.coverage, vec![1u8, 1, 0]);
778        assert_eq!(out.r2prime[2], 0.0, "an unfitted voxel must not report R2* as R2'");
779    }
780
781    /// Echo times the dictionary cannot model are refused, not asserted on or fitted
782    /// wrongly (#273).
783    #[test]
784    fn r2_epg_refuses_echo_times_it_cannot_model() {
785        let grid = crate::Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
786        let params = R2EpgParams::default();
787        let run = |te: &[f64]| {
788            let mag: Vec<f64> = te.iter().map(|t| (-t / 0.08).exp()).collect();
789            r2_epg(&mag, &[1], te, &grid, &params, None)
790        };
791        assert!(run(&[0.01, 0.02, 0.03, 0.04]).is_ok());
792        for (te, why) in [
793            (&[0.01, 0.02][..], "at least 3"),
794            (&[0.01, 0.02, 0.04][..], "equally spaced"),
795            (&[0.01, 0.01, 0.01][..], "equally spaced"),
796            (&[0.015, 0.025, 0.035][..], "TE1 = ESP"),
797            (&[0.01, f64::NAN, 0.03][..], "finite"),
798        ] {
799            let err = run(te).unwrap_err().to_string();
800            assert!(err.contains(why), "{te:?}: {err}");
801        }
802        let te = [0.01, 0.02, 0.03];
803        assert!(r2_epg(&[1.0; 2], &[1], &te, &grid, &params, None).is_err());
804        assert!(r2_epg(&[1.0; 3], &[1], &te, &grid, &params, Some(&[1.0, 1.0])).is_err());
805        let empty = R2EpgParams { t2_grid: vec![], ..R2EpgParams::default() };
806        assert!(r2_epg(&[1.0; 3], &[1], &te, &grid, &empty, None).unwrap_err().to_string().contains("empty"));
807    }
808
809    #[test]
810    fn r2prime_refuses_maps_on_different_grids() {
811        assert!(r2prime(&[1.0; 4], &[1.0; 3], &[1; 4], None).is_err());
812        assert!(r2prime(&[1.0; 4], &[1.0; 4], &[1; 3], None).is_err());
813        assert!(r2prime(&[1.0; 4], &[1.0; 4], &[1; 4], Some(&[1; 5])).is_err());
814    }
815}