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 ¶ms.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, ¶ms, 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, ¶ms, 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, ¶ms, Some(&[b1_true]));
615 let (r2_wrong, _) = r2_epg(&mag, &mask, &te, &grid, ¶ms, 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}