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 ¶ms.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, ¶ms, 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, ¶ms, 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, ¶ms, Some(&[b1_true])).unwrap();
700 let (r2_wrong, _) = r2_epg(&mag, &mask, &te, &grid, ¶ms, 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, ¶ms, 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, ¶ms, None).is_err());
804 assert!(r2_epg(&[1.0; 3], &[1], &te, &grid, ¶ms, 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}