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. [`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//! References:
22//! - Weigel, M. (2015). "Extended phase graphs: dephasing, RF pulses, and echoes
23//!   — pure and simple." JMRI 41(2):266-295.
24//! - Ben-Eliezer, N., et al. (2015). "Rapid and accurate T2 mapping from
25//!   multi-spin-echo data using Bloch-simulation-based reconstruction." MRM
26//!   73(2):809-817.
27
28use num_complex::Complex64;
29#[cfg(feature = "parallel")]
30use rayon::prelude::*;
31
32const PI: f64 = std::f64::consts::PI;
33
34/// EPG configuration state: three complex vectors indexed by dephasing order
35/// `k = 0..=k_max`. `fp[k]` is the F+ (positive dephasing) coherence at order
36/// `k`, `fm[k]` the F- (negative dephasing) coherence, and `z[k]` the
37/// longitudinal coherence.
38struct EpgState {
39    fp: Vec<Complex64>,
40    fm: Vec<Complex64>,
41    z: Vec<Complex64>,
42}
43
44impl EpgState {
45    fn new(k_max: usize) -> Self {
46        let zero = Complex64::new(0.0, 0.0);
47        EpgState {
48            fp: vec![zero; k_max + 1],
49            fm: vec![zero; k_max + 1],
50            z: vec![zero; k_max + 1],
51        }
52    }
53
54    /// Relaxation + longitudinal regrowth (toward M0 = 1) over one interval.
55    /// `e1 = exp(-dt/T1)`, `e2 = exp(-dt/T2)`.
56    #[inline]
57    fn relax(&mut self, e1: f64, e2: f64) {
58        for v in self.fp.iter_mut() {
59            *v *= e2;
60        }
61        for v in self.fm.iter_mut() {
62            *v *= e2;
63        }
64        for v in self.z.iter_mut() {
65            *v *= e1;
66        }
67        self.z[0] += 1.0 - e1;
68    }
69
70    /// Unit positive dephasing gradient: shift coherence orders. F+ moves to
71    /// higher orders, F- to lower orders, and the observable F+(0) is refilled
72    /// from conj(F-(0)) (Weigel 2015; Hargreaves EPG `epg_grad`).
73    #[inline]
74    fn grad(&mut self) {
75        let k_max = self.fp.len() - 1;
76        // F+ up: fp[k] = fp[k-1]
77        for k in (1..=k_max).rev() {
78            self.fp[k] = self.fp[k - 1];
79        }
80        // F- down: fm[k] = fm[k+1]
81        for k in 0..k_max {
82            self.fm[k] = self.fm[k + 1];
83        }
84        self.fm[k_max] = Complex64::new(0.0, 0.0);
85        // Refill the observable from the conjugate-symmetric partner.
86        self.fp[0] = self.fm[0].conj();
87    }
88
89    /// Instantaneous RF pulse of flip angle `alpha` (rad) and phase `phi` (rad),
90    /// applied to every coherence order (Weigel 2015, Hargreaves `epg_rf`).
91    #[inline]
92    fn rf(&mut self, alpha: f64, phi: f64) {
93        let c = (alpha / 2.0).cos();
94        let s = (alpha / 2.0).sin();
95        let c2 = c * c;
96        let s2 = s * s;
97        let sa = alpha.sin();
98        let ca = alpha.cos();
99        let i = Complex64::i();
100        let eip = Complex64::from_polar(1.0, phi); // e^{i phi}
101        let ei2p = Complex64::from_polar(1.0, 2.0 * phi); // e^{2i phi}
102
103        let m00 = Complex64::new(c2, 0.0);
104        let m01 = ei2p * s2;
105        let m02 = -i * eip * sa;
106        let m10 = ei2p.conj() * s2;
107        let m11 = Complex64::new(c2, 0.0);
108        let m12 = i * eip.conj() * sa;
109        let m20 = -i * 0.5 * eip.conj() * sa;
110        let m21 = i * 0.5 * eip * sa;
111        let m22 = Complex64::new(ca, 0.0);
112
113        for k in 0..self.fp.len() {
114            let a = self.fp[k];
115            let b = self.fm[k];
116            let d = self.z[k];
117            self.fp[k] = m00 * a + m01 * b + m02 * d;
118            self.fm[k] = m10 * a + m11 * b + m12 * d;
119            self.z[k] = m20 * a + m21 * b + m22 * d;
120        }
121    }
122}
123
124/// Simulate the CPMG multi-echo spin-echo magnitude train.
125///
126/// Models a 90° excitation followed by `n_echoes` refocusing pulses of nominal
127/// 180° scaled by `b1` (so the actual refocusing flip is `b1 * 180°`), spaced by
128/// echo spacing `esp` (seconds). Echoes form at `TE_n = n * esp`.
129///
130/// # Arguments
131/// * `t2` - Transverse relaxation time (seconds)
132/// * `t1` - Longitudinal relaxation time (seconds)
133/// * `b1` - Refocusing efficiency: fraction of the nominal 180° flip (1.0 = perfect)
134/// * `esp` - Echo spacing (seconds)
135/// * `n_echoes` - Number of echoes to simulate
136///
137/// # Returns
138/// `n_echoes` echo magnitudes, normalized so equilibrium magnetization M0 = 1.
139///
140/// With `b1 = 1.0` this returns exactly `exp(-n * esp / t2)` (perfect refocusing).
141pub fn epg_cpmg_echoes(t2: f64, t1: f64, b1: f64, esp: f64, n_echoes: usize) -> Vec<f64> {
142    // Coherence can reach up to one order per gradient (two per echo interval).
143    let k_max = 2 * n_echoes + 2;
144    let mut st = EpgState::new(k_max);
145
146    // Equilibrium then 90° excitation about y (phi = pi/2). CPMG requires the
147    // excitation and refocusing pulses to be 90° out of phase.
148    st.z[0] = Complex64::new(1.0, 0.0);
149    st.rf(PI / 2.0, PI / 2.0);
150
151    let e1 = (-esp / 2.0 / t1).exp();
152    let e2 = (-esp / 2.0 / t2).exp();
153    let alpha = b1 * PI; // refocusing flip angle (rad)
154
155    let mut echoes = Vec::with_capacity(n_echoes);
156    for _ in 0..n_echoes {
157        // First half-interval: relax + dephase.
158        st.relax(e1, e2);
159        st.grad();
160        // Refocusing pulse about x (phi = 0).
161        st.rf(alpha, 0.0);
162        // Second half-interval: relax + rephase.
163        st.relax(e1, e2);
164        st.grad();
165        // Echo forms at the observable coherence order 0.
166        echoes.push(st.fp[0].norm());
167    }
168    echoes
169}
170
171/// Parameters for [`r2_epg`] dictionary matching.
172pub struct R2EpgParams {
173    /// Assumed T1 in seconds (weak influence on the T2 estimate).
174    pub t1: f64,
175    /// Candidate T2 values (seconds) — the dictionary's T2 axis.
176    pub t2_grid: Vec<f64>,
177    /// Candidate B1 (refocusing efficiency) values — the dictionary's B1 axis.
178    /// When a B1 map is supplied to [`r2_epg`], map values are snapped to the
179    /// nearest entry of this grid (densify it for finer quantization).
180    pub b1_grid: Vec<f64>,
181}
182
183impl Default for R2EpgParams {
184    fn default() -> Self {
185        // T2 log-spaced 5 ms .. 500 ms (R2 ~ 2 .. 200 Hz).
186        let n_t2 = 150;
187        let (t2_lo, t2_hi) = (0.005_f64, 0.5_f64);
188        let ln_lo = t2_lo.ln();
189        let ln_hi = t2_hi.ln();
190        let t2_grid: Vec<f64> = (0..n_t2)
191            .map(|i| (ln_lo + (ln_hi - ln_lo) * i as f64 / (n_t2 - 1) as f64).exp())
192            .collect();
193
194        // B1 (refocusing efficiency) 0.6 .. 1.0 in steps of 0.02.
195        let n_b1 = 21;
196        let (b1_lo, b1_hi) = (0.6_f64, 1.0_f64);
197        let b1_grid: Vec<f64> = (0..n_b1)
198            .map(|i| b1_lo + (b1_hi - b1_lo) * i as f64 / (n_b1 - 1) as f64)
199            .collect();
200
201        R2EpgParams {
202            t1: 1.0,
203            t2_grid,
204            b1_grid,
205        }
206    }
207}
208
209/// One dictionary atom: a normalized echo train and its `(T2, B1)` labels.
210struct DictAtom {
211    signal: Vec<f64>, // L2-normalized echo amplitudes
212    t2: f64,
213    b1: f64,
214}
215
216/// Build the EPG dictionary for a given echo spacing and echo count, grouped by
217/// B1: `dict[i]` holds the atoms for `params.b1_grid[i]` (over all T2 values),
218/// so a fixed-B1 search can restrict itself to a single group.
219fn build_dictionary(params: &R2EpgParams, esp: f64, n_echoes: usize) -> Vec<Vec<DictAtom>> {
220    params
221        .b1_grid
222        .iter()
223        .map(|&b1| {
224            let mut group = Vec::with_capacity(params.t2_grid.len());
225            for &t2 in &params.t2_grid {
226                let mut sig = epg_cpmg_echoes(t2, params.t1, b1, esp, n_echoes);
227                let norm: f64 = sig.iter().map(|&v| v * v).sum::<f64>().sqrt();
228                if norm > 1e-30 {
229                    for v in sig.iter_mut() {
230                        *v /= norm;
231                    }
232                    group.push(DictAtom { signal: sig, t2, b1 });
233                }
234            }
235            group
236        })
237        .collect()
238}
239
240/// R2 mapping from multi-echo spin-echo magnitude via EPG dictionary matching.
241///
242/// For each masked voxel the (normalized) measured echo train is matched against
243/// a dictionary of EPG-simulated trains over a `(T2, B1)` grid; the R2 = 1/T2 of
244/// the best match is returned. Normalization removes the proton-density / receive
245/// gain, so only the decay *shape* is fit. This corrects the stimulated-echo bias
246/// that a mono-exponential fit ([`r2star_arlo`]) suffers on imperfectly-refocused
247/// (B1 < 1) data.
248///
249/// When a per-voxel `b1_map` is supplied (e.g. from a separate B1 acquisition),
250/// the B1 dimension of the search is fixed: each voxel's B1 is snapped to the
251/// nearest `params.b1_grid` entry and only that column of the dictionary is
252/// searched (a T2-only fit). This keeps EPG's stimulated-echo bias correction
253/// while removing the free B1 parameter that overfits noise at low SNR.
254///
255/// # Arguments
256/// * `magnitude` - MESE magnitude, flattened `[v0_e0, v0_e1, ..., v1_e0, ...]`
257///   (row-major `(n_voxels, n_echoes)`, same layout as [`r2star_arlo`])
258/// * `mask` - Binary brain mask `[nx*ny*nz]` (1 = process, 0 = skip)
259/// * `echo_times` - Spin-echo times in seconds `[n_echoes]` (equi-spaced, ≥3)
260/// * `grid` - Volume grid (dimensions and voxel sizes)
261/// * `params` - Dictionary grids and assumed T1
262/// * `b1_map` - Optional known refocusing-efficiency map `[nx*ny*nz]`; `None`
263///   fits B1 per voxel over the full `b1_grid`
264///
265/// # Returns
266/// `(r2_map, b1_map)` - R2 in Hz and the B1 (refocusing efficiency) used per
267/// voxel — fitted, or the snapped input when `b1_map` was given — both
268/// `[nx*ny*nz]`.
269///
270/// # Panics
271/// Panics if `echo_times.len() < 3`, echo times are not equi-spaced, or the
272/// `magnitude`/`mask`/`b1_map` lengths are inconsistent with `grid`.
273pub fn r2_epg(
274    magnitude: &[f64],
275    mask: &[u8],
276    echo_times: &[f64],
277    grid: &crate::Grid,
278    params: &R2EpgParams,
279    b1_map: Option<&[f64]>,
280) -> (Vec<f64>, Vec<f64>) {
281    let n_echoes = echo_times.len();
282    assert!(n_echoes >= 3, "EPG R2 fitting requires at least 3 echoes");
283    let n_voxels = grid.n_total();
284    assert_eq!(
285        magnitude.len(),
286        n_voxels * n_echoes,
287        "magnitude length must be n_voxels * n_echoes"
288    );
289    assert_eq!(mask.len(), n_voxels, "mask length must be n_voxels");
290    if let Some(b1) = b1_map {
291        assert_eq!(b1.len(), n_voxels, "b1_map length must be n_voxels");
292    }
293
294    // Sort echoes by time and require (approximately) uniform spacing.
295    let sort_indices: Vec<usize> = {
296        let mut idx: Vec<usize> = (0..n_echoes).collect();
297        idx.sort_by(|&a, &b| echo_times[a].partial_cmp(&echo_times[b]).unwrap());
298        idx
299    };
300    let mut te_sorted: Vec<f64> = echo_times.to_vec();
301    te_sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
302    let esp = te_sorted[1] - te_sorted[0];
303    let diffs: Vec<f64> = te_sorted.windows(2).map(|w| w[1] - w[0]).collect();
304    let max_dev = diffs.iter().map(|&d| (d - esp).abs()).fold(0.0_f64, f64::max);
305    assert!(
306        max_dev <= 1e-4,
307        "EPG R2 fitting requires equi-spaced echo times"
308    );
309    // EPG forms echoes at TE_n = n*esp; require the acquisition to start at esp.
310    // (A constant offset would need a different first-interval model.)
311
312    let dict = build_dictionary(params, esp, n_echoes);
313    assert!(
314        dict.iter().any(|g| !g.is_empty()),
315        "EPG dictionary is empty"
316    );
317
318    let mut r2_out = vec![0.0_f64; n_voxels];
319    let mut b1_out = vec![0.0_f64; n_voxels];
320
321    // Match each voxel against the dictionary (parallel over voxels).
322    let mut out: Vec<(f64, f64)> = vec![(0.0, 0.0); n_voxels];
323    maybe_par_chunks_mut!(out.as_mut_slice(), 1)
324        .enumerate()
325        .for_each(|(v, slot)| {
326            if mask[v] == 0 {
327                return;
328            }
329            // Extract and normalize the measured echo train.
330            let mut sig: Vec<f64> = sort_indices
331                .iter()
332                .map(|&ei| magnitude[v * n_echoes + ei])
333                .collect();
334            let norm: f64 = sig.iter().map(|&x| x * x).sum::<f64>().sqrt();
335            if norm < 1e-20 {
336                return;
337            }
338            for x in sig.iter_mut() {
339                *x /= norm;
340            }
341
342            // With a known B1, restrict the search to the nearest B1 column.
343            let groups: &[Vec<DictAtom>] = match b1_map {
344                Some(b1) => {
345                    let gi = params
346                        .b1_grid
347                        .iter()
348                        .enumerate()
349                        .min_by(|(_, a), (_, b)| {
350                            (*a - b1[v]).abs().partial_cmp(&(*b - b1[v]).abs()).unwrap()
351                        })
352                        .map(|(i, _)| i)
353                        .unwrap();
354                    std::slice::from_ref(&dict[gi])
355                }
356                None => &dict,
357            };
358
359            // Best match = maximum normalized dot product (cosine similarity).
360            let mut best_dot = f64::NEG_INFINITY;
361            let mut best = (0.0_f64, 0.0_f64);
362            for atom in groups.iter().flatten() {
363                let dot: f64 = sig
364                    .iter()
365                    .zip(atom.signal.iter())
366                    .map(|(&a, &b)| a * b)
367                    .sum();
368                if dot > best_dot {
369                    best_dot = dot;
370                    best = (1.0 / atom.t2, atom.b1);
371                }
372            }
373            slot[0] = best;
374        });
375
376    for v in 0..n_voxels {
377        r2_out[v] = out[v].0;
378        b1_out[v] = out[v].1;
379    }
380
381    (r2_out, b1_out)
382}
383
384/// Compute R2' = R2* − R2, clamped at zero, within the mask.
385///
386/// R2* (from a gradient-echo acquisition) captures reversible + irreversible
387/// dephasing; R2 (from a spin-echo acquisition) captures only the irreversible
388/// part. Their difference R2' is the reversible dephasing that chi-separation
389/// uses to constrain the paramagnetic/diamagnetic split. Noise can make R2* < R2
390/// in some voxels, so negative values are clamped to zero.
391///
392/// # Arguments
393/// * `r2star` - R2* map in Hz `[nx*ny*nz]`
394/// * `r2` - R2 map in Hz `[nx*ny*nz]` (co-registered to `r2star`)
395/// * `mask` - Binary brain mask `[nx*ny*nz]`
396///
397/// # Returns
398/// R2' map in Hz `[nx*ny*nz]`.
399pub fn r2prime(r2star: &[f64], r2: &[f64], mask: &[u8]) -> Vec<f64> {
400    assert_eq!(r2star.len(), r2.len(), "r2star and r2 must be the same length");
401    assert_eq!(r2star.len(), mask.len(), "mask must match map length");
402    (0..r2star.len())
403        .map(|i| {
404            if mask[i] != 0 {
405                (r2star[i] - r2[i]).max(0.0)
406            } else {
407                0.0
408            }
409        })
410        .collect()
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::Grid;
417
418    /// With perfect 180° refocusing (b1 = 1), the EPG train must reduce exactly
419    /// to the mono-exponential S(TE_n) = exp(-TE_n / T2).
420    #[test]
421    fn test_epg_perfect_refocus_is_monoexponential() {
422        let t2 = 0.060; // 60 ms
423        let t1 = 1.2;
424        let esp = 0.010; // 10 ms
425        let n = 8;
426        let echoes = epg_cpmg_echoes(t2, t1, 1.0, esp, n);
427        for k in 0..n {
428            let te = (k + 1) as f64 * esp;
429            let expected = (-te / t2).exp();
430            let err = (echoes[k] - expected).abs() / expected;
431            assert!(
432                err < 1e-6,
433                "echo {} EPG {} vs mono-exp {} (rel err {:.2e})",
434                k,
435                echoes[k],
436                expected,
437                err
438            );
439        }
440    }
441
442    /// With imperfect refocusing (b1 < 1), stimulated-echo pathways make later
443    /// echoes DECAY MORE SLOWLY than a mono-exponential — i.e. the apparent T2 is
444    /// biased upward. Verify the later echoes sit above the mono-exp curve.
445    #[test]
446    fn test_epg_imperfect_refocus_slower_decay() {
447        let t2 = 0.060;
448        let t1 = 1.2;
449        let esp = 0.010;
450        let n = 8;
451        let echoes = epg_cpmg_echoes(t2, t1, 0.7, esp, n);
452        // Normalize both to first echo for a fair shape comparison.
453        let first = echoes[0];
454        for k in 2..n {
455            let te = (k + 1) as f64 * esp;
456            let te1 = esp;
457            let mono = (-(te - te1) / t2).exp(); // relative to echo 1
458            let epg_rel = echoes[k] / first;
459            assert!(
460                epg_rel > mono,
461                "echo {}: EPG rel {} should exceed mono-exp rel {}",
462                k,
463                epg_rel,
464                mono
465            );
466        }
467    }
468
469    /// The headline result: on imperfectly-refocused data, EPG recovers the true
470    /// T2 while a mono-exponential (log-linear) fit is biased. We simulate a
471    /// b1 = 0.75 train, fit R2 with both, and check EPG is far closer to truth.
472    #[test]
473    fn test_epg_beats_monoexp_on_imperfect_refocus() {
474        let t2_true = 0.070; // 70 ms  -> R2 = 14.29 Hz
475        let r2_true = 1.0 / t2_true;
476        let t1 = 1.2;
477        let b1_true = 0.75;
478        let esp = 0.010;
479        let n = 10;
480        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
481
482        // Simulate one voxel's MESE train (scaled by an arbitrary M0).
483        let m0 = 850.0;
484        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
485        let mag: Vec<f64> = train.iter().map(|&v| v * m0).collect();
486
487        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
488        let mask = vec![1u8];
489
490        // EPG fit (dictionary includes b1 = 0.75 and t2 near 70 ms).
491        let params = R2EpgParams::default();
492        let (r2_epg_map, b1_map) = r2_epg(&mag, &mask, &te, &grid, &params, None);
493        let r2_epg_val = r2_epg_map[0];
494        let epg_err = (r2_epg_val - r2_true).abs() / r2_true;
495
496        // Mono-exponential (ARLO) fit for comparison.
497        let (r2_arlo_map, _) = crate::utils::r2star::r2star_arlo(&mag, &mask, &te, &grid);
498        let arlo_err = (r2_arlo_map[0] - r2_true).abs() / r2_true;
499
500        // EPG should be within ~5% (grid-limited) and clearly better than ARLO.
501        assert!(
502            epg_err < 0.06,
503            "EPG R2 {:.3} Hz vs true {:.3} Hz (err {:.1}%)",
504            r2_epg_val,
505            r2_true,
506            epg_err * 100.0
507        );
508        assert!(
509            epg_err < arlo_err,
510            "EPG err {:.1}% should beat ARLO err {:.1}% (EPG {:.2} Hz, ARLO {:.2} Hz, true {:.2} Hz)",
511            epg_err * 100.0,
512            arlo_err * 100.0,
513            r2_epg_val,
514            r2_arlo_map[0],
515            r2_true
516        );
517        // The fitted B1 should land near the truth.
518        assert!(
519            (b1_map[0] - b1_true).abs() <= 0.06,
520            "fitted B1 {} vs true {}",
521            b1_map[0],
522            b1_true
523        );
524    }
525
526    /// On perfectly-refocused data, EPG and ARLO should agree (both correct).
527    #[test]
528    fn test_epg_matches_arlo_on_perfect_refocus() {
529        let t2_true = 0.050;
530        let r2_true = 1.0 / t2_true;
531        let esp = 0.012;
532        let n = 6;
533        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
534        let train = epg_cpmg_echoes(t2_true, 1.0, 1.0, esp, n);
535        let mag: Vec<f64> = train.iter().map(|&v| v * 500.0).collect();
536
537        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
538        let mask = vec![1u8];
539        let (r2_epg_map, _) = r2_epg(&mag, &mask, &te, &grid, &R2EpgParams::default(), None);
540        let err = (r2_epg_map[0] - r2_true).abs() / r2_true;
541        assert!(err < 0.05, "EPG R2 {} vs true {}", r2_epg_map[0], r2_true);
542    }
543
544    /// With a known B1 map the fit is T2-only: R2 accuracy should match the full
545    /// (T2, B1) search and the returned B1 must be the snapped input value.
546    #[test]
547    fn test_epg_fixed_b1_map_recovers_r2() {
548        let t2_true = 0.070;
549        let r2_true = 1.0 / t2_true;
550        let t1 = 1.2;
551        let b1_true = 0.7; // on the default b1_grid (0.6 + 5*0.02)
552        let esp = 0.010;
553        let n = 10;
554        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
555        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
556        let mag: Vec<f64> = train.iter().map(|&v| v * 850.0).collect();
557
558        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
559        let mask = vec![1u8];
560        let b1_known = vec![b1_true];
561        let (r2_map, b1_out) = r2_epg(
562            &mag,
563            &mask,
564            &te,
565            &grid,
566            &R2EpgParams::default(),
567            Some(&b1_known),
568        );
569        let err = (r2_map[0] - r2_true).abs() / r2_true;
570        assert!(
571            err < 0.06,
572            "fixed-B1 EPG R2 {:.3} Hz vs true {:.3} Hz (err {:.1}%)",
573            r2_map[0],
574            r2_true,
575            err * 100.0
576        );
577        assert!(
578            (b1_out[0] - b1_true).abs() < 1e-12,
579            "returned B1 {} should be the snapped input {}",
580            b1_out[0],
581            b1_true
582        );
583    }
584
585    /// Off-grid B1 values must snap to the nearest b1_grid entry, and a wrong
586    /// fixed B1 must actually constrain the fit (biasing R2 versus the truth) —
587    /// proving the search really is restricted to the supplied column.
588    #[test]
589    fn test_epg_fixed_b1_snaps_and_constrains() {
590        let t2_true = 0.070;
591        let r2_true = 1.0 / t2_true;
592        let t1 = 1.2;
593        let b1_true = 0.7;
594        let esp = 0.010;
595        let n = 10;
596        let te: Vec<f64> = (1..=n).map(|k| k as f64 * esp).collect();
597        let train = epg_cpmg_echoes(t2_true, t1, b1_true, esp, n);
598        let mag: Vec<f64> = train.iter().map(|&v| v * 850.0).collect();
599
600        let grid = Grid::new(1, 1, 1, 1.0, 1.0, 1.0);
601        let mask = vec![1u8];
602        let params = R2EpgParams::default();
603
604        // Off-grid input snaps to the nearest grid entry (0.695 -> 0.70).
605        let (_, b1_out) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[0.695]));
606        assert!(
607            (b1_out[0] - 0.70).abs() < 1e-12,
608            "B1 0.695 should snap to 0.70, got {}",
609            b1_out[0]
610        );
611
612        // Forcing perfect refocusing on a b1 = 0.7 train mis-reads the
613        // stimulated-echo signal as slower decay: R2 biased low vs truth.
614        let (r2_right, _) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[b1_true]));
615        let (r2_wrong, _) = r2_epg(&mag, &mask, &te, &grid, &params, Some(&[1.0]));
616        let err_right = (r2_right[0] - r2_true).abs() / r2_true;
617        let err_wrong = (r2_wrong[0] - r2_true).abs() / r2_true;
618        assert!(
619            r2_wrong[0] < r2_true && err_wrong > err_right + 0.05,
620            "wrong fixed B1 should bias R2 low: right {:.2} Hz (err {:.1}%), wrong {:.2} Hz (err {:.1}%), true {:.2} Hz",
621            r2_right[0],
622            err_right * 100.0,
623            r2_wrong[0],
624            err_wrong * 100.0,
625            r2_true
626        );
627    }
628
629    #[test]
630    fn test_r2prime_subtract_and_clamp() {
631        let r2star = vec![50.0, 30.0, 10.0, 0.0];
632        let r2 = vec![20.0, 35.0, 10.0, 5.0];
633        let mask = vec![1u8, 1, 0, 1];
634        let rp = r2prime(&r2star, &r2, &mask);
635        assert!((rp[0] - 30.0).abs() < 1e-12); // 50 - 20
636        assert!(rp[1].abs() < 1e-12); // clamp negative (30 - 35)
637        assert!(rp[2].abs() < 1e-12); // masked out
638        assert!(rp[3].abs() < 1e-12); // clamp negative (0 - 5)
639    }
640}