Skip to main content

qsm_core/utils/
echo_time.rs

1//! Which echo time converts a field in ppm to phase in radians.
2//!
3//! Several methods take the field in ppm but work on the phase in radians inside, converting
4//! with `2π·γ·B0·TE`. A field combined from several echoes has no single echo time, so the
5//! method has to be told which one to use, and the choice scales the phase: it changes how
6//! strongly a nonlinear data term, or a threshold defined in radians, acts, and so it can
7//! change the result. Each such method has an [`EchoTime`] parameter next to the scan's echo
8//! times, and its default is what that method's reference code uses (documented, with its
9//! source, on the parameter). With one echo every choice but [`EchoTime::Seconds`] is that
10//! echo's time.
11
12/// Which echo time a method converts the field from ppm to radians at.
13#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub enum EchoTime {
16    /// The first echo time, `TE(1)`.
17    First,
18    /// The last echo time, `TE(end)`.
19    Last,
20    /// The echo spacing, `TE(2) − TE(1)` as the MEDI toolbox's readers set `delta_TE`; the
21    /// echo time for one echo.
22    Spacing,
23    /// The sum of the echo times, `ΣTE`: the phase summed over the echoes, as STI Suite's
24    /// `QSM_GUI` passes its combined multi-echo phase to `QSM_star` (its `sum_TE` field).
25    Sum,
26    /// An explicit echo time in seconds, whatever the scan's echo times are.
27    Seconds(f64),
28}
29
30impl EchoTime {
31    /// The echo time in seconds this choice picks from `echo_times` (seconds, in acquisition
32    /// order).
33    ///
34    /// Errors if the choice needs the echo times and there are none, or if the result is not a
35    /// finite positive time (e.g. echoes given out of order for [`EchoTime::Spacing`]).
36    pub fn resolve(self, echo_times: &[f64]) -> Result<f64, String> {
37        let te = match self {
38            EchoTime::Seconds(te) => te,
39            _ if echo_times.is_empty() => {
40                return Err(format!("the {} needs the echo times; none were given", self.describe()));
41            }
42            EchoTime::First => echo_times[0],
43            EchoTime::Last => echo_times[echo_times.len() - 1],
44            EchoTime::Spacing if echo_times.len() == 1 => echo_times[0],
45            EchoTime::Spacing => echo_times[1] - echo_times[0],
46            EchoTime::Sum => echo_times.iter().sum(),
47        };
48        if te.is_finite() && te > 0.0 {
49            Ok(te)
50        } else {
51            Err(format!(
52                "the {} must be a finite time > 0 s, got {te} s (echo times {echo_times:?})",
53                self.describe()
54            ))
55        }
56    }
57
58    /// [`resolve`](Self::resolve) for a method that has no error path: panics with `method`
59    /// and the reason if the choice cannot be resolved. The pipeline checks first.
60    pub(crate) fn seconds(self, echo_times: &[f64], method: &str) -> f64 {
61        self.resolve(echo_times).unwrap_or_else(|e| panic!("{method}: {e}"))
62    }
63
64    fn describe(self) -> &'static str {
65        match self {
66            EchoTime::First => "first echo time",
67            EchoTime::Last => "last echo time",
68            EchoTime::Spacing => "echo spacing",
69            EchoTime::Sum => "sum of the echo times",
70            EchoTime::Seconds(_) => "echo time",
71        }
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn picks_each_choice() {
81        let te = [0.004, 0.012, 0.020];
82        assert_eq!(EchoTime::First.resolve(&te), Ok(0.004));
83        assert_eq!(EchoTime::Last.resolve(&te), Ok(0.020));
84        assert!((EchoTime::Spacing.resolve(&te).unwrap() - 0.008).abs() < 1e-15);
85        assert!((EchoTime::Sum.resolve(&te).unwrap() - 0.036).abs() < 1e-15);
86        assert_eq!(EchoTime::Seconds(0.03).resolve(&te), Ok(0.03));
87    }
88
89    #[test]
90    fn one_echo_is_that_echo_for_every_scan_choice() {
91        for c in [EchoTime::First, EchoTime::Last, EchoTime::Spacing, EchoTime::Sum] {
92            assert_eq!(c.resolve(&[0.02]), Ok(0.02), "{c:?}");
93        }
94    }
95
96    #[test]
97    fn refuses_what_it_cannot_resolve() {
98        assert!(EchoTime::First.resolve(&[]).unwrap_err().contains("needs the echo times"));
99        assert_eq!(EchoTime::Seconds(0.01).resolve(&[]), Ok(0.01));
100        assert!(EchoTime::Spacing.resolve(&[0.02, 0.01]).is_err());
101        assert!(EchoTime::Seconds(0.0).resolve(&[0.01]).is_err());
102        assert!(EchoTime::Seconds(f64::NAN).resolve(&[0.01]).is_err());
103    }
104}