qsm_core/separation/decompose.rs
1//! DECOMPOSE-QSM: signal-domain paramagnetic/diamagnetic source separation.
2//!
3//! DECOMPOSE (Chen et al., NeuroImage 2021) separates paramagnetic (χ+, iron) and
4//! diamagnetic (χ−, myelin·calcium) susceptibility by fitting a three-compartment
5//! complex multi-echo gradient-echo signal per voxel. Each voxel's signal is
6//!
7//! ```text
8//! S(t) = C+ · exp(−( a·χ+ + R2*₀ + i·(2/3)·χ+·γ·B0)·t)
9//! + C− · exp(−(−a·χ− + R2*₀ + i·(2/3)·χ−·γ·B0)·t)
10//! + C₀ · exp(−R2*₀·t)
11//! ```
12//!
13//! with `γ = 42.58·2π`, `a = 2π·γ·B0 / (9√3)` the static-dephasing broadening
14//! coefficient, χ+ ≥ 0, χ− ≤ 0 (ppm-scale). C+, C−, C₀ are the paramagnetic,
15//! diamagnetic and neutral compartment amplitudes and R2*₀ the baseline decay.
16//!
17//! As in the authors' `DECOMPOSE_analysis.m`, the *phase* is synthesized from a QSM of
18//! **each echo** (theirs: STAR-QSM per echo, `QSM_multi`): the complex data per echo is
19//! `y(TE) = |mag_norm(TE)| · exp(−i·(2/3)·χ(TE)·γ·B0·TE)` (magnitude normalised by its global
20//! maximum). [`decompose`] takes that per-echo QSM; producing it is the caller's. A voxel whose
21//! `Σ y` is zero is skipped, as theirs is.
22//!
23//! The per-voxel fit is a 3-stage alternating bounded least-squares (`lsqcurvefit`), repeated
24//! `n_inner` times: (1) amplitudes `[C+,C−,C₀]` against the linear signal, then
25//! (2) `R2*₀` and (3) `[χ+,χ−]` against `log(signal)` (complex log). The residual
26//! is the complex difference packed as `[Re; −Im]`, matching the reference.
27//!
28//! Each source is reconstructed from the fitted parameters via a per-compartment
29//! phase accumulation, `−Σ angle(model) / ((2/3)·γ·B0·ΣTE)`: the paramagnetic
30//! sub-model (`pscModel`) gives χ+ and the diamagnetic sub-model (`dscModel`)
31//! gives |χ−|. χ+ is returned ≥ 0, χ− ≤ 0.
32//!
33//! **Phase unwrapping** (off by default: [`DecomposeParams::unwrap_phase`]). The authors' code
34//! takes every phase on its principal branch (MATLAB's `log` and `angle`), and so does this
35//! port by default. With the option on: every phase the fit and the reconstruction see arrives
36//! through `atan2`, on `(−π, π]`. A compartment accumulates `(2/3)·χ·γ·B0·TE`
37//! radians, so above `|χ_total| = π / ((2/3)·γ·B0·TE_max)` the late echoes fold
38//! back onto that branch — 0.09 ppm at 7 T with a 28 ms last echo, which is
39//! ordinary globus pallidus. Left folded, the alternation converges to a
40//! degenerate fixed point (χ− pinned at the χ bound, R2*₀ and the amplitudes at
41//! zero) and returns χ+ ≈ 0, so the most paramagnetic structure in the
42//! volume reads as a hole. Both the stage-2/3 residuals and `recon_phase`
43//! therefore walk the echoes in order and keep each phase on the branch nearest
44//! its predecessor ([`unwrap_near`]); the synthesized data phase is known in
45//! closed form and is used unwrapped rather than round-tripped through `atan2`.
46//! The option does only this; it does not change the χ search (that is
47//! [`DecomposeParams::chi_bound`]). The original method, published at 3 T, does not meet this
48//! limit in practice — the threshold there is ~0.2 ppm.
49//!
50//! **The χ bound** ([`DecomposeParams::chi_bound`], a tuning value). The authors search
51//! `0 ≤ χ+ ≤ 0.5`, `−0.5 ≤ χ− ≤ 0` ppm, a bound "roughly calculated using TE = 25 ms and B0 = 3T
52//! to ensure the phase does not exceed 2π" (Chen et al. 2021, §2.2); that is the default
53//! ([`DecomposeChiBound::Ppm`]`(0.5)`). [`DecomposeChiBound::EchoSpacing`] instead takes the
54//! bound from the scan: `π / ((2/3)·γ·B0·ΔTE_max)`, with `ΔTE_max` the largest phase step the
55//! unwrapping walks (`TE₁` from zero, or any echo spacing). Unwrapping along the echoes is
56//! unambiguous only while the phase step between echoes stays under π, and this is the largest
57//! |χ| for which it does: 0.31 ppm (0.3145) at 7 T with 8 ms spacing, 0.73 ppm at 3 T. Past it the
58//! model matches a given χ on more than one branch and the stage-3 landscape grows spurious
59//! minima.
60//!
61//! **At 7 T.** The authors designed the fit for 3 T, and at 7 T the phase wraps between echoes
62//! inside ordinary deep grey matter. Measured 2026-10-30 on the QSM-CI 7 T chisep phantom
63//! (164×205×205, 1 mm, TE 4/12/20/28 ms; its provided χ given at every echo) with this code at
64//! astewartau/QSM.rs@add3d6e (the `lsqcurvefit` port, FDLIBM), `n_inner` 10:
65//!
66//! ```text
67//! QSMCI_CHISEP=<phantom> DECOMPOSE_UNWRAP=<0|1> DECOMPOSE_CHI_BOUND=<ppm|echo-spacing> \
68//! cargo test --release --features parallel --test chisep_relaxometry_qsmci \
69//! test_decompose_qsmci -- --ignored --nocapture
70//! ```
71//!
72//! "Pallidum" is the phantom's globus pallidus (`dseg` label 2, 3683 voxels, true mean χ+
73//! 0.137 ppm); "collapsed" counts its voxels whose χ+ is under a quarter of the truth.
74//!
75//! | `unwrap_phase` | `chi_bound` | χ+ r / XSIM / NRMSE | χ− r / XSIM / NRMSE | pallidum mean χ+ | collapsed |
76//! |---|---|---|---|---|---|
77//! | off (authors) | 0.5 (authors) | 0.484 / 0.216 / 10.9 % | 0.745 / 0.325 / 4.8 % | 0.002 | 3541 |
78//! | on | 0.5 | 0.658 / 0.250 / 10.0 % | 0.751 / 0.324 / 4.7 % | 0.027 | 2932 |
79//! | off | echo spacing (0.3145) | 0.587 / 0.367 / 9.7 % | 0.846 / 0.471 / 4.1 % | 0.005 | 3372 |
80//! | on | echo spacing (0.3145) | **0.895 / 0.489 / 7.1 %** | **0.865 / 0.477 / 4.0 %** | 0.111 | 17 |
81//!
82//! So the two help in different ways. The bound alone improves χ− (r 0.75 → 0.85) and χ+'s XSIM,
83//! but leaves the pallidum collapsed; unwrapping alone recovers little of it; together they
84//! recover it. (The rows were first measured with the bound hard-wired into the unwrapping
85//! option, as `min(0.5, π / ((2/3)·γ·B0·ΔTE_max))`; that option, and a plain `chi_bound` of
86//! 0.3145 with and without unwrapping, gave the same metrics to the digits shown, and so does
87//! [`DecomposeChiBound::EchoSpacing`]. An older note
88//! here, from the bounded-LM port, said the bound removed "a speckle of dropouts" that unwrapping
89//! alone left; with this solver, unwrapping alone leaves most of the pallidum collapsed, and
90//! voxels at exactly χ+ = 0 number 1–8 of 1.31 M in every row.) The defaults stay the authors'
91//! (off, 0.5); the pipeline rules `decompose.unwrap-7t` and `decompose.chi-bound-7t` suggest
92//! both at 7 T (from 6.5 T, so a Siemens 7 T's 6.98 T counts). An earlier figure, χ+ r 0.59 for
93//! the authors' default against 0.87 (QSM.rs#333's CI run 40786263426), compared ports that also
94//! differed in the solver, so it isolated neither change.
95//!
96//! Note that `−Σ angle / den` remains a *compressive* estimator of χ: its gain
97//! rises with χ itself (≈0.03 at white-matter χ+, ≈0.87 in the pallidum on the
98//! QSM-CI phantom), so it flattens contrast between weak sources. That is
99//! inherent to the reference's reconstruction, not to this port — the fitted
100//! compartment amplitudes set the gain, and they are only weakly identifiable
101//! from data that carries no sub-voxel compartment signature.
102//!
103//! **Even on its own model the fit underestimates.** Data generated noise-free from the signal
104//! model above was given as the per-echo QSM, χ(TE) = −∠S(TE) / ((2/3)·γ·B0·TE) (Eq. 4). That is
105//! 400 voxels with C± 0.05–0.4, χ± ±(0.01–0.15) ppm, R2*₀ 15–25 s⁻¹, at 3 T with 16 echoes from
106//! 2.7 to 46.2 ms; no phase wraps. Measured 2026-11-01, `n_inner` 10:
107//!
108//! ```text
109//! r vs Eq. 6/7 at the truth median out/truth slope through 0 exactly 0
110//! χ+ (vs PCS) 0.85 0.71 0.86 44 / 400
111//! χ− (vs DCS) 0.83 0.70 0.85 48 / 400
112//! ```
113//!
114//! The fitted C± and χ± correlate with the truth at only r 0.15–0.40. The 10-pass alternation
115//! stops at a fixed point short of the global minimum from the reference's start values. On such
116//! data that minimum is exact: a global fit of all six parameters recovers the truth. MATLAB R2023b, running
117//! the reference loop (the QSM-CI `decompose-qsm` code) on the same voxels, gives the same table.
118//! This port is within 0.6 ppb of it in every voxel (NRMSE 0.47 % χ+, 0.29 % χ−). So the
119//! underestimate belongs to the method, not to the port.
120//!
121//! **Output mapping.** The port fits what the authors fit: their `ydata =
122//! squeeze(y_data(i,j,k,:))'` is MATLAB's conjugate transpose, so the fit is to conj(y), in their
123//! parameter order and with their bounds and initial values. Conjugating the data mirrors the fit
124//! (`conj(S(C+, C−, χ+, χ−)) = S(C−, C+, −χ−, −χ+)`), which is why their script writes DSC to its
125//! paramagnetic file (`XPSC`) and PSC to the diamagnetic one; the port writes χ+ = |DSC| and
126//! χ− = −|PSC| the same way. (Fitting y itself and writing PSC as χ+ gives the same maps
127//! mathematically, as earlier versions of this port did, but not the same rounding, and the
128//! solver's path through a near-degenerate voxel follows rounding.)
129//!
130//! **Solver.** Every stage is MATLAB `lsqcurvefit` with its default options, which the authors
131//! use: the trust-region-reflective method, ported in [`crate::solvers::lsq_trr`] and verified
132//! against MATLAB R2023b call by call on this fit (its module docs, *Matching MATLAB*: 92.6 % of
133//! the fixture's 11340 stage calls take MATLAB's exact path, all stage-2 calls among them). The
134//! solver of each stage is selectable ([`DecomposeParams::solvers`]), for the paper's choice.
135//! Before that this port used a bounded Levenberg–Marquardt, which left χ± 2.9–3.3 % (NRMSE) from
136//! the authors' code on the KKI crop below and 11.1 % / 9.0 % on the full chisep-mc phantom
137//! (astewartau/QSM.rs#329).
138//!
139//! # Agreement with the authors' code
140//!
141//! Against the QSM-CI `decompose-qsm` container (the authors' `utils/` and fit loop compiled
142//! from licensed MATLAB R2023b, verified identical to it, QSMxT/QSM-CI#309), given the same
143//! per-echo QSM:
144//!
145//! | port | data | χ+ NRMSE (r) | χ− NRMSE (r) |
146//! |---|---|---|---|
147//! | unwrapping and χ cap on, LM 30 iterations, stop at 1e-10 | KKI 24×24×16 crop (3 T, 5 echoes) | 92.0 % (0.587) | 34.9 % (0.933) |
148//! | principal-branch phases, LM 30 iterations | KKI crop | 8.2 % (0.9956) | 10.1 % (0.9951) |
149//! | LM to 400 iterations, stop at 1e-12 | KKI crop | 2.9 % (0.9994) | 3.3 % (0.9994) |
150//! | LM to 400 iterations, stop at 1e-12 | `tests/data/decompose_qsm_small` (378 voxels) | 1.1 % (0.99985) | 11.6 % (0.9915) |
151//! | **`lsqcurvefit` trust-region-reflective** (default) | `tests/data/decompose_qsm_small` | **0.24 %** (0.99999) | **0.90 %** (0.99995) |
152//!
153//! What remains is concentrated in voxels where one compartment collapses (an amplitude or χ
154//! within rounding of 0): there the Jacobian column of the vanished parameter is rounding noise,
155//! and MATLAB's and Rust's elementary functions round differently (see `lsq_trr`).
156//!
157//! # Threads
158//!
159//! Voxels are fitted in parallel (rayon, feature `parallel`), and the result is bit-identical at
160//! any thread count. The fit allocates nothing per voxel (see the loop). On a 6-core i5-11500,
161//! 12000 synthetic 6-echo voxels, static musl build: 1.37 s on 1 thread, 0.70 s on 2, 0.37 s on
162//! 4, 0.26 s on 6, 0.23 s on 12. (With bounded LM to 400 iterations: 28.0 s, 8.0 s, 6.4 s on 1,
163//! 4, 6 threads; `lsqcurvefit`'s 1e-6 tolerances end each stage far sooner than LM's 1e-12.)
164//!
165//! # Reference pipeline
166//!
167//! Chen et al. 2021 (NeuroImage 242:118477, §3.6 "Image preparation and QSM reconstruction"; PMC8720043):
168//!
169//! - **Data:** in vivo at 3 T (GE MR750w), 0.86 × 0.86 × 1.0 mm, 16 echoes,
170//! TE1/ΔTE/TE16 = 2.7/2.9/46.2 ms. The phantom was 1 mm iso, 16 echoes 1.47–25.9 ms; the ex
171//! vivo data were 7 T.
172//! - **Mask:** FSL BET. **Unwrapping:** Laplacian. **Background removal:** V-SHARP.
173//! - **QSM input:** STAR-QSM computed **for each echo** ("Note that QSM of each echo, χ(t), is
174//! used instead of the average QSM across all echoes", §2.2; "it is necessary to use
175//! TE-dependent QSM as the input", §4.1). A host gets it from a per-echo reconstruction
176//! ([`crate::pipeline::per_echo`]). The signal model (Eq. 4) is
177//! `y(t) = M(t)·exp(−i·(2/3)·χ(t)·γ·B0·t)` with the per-echo χ(t), not one echo-combined χ.
178//! [`decompose`] takes that per-echo χ. (The authors' script runs STAR-QSM per echo at
179//! `padsize` 12 after `MRPhaseUnwrap` and `V_SHARP(smvsize 25)` of that echo.)
180//! - **Referencing:** χ referenced to the whole-brain mean.
181//! - **Constants:** `0.5 > |χ+|, |χ−| > 0` (§2.2, [`DecomposeParams::chi_bound`]'s default), and
182//! `a = 323.5 Hz/ppm` at 3 T (§2.1), which the formula above reproduces and which scales with
183//! B0 by construction.
184//!
185//! [`DecomposeParams`]'s `b0` defaults to 7 T. The pipeline dispatcher overrides it from the scan
186//! metadata.
187//!
188//! Reference:
189//! Chen, J., et al. (2021). "Decompose quantitative susceptibility mapping (QSM)
190//! to sub-voxel diamagnetic and paramagnetic components based on gradient-echo MRI
191//! data." NeuroImage 242:118477. https://doi.org/10.1016/j.neuroimage.2021.118477
192//! Reference implementation (QSM-CI port of Tim Ho's open MATLAB DECOMPOSE-QSM).
193//!
194//! # Pipeline rules
195//!
196//! The facts above that a configuration can depart from are published as rules in
197//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `decompose.mask-bet`, `decompose.unwrap-laplacian`, `decompose.bg-vsharp`, `decompose.qsm-star`, `decompose.qsm-per-echo` (Recommended: stated by the authors, §2.2 and §4.1), `decompose.reference-mean`, and at 7 T `decompose.unwrap-7t` and `decompose.chi-bound-7t` (Recommended: measured, *At 7 T* above).
198
199#[cfg(feature = "parallel")]
200use rayon::prelude::*;
201
202use std::f64::consts::PI;
203
204use crate::error::{check_len, InputError};
205use crate::solvers::lsq_trr::{lsq_trust_region_reflective, LsqTrrOptions, LsqTrrWorkspace};
206use crate::utils::fdlibm;
207
208/// Gyromagnetic constant used by the DECOMPOSE reference: `42.58 · 2π` — the reference
209/// implementation's value (`signalModel.m`), not `crate::constants`. Keep it.
210const GAMMA: f64 = 42.58 * 2.0 * PI;
211
212/// Parameters for [`decompose`].
213#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
214#[derive(Clone, Debug)]
215pub struct DecomposeParams {
216 /// Main field strength in Tesla.
217 pub b0: f64,
218 /// Number of alternating 3-stage fit passes per voxel (reference default: 10).
219 pub n_inner: usize,
220 /// The bound on |χ+| and |χ−| in the stage-3 search. Default: the authors' 0.5 ppm
221 /// ([`DecomposeChiBound::Ppm`]); [`DecomposeChiBound::EchoSpacing`] takes it from the scan
222 /// (see the module docs, *The χ bound*).
223 pub chi_bound: DecomposeChiBound,
224 /// `optimoptions('lsqcurvefit', …)` of every stage: iterations, evaluations, tolerances and
225 /// finite-difference settings. Default: MATLAB's defaults ([`LsqTrrOptions::default`]), which
226 /// the authors' code keeps (`MaxIterations` 400, the 1e-6 tolerances).
227 pub lsqcurvefit: LsqTrrOptions,
228 /// Unwrap the phase along the echoes (see the module docs, *Phase unwrapping*). Default
229 /// `false`: the authors' code takes every phase on its principal branch (`log`, `angle`).
230 /// It leaves the χ search to [`chi_bound`](Self::chi_bound).
231 pub unwrap_phase: bool,
232 /// The solver of each of the three stages (see [`DecomposeStageSolvers`]).
233 pub solvers: DecomposeStageSolvers,
234}
235
236/// Which solver fits each stage of the alternating fit.
237///
238/// The reference implementation (Tim Ho's MATLAB DECOMPOSE-QSM, which the QSM-CI `decompose-qsm`
239/// container runs) calls `lsqcurvefit` with its default trust-region-reflective algorithm for all
240/// three stages, and that is the default here. The paper (Chen et al. 2021, §3.1) describes a
241/// different choice: Eq. 5.1 (the amplitudes, with `C+ + C− + C₀ = 1`) and Eq. 5.2 (R2*₀) by
242/// `lsqlin`'s interior-point method, and only Eq. 5.3 (χ±) by `lsqnonlin` trust-region-reflective
243/// with a supplied Jacobian sparsity pattern. Those solvers are not ported (yet); this is where
244/// they would be selected.
245#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247pub struct DecomposeStageSolvers {
248 /// Stage 1, the amplitudes `[C+, C−, C₀]`.
249 pub amplitudes: DecomposeStageSolver,
250 /// Stage 2, `R2*₀`.
251 pub r2star: DecomposeStageSolver,
252 /// Stage 3, `[χ+, χ−]`.
253 pub chi: DecomposeStageSolver,
254}
255
256impl Default for DecomposeStageSolvers {
257 fn default() -> Self {
258 let s = DecomposeStageSolver::LsqcurvefitTrustRegionReflective;
259 Self { amplitudes: s, r2star: s, chi: s }
260 }
261}
262
263/// A stage solver for [`decompose`].
264#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub enum DecomposeStageSolver {
267 /// MATLAB `lsqcurvefit` with its default options (trust-region-reflective, finite-difference
268 /// Jacobian): [`lsq_trust_region_reflective`]. The reference implementation's solver.
269 LsqcurvefitTrustRegionReflective,
270}
271
272/// The bound on |χ| in DECOMPOSE's stage-3 search (`[0, −bound]` to `[bound, 0]`), see the
273/// module docs, *The χ bound*.
274#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
275#[derive(Clone, Copy, Debug, PartialEq)]
276pub enum DecomposeChiBound {
277 /// A fixed bound in ppm. The authors' is 0.5 (Chen et al. 2021, §2.2), the default.
278 Ppm(f64),
279 /// `π / ((2/3)·γ·B0·ΔTE_max)`, the largest |χ| whose phase step between echoes stays under π,
280 /// so that unwrapping along the echoes is unambiguous; `ΔTE_max` is the largest of `TE₁` and
281 /// the echo spacings. A tuning value: 0.3145 ppm at 7 T with 8 ms spacing.
282 EchoSpacing,
283}
284
285impl DecomposeParams {
286 /// Checks a fixed `chi_bound` ([`DecomposeChiBound::Ppm`]): it must be ≥ 0 ppm (a negative
287 /// bound leaves the fit's lower bound above its upper one). An
288 /// [`EchoSpacing`](DecomposeChiBound::EchoSpacing) bound depends on the echo times, and
289 /// [`decompose`] checks it.
290 pub fn validate(&self) -> Result<(), InputError> {
291 if let DecomposeChiBound::Ppm(_) = self.chi_bound {
292 self.chi_bound.resolve(self.b0, &[])?;
293 }
294 Ok(())
295 }
296}
297
298impl Default for DecomposeChiBound {
299 fn default() -> Self {
300 DecomposeChiBound::Ppm(0.5)
301 }
302}
303
304impl DecomposeChiBound {
305 /// Stable id: `"ppm"` (whatever its value) or `"echo-spacing"`.
306 pub fn id(self) -> &'static str {
307 match self {
308 DecomposeChiBound::Ppm(_) => "ppm",
309 DecomposeChiBound::EchoSpacing => "echo-spacing",
310 }
311 }
312
313 /// The bound in ppm at field `b0` (T) and `echo_times` (s, in acquisition order). Errors if
314 /// it is not ≥ 0 (a negative bound leaves the fit's lower bound above its upper one), or for
315 /// [`EchoSpacing`](Self::EchoSpacing) not a finite positive bound (no echoes, a time ≤ 0).
316 pub fn resolve(self, b0: f64, echo_times: &[f64]) -> Result<f64, InputError> {
317 match self {
318 DecomposeChiBound::Ppm(x) if x.is_nan() || x < 0.0 => {
319 Err(InputError::param("DECOMPOSE chi_bound", x, ">= 0 ppm"))
320 }
321 DecomposeChiBound::Ppm(x) => Ok(x),
322 DecomposeChiBound::EchoSpacing => {
323 let first = echo_times.first().copied().unwrap_or(f64::NAN);
324 let dte = echo_times.windows(2).map(|w| w[1] - w[0]).fold(first, f64::max);
325 let bound = PI / ((2.0 / 3.0) * GAMMA * b0 * dte);
326 if bound.is_finite() && bound > 0.0 {
327 Ok(bound)
328 } else {
329 Err(InputError::new(format!(
330 "DECOMPOSE chi_bound from the echo spacing must be a finite bound > 0 ppm, \
331 got {bound} (B0 {b0} T, echo times {echo_times:?})"
332 )))
333 }
334 }
335 }
336 }
337}
338
339impl Default for DecomposeParams {
340 fn default() -> Self {
341 Self {
342 b0: 7.0,
343 n_inner: 10,
344 chi_bound: DecomposeChiBound::default(),
345 lsqcurvefit: LsqTrrOptions::default(),
346 unwrap_phase: false,
347 solvers: DecomposeStageSolvers::default(),
348 }
349 }
350}
351
352/// DECOMPOSE source separation from per-echo QSM and the multi-echo magnitude.
353///
354/// # Arguments
355/// * `chi_per_echo` — QSM of **each echo** in **ppm**, flattened as `(n_voxels, n_echoes)`
356/// (echo fastest per voxel, like `magnitude`): the authors' input (`QSM_multi`, a STAR-QSM
357/// per echo in `DECOMPOSE_analysis.m`). It sets each echo's phase,
358/// `−(2/3)·χ(TE)·γ·B0·TE`.
359/// * `magnitude` — Multi-echo magnitude, flattened as `(n_voxels, n_echoes)` in
360/// row-major order (echo fastest per voxel) — the same layout as
361/// [`crate::separation::r2star_qsm_from_magnitude`]. Normalised internally by
362/// its global maximum.
363/// * `echo_times` — Echo times in **seconds** (`n_echoes`).
364/// * `mask` — Binary brain mask (`n_voxels`, 1 = inside).
365/// * `params` — See [`DecomposeParams`].
366/// * `progress` — Progress callback `(voxels_done, voxels_total)`, called
367/// periodically over the fitted (masked) voxels.
368///
369/// # Returns
370/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
371/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
372/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
373/// and `chi_total = chi_pos + chi_neg`.
374pub fn decompose(
375 chi_per_echo: &[f64],
376 magnitude: &[f64],
377 echo_times: &[f64],
378 mask: &[u8],
379 params: &DecomposeParams,
380 mut progress: impl FnMut(usize, usize),
381) -> Result<super::ChiSepMaps, InputError> {
382 params.validate()?;
383 let n = mask.len();
384 let ne = echo_times.len();
385 if ne < 3 {
386 return Err(InputError::new(format!("DECOMPOSE needs at least 3 echoes, got {ne}")));
387 }
388 check_len("DECOMPOSE per-echo QSM (n_voxels * n_echoes)", chi_per_echo.len(), n * ne)?;
389 check_len("DECOMPOSE magnitude (n_voxels * n_echoes)", magnitude.len(), n * ne)?;
390
391 let b0 = params.b0;
392 let te = echo_times;
393 let upper = params.chi_bound.resolve(b0, te)?;
394
395 // Global-max magnitude normalisation (matches the reference).
396 let gmax = magnitude.iter().cloned().fold(0.0_f64, f64::max).max(1e-30);
397
398 // Reconstruction denominator: (2/3)·γ·B0·ΣTE.
399 let den = (2.0 / 3.0) * GAMMA * b0 * te.iter().sum::<f64>();
400
401 // Masked voxel indices (only these are fit).
402 let voxels: Vec<usize> = (0..n).filter(|&i| mask[i] != 0).collect();
403 let total = voxels.len();
404
405 // Per-voxel fit → (index, chi_pos, chi_neg). Embarrassingly parallel.
406 //
407 // The fit allocates nothing: every buffer lives in a `Scratch` that rayon hands out per
408 // worker job and reuses across voxels. This matters far beyond the usual allocator overhead:
409 // the released binaries are static musl builds, whose allocator serialises concurrent small
410 // allocations. Before QSM.rs#93 this loop made ~10⁴ allocations per voxel and got *slower*
411 // with every thread added (QSMxT v9.16.0 on 3200 voxels: 2.2 s on 1 thread, 6.3 s on 8);
412 // allocation-free, it scales with the physical cores (the module docs, *Threads*).
413 let results: Vec<(usize, f64, f64)> = maybe_par_map_init!(
414 voxels,
415 || Scratch::new(ne),
416 |ws: &mut Scratch, &i: &usize| {
417 // The authors' `ydata = squeeze(y_data(i,j,k,:))'`: MATLAB's `'` is the conjugate
418 // transpose, so they fit conj(y), y[e] = mag_norm · exp(−i·(2/3)·χ(TE_e)·γ·B0·TE_e).
419 let (mut sum_re, mut sum_im) = (0.0, 0.0);
420 for e in 0..ne {
421 let m = magnitude[i * ne + e] / gmax;
422 let ph = -(2.0 / 3.0) * chi_per_echo[i * ne + e] * GAMMA * b0 * te[e];
423 let y = (m * fdlibm::cos(ph), m * fdlibm::sin(ph));
424 ws.yc[e] = (y.0, -y.1);
425 sum_re += ws.yc[e].0;
426 sum_im += ws.yc[e].1;
427 // log(conj y) for stages 2 & 3: MATLAB's `log`, on the principal branch, unless
428 // `unwrap_phase`, where the synthesized phase is used as it is (see
429 // `unwrap_near`).
430 let (lr, la) = matlab_log(ws.yc[e]);
431 ws.logyc[e] = (lr, if params.unwrap_phase { -ph } else { la });
432 }
433 // The authors skip a voxel whose `sum(ydata)` is zero.
434 if sum_re == 0.0 && sum_im == 0.0 {
435 return (i, 0.0, 0.0);
436 }
437
438 let p = fit_voxel(te, &ws.yc, &ws.logyc, b0, upper, params, &mut ws.solver);
439
440 // The authors' reconstruction: PSC = −Σangle(pscModel)/den, DSC = −Σangle(dscModel)/den,
441 // written as χ+ = |DSC| (`XPSC`) and |χ−| = |PSC| (`XDSC`). Fitting conj(y) mirrors the
442 // compartments, so it is DSC that carries the paramagnetic source (module docs,
443 // *Output mapping*).
444 let unwrap = params.unwrap_phase;
445 let dsc = recon_phase(te, den, unwrap, |t| dsc_model(t, &p, b0));
446 let psc = recon_phase(te, den, unwrap, |t| psc_model(t, &p, b0));
447 (i, dsc.abs(), -psc.abs())
448 }
449 )
450 .collect();
451
452 let mut chi_pos = vec![0.0_f64; n];
453 let mut chi_neg = vec![0.0_f64; n];
454 let mut chi_out = vec![0.0_f64; n];
455 for (i, pos, neg) in results {
456 chi_pos[i] = pos;
457 chi_neg[i] = neg;
458 chi_out[i] = pos + neg;
459 }
460 progress(total, total);
461 Ok((chi_pos, chi_neg, chi_out))
462}
463
464/// One voxel's fitted parameters, in the authors' order and sign convention (fitted to conj(y)).
465#[derive(Clone, Copy, Debug, PartialEq)]
466struct Fit {
467 /// `C_plus`, `C_minus`, `C_0`.
468 c: [f64; 3],
469 /// `R_star_2_0`.
470 r0: f64,
471 /// `chi_plus`, `chi_minus`.
472 chi: [f64; 2],
473}
474
475/// Reusable scratch for one voxel's fit. Every buffer is fully overwritten before it is read, so
476/// a `Scratch` shared across voxels cannot carry state between them.
477struct Scratch {
478 /// conj(y) per echo.
479 yc: Vec<(f64, f64)>,
480 /// `log(conj(y))` per echo.
481 logyc: Vec<(f64, f64)>,
482 solver: StageSolvers,
483}
484
485impl Scratch {
486 fn new(ne: usize) -> Self {
487 Self { yc: vec![(0.0, 0.0); ne], logyc: vec![(0.0, 0.0); ne], solver: StageSolvers::new(2 * ne) }
488 }
489}
490
491/// `lsqcurvefit` workspaces for the three stages (3, 1 and 2 parameters).
492struct StageSolvers {
493 s1: LsqTrrWorkspace,
494 s2: LsqTrrWorkspace,
495 s3: LsqTrrWorkspace,
496}
497
498impl StageSolvers {
499 fn new(m: usize) -> Self {
500 Self { s1: LsqTrrWorkspace::new(m, 3), s2: LsqTrrWorkspace::new(m, 1), s3: LsqTrrWorkspace::new(m, 2) }
501 }
502}
503
504/// The authors' three-stage alternating fit for one voxel (`DECOMPOSE_analysis.m`):
505///
506/// ```text
507/// chi_0 = [0.05 -0.05]; R0 = 25; C0 = [0.3 0.3 0.4];
508/// for it = 1:N_inner
509/// C0 = lsqcurvefit(objective1 (C), C0, TE, complex_to_real(ydata), [0 0 0], [inf inf inf]);
510/// R0 = lsqcurvefit(objective2 (R0), R0, TE, complex_to_real(log(ydata)), 0, inf);
511/// chi_0 = lsqcurvefit(objective3 (χ), chi_0, TE, complex_to_real(log(ydata)), [0 -0.5], [0.5 0]);
512/// end
513/// ```
514///
515/// with `lsqcurvefit`'s default options, which [`lsq_trust_region_reflective`] reproduces. The
516/// χ bound `upper` is [`DecomposeParams::chi_bound`], resolved (the authors' 0.5 by default).
517/// `lsqcurvefit`'s residual is `fun(x, TE) − ydata`, packed by `complex_to_real` as `[Re; −Im]`.
518fn fit_voxel(
519 te: &[f64],
520 yc: &[(f64, f64)],
521 logyc: &[(f64, f64)],
522 b0: f64,
523 upper: f64,
524 params: &DecomposeParams,
525 ws: &mut StageSolvers,
526) -> Fit {
527 let ne = te.len();
528 let mut p = Fit { c: [0.3, 0.3, 0.4], r0: 25.0, chi: [0.05, -0.05] };
529
530 let unwrap = params.unwrap_phase;
531 let inf = f64::INFINITY;
532 let opts = ¶ms.lsqcurvefit;
533
534 // `complex_to_real(log(model)) − complex_to_real(log(ydata))`, phases optionally unwrapped
535 // along the echoes.
536 let log_resid = |q: &Fit, out: &mut [f64]| {
537 let mut prev = 0.0;
538 for e in 0..ne {
539 let z = signal_model(te[e], q, b0);
540 let (lr, la) = matlab_log(z);
541 let ang = if e == 0 || !unwrap { la } else { unwrap_near(la, prev) };
542 prev = ang;
543 out[e] = lr - logyc[e].0;
544 out[ne + e] = -ang - -logyc[e].1;
545 }
546 };
547
548 for _ in 0..params.n_inner {
549 // Stage 1: objective1, the amplitudes [C+, C−, C₀] against the linear signal.
550 let q = p;
551 let mut x = p.c;
552 match params.solvers.amplitudes {
553 DecomposeStageSolver::LsqcurvefitTrustRegionReflective => lsq_trust_region_reflective(
554 |x: &[f64], out: &mut [f64]| {
555 let q = Fit { c: [x[0], x[1], x[2]], ..q };
556 for e in 0..ne {
557 let z = signal_model(te[e], &q, b0);
558 out[e] = z.0 - yc[e].0;
559 out[ne + e] = -z.1 - -yc[e].1;
560 }
561 },
562 &mut x,
563 &[0.0; 3],
564 &[inf; 3],
565 opts,
566 &mut ws.s1,
567 |_| {},
568 ),
569 };
570 p.c = x;
571 // Stage 2: objective2, R2*₀ against log(ydata).
572 let q = p;
573 let mut x = [p.r0];
574 match params.solvers.r2star {
575 DecomposeStageSolver::LsqcurvefitTrustRegionReflective => lsq_trust_region_reflective(
576 |x: &[f64], out: &mut [f64]| log_resid(&Fit { r0: x[0], ..q }, out),
577 &mut x,
578 &[0.0],
579 &[inf],
580 opts,
581 &mut ws.s2,
582 |_| {},
583 ),
584 };
585 p.r0 = x[0];
586 // Stage 3: objective3, [χ+, χ−] against log(ydata).
587 let q = p;
588 let mut x = p.chi;
589 match params.solvers.chi {
590 DecomposeStageSolver::LsqcurvefitTrustRegionReflective => lsq_trust_region_reflective(
591 |x: &[f64], out: &mut [f64]| log_resid(&Fit { chi: [x[0], x[1]], ..q }, out),
592 &mut x,
593 &[0.0, -upper],
594 &[upper, 0.0],
595 opts,
596 &mut ws.s3,
597 |_| {},
598 ),
599 };
600 p.chi = x;
601 }
602 p
603}
604
605/// MATLAB's complex `log(z)` as `(log|z|, angle z)`: FDLIBM's `atan2` and MATLAB's rule for the
606/// real part ([`fdlibm::log_abs`]), bit for bit.
607#[inline]
608fn matlab_log(z: (f64, f64)) -> (f64, f64) {
609 (fdlibm::log_abs(z.0, z.1), fdlibm::atan2(z.1, z.0))
610}
611
612/// `C · exp(−(decay + i·freq)·t)` evaluated as MATLAB does it: `exp` of the complex exponent
613/// `(−decay·t, −freq·t)` is `e^{re}·(cos im, sin im)`, then scaled by `C`.
614#[inline]
615fn compartment(amp: f64, decay: f64, freq: f64, t: f64) -> (f64, f64) {
616 let e = fdlibm::exp(-decay * t);
617 let im = -freq * t;
618 (amp * (e * fdlibm::cos(im)), amp * (e * fdlibm::sin(im)))
619}
620
621/// `a = 2π·γ·B0 / (9√3)` (`signalModel.m`).
622#[inline]
623fn broadening(b0: f64) -> f64 {
624 (2.0 * PI * GAMMA * b0) / (9.0 * 3.0_f64.sqrt())
625}
626
627/// The authors' `signalModel.m` at echo time `t` (seconds). Returns `(re, im)`.
628#[inline]
629fn signal_model(t: f64, p: &Fit, b0: f64) -> (f64, f64) {
630 let a = broadening(b0);
631 let t1 = compartment(p.c[0], a * p.chi[0] + p.r0, (2.0 / 3.0) * p.chi[0] * GAMMA * b0, t);
632 let t2 = compartment(p.c[1], -a * p.chi[1] + p.r0, (2.0 / 3.0) * p.chi[1] * GAMMA * b0, t);
633 let t3 = p.c[2] * fdlibm::exp(-p.r0 * t);
634 (t1.0 + t2.0 + t3, t1.1 + t2.1)
635}
636
637/// The authors' `pscModel.m`: the `C_plus` compartment, the rest at the baseline decay.
638#[inline]
639fn psc_model(t: f64, p: &Fit, b0: f64) -> (f64, f64) {
640 let a = broadening(b0);
641 let t1 = compartment(p.c[0], a * p.chi[0] + p.r0, (2.0 / 3.0) * p.chi[0] * GAMMA * b0, t);
642 let t3 = (p.c[2] + p.c[1]) * fdlibm::exp(-p.r0 * t);
643 (t1.0 + t3, t1.1)
644}
645
646/// The authors' `dscModel.m`: the `C_minus` compartment with its frequency negated
647/// (`(2/3)·(−χ−)·γ·B0`), the rest at the baseline decay.
648#[inline]
649fn dsc_model(t: f64, p: &Fit, b0: f64) -> (f64, f64) {
650 let a = broadening(b0);
651 let t2 = compartment(p.c[1], -a * p.chi[1] + p.r0, (2.0 / 3.0) * -p.chi[1] * GAMMA * b0, t);
652 let t3 = (p.c[2] + p.c[0]) * fdlibm::exp(-p.r0 * t);
653 (t2.0 + t3, t2.1)
654}
655
656/// `2π`, the period of the phase branch cut.
657const TAU: f64 = 2.0 * PI;
658
659/// Put `ang` on the 2π-branch nearest `prev` (only with [`DecomposeParams::unwrap_phase`]).
660///
661/// Every phase here reaches us through `atan2`, which folds onto `(−π, π]`. That
662/// fold is not harmless: a compartment accumulates `(2/3)·χ·γ·B0·TE` radians, so
663/// at `|χ_total| > π / ((2/3)·γ·B0·TE_max)` — 0.09 ppm at 7 T with a 28 ms last
664/// echo, i.e. inside the globus pallidus — the late echoes fold back and the
665/// alternating fit chases the fold into a degenerate fixed point (χ− pinned at
666/// its bound, R2*₀ and the amplitudes driven to zero, χ+ stuck at 0). Walking the
667/// echoes in order and keeping each phase on the branch nearest its predecessor
668/// restores the monotone ramp the model assumes.
669///
670/// The first echo is left on its principal branch: it anchors the series, and at
671/// any sane `TE₁` its phase is well inside `(−π, π]` for `|χ| ≤ chi_bound`.
672#[inline]
673fn unwrap_near(ang: f64, prev: f64) -> f64 {
674 ang + ((prev - ang) / TAU).round() * TAU
675}
676
677/// Reconstruct a source value: `−Σ angle(model(TE)) / den`, with (if `unwrap`) the phase
678/// unwrapped along the echo axis so a strong source is not folded back to zero
679/// (see [`unwrap_near`]).
680fn recon_phase(te: &[f64], den: f64, unwrap: bool, model: impl Fn(f64) -> (f64, f64)) -> f64 {
681 let mut s = 0.0;
682 let mut prev = 0.0;
683 for (e, &t) in te.iter().enumerate() {
684 let z = model(t);
685 let raw = fdlibm::atan2(z.1, z.0);
686 let ang = if e == 0 || !unwrap { raw } else { unwrap_near(raw, prev) };
687 prev = ang;
688 s += ang;
689 }
690 -s / den
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696
697 /// One χ per voxel repeated at every echo: the per-echo QSM `decompose` takes.
698 fn per_echo(chi: &[f64], ne: usize) -> Vec<f64> {
699 chi.iter().flat_map(|&c| std::iter::repeat_n(c, ne)).collect()
700 }
701
702 /// Output mapping on a synthetic voxel: χ+ = |DSC| and χ− = −|PSC| of the parameters fitted
703 /// to conj(y), as the authors write them, and on paramagnetic-dominant data χ+ dominates.
704 #[test]
705 fn outputs_are_the_authors_dsc_and_psc() {
706 let te = [0.004, 0.012, 0.020, 0.028];
707 let b0 = 7.0;
708 let params = DecomposeParams {
709 b0,
710 n_inner: 12,
711 chi_bound: DecomposeChiBound::Ppm(0.5),
712 lsqcurvefit: LsqTrrOptions::default(),
713 unwrap_phase: false,
714 solvers: Default::default(),
715 };
716 let truth = Fit {
717 c: [0.5, 0.1, 0.4],
718 r0: 20.0,
719 chi: [0.10, -0.02],
720 };
721 let ne = te.len();
722 let mag: Vec<f64> = te
723 .iter()
724 .map(|&t| {
725 let z = signal_model(t, &truth, b0);
726 (z.0 * z.0 + z.1 * z.1).sqrt()
727 })
728 .collect();
729 let chi = per_echo(&[truth.chi[0] + truth.chi[1]], ne);
730 let (pos, neg, tot) = decompose(&chi, &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap();
731
732 // The same fit by hand.
733 let gmax = mag.iter().cloned().fold(0.0, f64::max);
734 let yc: Vec<(f64, f64)> = (0..ne)
735 .map(|e| {
736 let ph = -(2.0 / 3.0) * chi[e] * GAMMA * b0 * te[e];
737 (mag[e] / gmax * fdlibm::cos(ph), -(mag[e] / gmax * fdlibm::sin(ph)))
738 })
739 .collect();
740 let logyc: Vec<(f64, f64)> = yc.iter().map(|&z| matlab_log(z)).collect();
741 let mut ws = StageSolvers::new(2 * ne);
742 let p = fit_voxel(&te, &yc, &logyc, b0, 0.5, ¶ms, &mut ws);
743 let den = (2.0 / 3.0) * GAMMA * b0 * te.iter().sum::<f64>();
744 let dsc = recon_phase(&te, den, false, |t| dsc_model(t, &p, b0));
745 let psc = recon_phase(&te, den, false, |t| psc_model(t, &p, b0));
746 assert_eq!((pos[0], neg[0]), (dsc.abs(), -psc.abs()));
747 assert!(
748 pos[0] > neg[0].abs(),
749 "χ+ dominant: χ+={} χ−={}",
750 pos[0],
751 neg[0]
752 );
753 assert!(
754 (tot[0] - (pos[0] + neg[0])).abs() < 1e-12,
755 "χ_total = χ+ + χ−"
756 );
757 }
758
759 /// Each echo's phase comes from that echo's QSM: a per-echo χ that differs between echoes
760 /// gives a different result from one repeated χ.
761 #[test]
762 fn each_echo_uses_its_own_qsm() {
763 let te = [0.004, 0.012, 0.020, 0.028];
764 let params = DecomposeParams { b0: 3.0, n_inner: 4, ..Default::default() };
765 let mag = [1.0, 0.8, 0.6, 0.45];
766 let same = decompose(&[0.05; 4], &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap();
767 let varying = decompose(&[0.05, 0.06, 0.07, 0.08], &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap();
768 assert_ne!((same.0[0], same.1[0]), (varying.0[0], varying.1[0]));
769 }
770
771 /// Regression: χ+ must survive past the phase-wrap threshold.
772 ///
773 /// A compartment accumulates `(2/3)·χ·γ·B0·TE` radians, so with `atan2`'s
774 /// `(−π, π]` fold and no unwrapping the alternating fit collapses to a
775 /// degenerate fixed point above `|χ_total| = π / ((2/3)·γ·B0·TE_max)` and
776 /// returns χ+ = 0 exactly. At the phantom's 7 T and 28 ms last echo that
777 /// threshold is 0.090 ppm — inside the globus pallidus, which consequently
778 /// reconstructed as a hole. Sweep across it and require a response that is
779 /// non-zero and increasing, not a dropout, with the configuration the 7 T rules suggest
780 /// (`unwrap_phase` and the echo-spacing χ bound; unwrapping with the authors' 0.5 ppm bound
781 /// fails here, as on the phantom: module docs, *At 7 T*).
782 #[test]
783 fn chi_pos_survives_the_phase_wrap_threshold() {
784 let te = [0.004, 0.012, 0.020, 0.028]; // 7 T phantom sampling
785 let b0 = 7.0;
786 let params = DecomposeParams {
787 b0,
788 n_inner: 10,
789 chi_bound: DecomposeChiBound::EchoSpacing,
790 lsqcurvefit: LsqTrrOptions::default(),
791 unwrap_phase: true,
792 solvers: Default::default(),
793 };
794 let threshold = PI / ((2.0 / 3.0) * GAMMA * b0 * te[te.len() - 1]);
795 assert!(
796 (0.06..0.12).contains(&threshold),
797 "test assumes a threshold inside the swept range, got {threshold}"
798 );
799
800 // One voxel forward-simulated from the model, for a range of true χ+ that
801 // straddles the threshold.
802 let (cp, cm, c0, chim, r0) = (0.35, 0.25, 0.40, -0.03, 25.0);
803 let mut prev = 0.0_f64;
804 for k in 4..=13 {
805 let chip = 0.02 * k as f64;
806 let mag: Vec<f64> = te
807 .iter()
808 .map(|&t| {
809 let z = signal_model(t, &Fit { c: [cp, cm, c0], r0, chi: [chip, chim] }, b0);
810 (z.0 * z.0 + z.1 * z.1).sqrt()
811 })
812 .collect();
813 let (pos, _neg, _tot) =
814 decompose(&per_echo(&[chip + chim], te.len()), &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap();
815 assert!(
816 pos[0] > 0.0,
817 "χ+ collapsed to zero at χ+={chip} (χ_total={}, threshold {threshold:.4})",
818 chip + chim
819 );
820 assert!(
821 pos[0] > prev,
822 "χ+ must increase with the true source: {} -> {} at χ+={chip}",
823 prev,
824 pos[0]
825 );
826 prev = pos[0];
827 }
828 }
829
830 /// The 7 T configuration the pipeline rules suggest (`unwrap_phase` with the echo-spacing χ
831 /// bound) recovers χ+ across the deep-grey range.
832 ///
833 /// Single-compartment data built the way the QSM-CI phantom builds it (magnitude decaying at
834 /// R2* = R2 + Dr·|χ|, phase from χ_total), swept over χ+ 0.03–0.30 ppm; every voxel must
835 /// recover at least 0.3 of its χ+. With the authors' 0.5 ppm bound unwrapping alone fails
836 /// here, as on the phantom, where it leaves most of the pallidum collapsed and the
837 /// echo-spacing bound (0.3145 ppm here) recovers it (module docs, *At 7 T*).
838 #[test]
839 fn chi_pos_has_no_dropouts_across_deep_grey() {
840 let te = [0.004, 0.012, 0.020, 0.028];
841 let b0 = 7.0;
842 let params = DecomposeParams {
843 b0,
844 n_inner: 10,
845 chi_bound: DecomposeChiBound::EchoSpacing,
846 lsqcurvefit: LsqTrrOptions::default(),
847 unwrap_phase: true,
848 solvers: Default::default(),
849 };
850 let (dr_pos, dr_neg, r2) = (320.0_f64, 397.0_f64, 30.0_f64);
851 let chim = -0.015_f64;
852 for k in 3..=30 {
853 let chip = 0.01 * k as f64;
854 let r2s = r2 + dr_pos * chip + dr_neg * chim.abs();
855 let mag: Vec<f64> = te.iter().map(|&t| (-r2s * t).exp()).collect();
856 let (pos, _neg, _tot) =
857 decompose(&per_echo(&[chip + chim], te.len()), &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap();
858 assert!(
859 pos[0] > 0.3 * chip,
860 "dropout at χ+={chip:.2}: recovered {:.5} (gain {:.3})",
861 pos[0],
862 pos[0] / chip
863 );
864 }
865 }
866
867 /// Multi-voxel run exercising the masked-out, all-zero, and progress paths.
868 #[test]
869 fn decompose_multivoxel_mask_and_zero_paths() {
870 let te = [0.004, 0.012, 0.020, 0.028];
871 let b0 = 7.0;
872 let ne = te.len();
873 let params = DecomposeParams { b0, n_inner: 4, chi_bound: DecomposeChiBound::Ppm(0.5), lsqcurvefit: LsqTrrOptions::default(), unwrap_phase: false, solvers: Default::default() };
874
875 // A real signal for the fitted voxels.
876 let (cp, cm, c0, chip, chim, r0) = (0.4, 0.2, 0.4, 0.06, -0.05, 22.0);
877 let sig: Vec<(f64, f64)> = te
878 .iter()
879 .map(|&t| signal_model(t, &Fit { c: [cp, cm, c0], r0, chi: [chip, chim] }, b0))
880 .collect();
881 let smag: Vec<f64> = sig.iter().map(|z| (z.0 * z.0 + z.1 * z.1).sqrt()).collect();
882
883 // 4 voxels: [0]=masked-out, [1]=all-zero magnitude, [2],[3]=real signal.
884 let n = 4;
885 let mut mag = vec![0.0_f64; n * ne];
886 for e in 0..ne {
887 mag[2 * ne + e] = smag[e];
888 mag[3 * ne + e] = smag[e];
889 }
890 let chi_total = per_echo(&vec![chip + chim; n], ne);
891 let mask = vec![0u8, 1, 1, 1];
892
893 let mut last = (0usize, 0usize);
894 let (pos, neg, tot) =
895 decompose(&chi_total, &mag, &te, &mask, ¶ms, |i, t| last = (i, t)).unwrap();
896
897 // Masked-out and all-zero voxels stay at zero.
898 assert_eq!((pos[0], neg[0], tot[0]), (0.0, 0.0, 0.0), "masked voxel");
899 assert_eq!((pos[1], neg[1], tot[1]), (0.0, 0.0, 0.0), "all-zero voxel");
900 // Fitted voxels obey the sign convention + invariant.
901 for i in [2usize, 3] {
902 assert!(pos[i] >= 0.0 && neg[i] <= 0.0, "signs at voxel {i}");
903 assert!((tot[i] - (pos[i] + neg[i])).abs() < 1e-12, "invariant at voxel {i}");
904 }
905 // Progress reported completion over the 3 masked voxels.
906 assert_eq!(last, (3, 3), "final progress");
907 }
908
909 /// The echo-spacing χ bound: `π / ((2/3)·γ·B0·ΔTE_max)`, 0.3145 ppm at 7 T with 8 ms spacing
910 /// (the first echo, 4 ms, is a smaller step), set by the largest step (`TE₁` counts), and an
911 /// error where it is not a finite positive bound.
912 #[test]
913 fn echo_spacing_chi_bound() {
914 let b = |b0: f64, te: &[f64]| DecomposeChiBound::EchoSpacing.resolve(b0, te);
915 let at7 = b(7.0, &[0.004, 0.012, 0.020, 0.028]).unwrap();
916 assert!((at7 - 0.31447).abs() < 1e-4, "{at7}");
917 assert_eq!(at7, PI / ((2.0 / 3.0) * GAMMA * 7.0 * 0.008));
918 // An irregular spacing: the largest step sets it.
919 assert_eq!(b(7.0, &[0.004, 0.008, 0.020]).unwrap(), PI / ((2.0 / 3.0) * GAMMA * 7.0 * 0.012));
920 // A first echo later than the spacing: TE₁ sets it.
921 assert_eq!(b(3.0, &[0.010, 0.013, 0.016]).unwrap(), PI / ((2.0 / 3.0) * GAMMA * 3.0 * 0.010));
922 assert!(b(7.0, &[]).is_err() && b(7.0, &[0.0, 0.0, 0.0]).is_err() && b(0.0, &[0.004, 0.008]).is_err());
923 assert_eq!(DecomposeChiBound::Ppm(0.2).resolve(7.0, &[]).unwrap(), 0.2);
924 assert_eq!(DecomposeParams::default().chi_bound, DecomposeChiBound::Ppm(0.5));
925 assert_eq!((DecomposeChiBound::Ppm(0.5).id(), DecomposeChiBound::EchoSpacing.id()), ("ppm", "echo-spacing"));
926 }
927
928 /// #199: a negative `chi_bound` panicked inside the fit; it is an error now, as are the
929 /// shape problems that used to be asserts.
930 #[test]
931 fn negative_chi_bound_and_bad_shapes_are_errors() {
932 let te = [0.004, 0.008, 0.012];
933 let mag = [1.0, 0.9, 0.8];
934 for bad in [-0.1, f64::NAN] {
935 let params = DecomposeParams { chi_bound: DecomposeChiBound::Ppm(bad), ..Default::default() };
936 let err = decompose(&[0.0; 3], &mag, &te, &[1u8], ¶ms, |_, _| {}).unwrap_err();
937 assert!(err.0.contains("DECOMPOSE chi_bound") && err.0.contains(">= 0"), "{err}");
938 }
939 let ok = DecomposeParams::default();
940 assert!(decompose(&[0.0; 2], &mag[..2], &te[..2], &[1u8], &ok, |_, _| {}).is_err());
941 assert!(decompose(&[0.0; 3], &mag[..2], &te, &[1u8], &ok, |_, _| {}).is_err());
942 assert!(decompose(&[0.0; 3], &mag, &te, &[1u8, 1], &ok, |_, _| {}).is_err());
943 // One χ per voxel where one per echo is needed.
944 assert!(decompose(&[0.0], &mag, &te, &[1u8], &ok, |_, _| {}).is_err());
945 }
946
947 /// Every `lsqcurvefit` call of the authors' fit, replayed stage by stage against MATLAB
948 /// R2023b (`scripts/lsq_trr_matlab/probe_lsq_trr.m` on the conj(y) that
949 /// `decompose_qsm_parity::decompose_probe_input` writes): each stage starts from MATLAB's
950 /// own x0 and fixed parameters, so a difference is that call's alone.
951 /// `DECOMPOSE_TRACE=<trace> DECOMPOSE_PROBE_IN=<input> cargo test --release --lib
952 /// decompose_stages_match_matlab -- --ignored --nocapture`.
953 #[test]
954 #[ignore]
955 fn decompose_stages_match_matlab() {
956 use crate::solvers::lsq_trr_trace::{compare, dump, parse, replay};
957 let (Ok(tp), Ok(yp)) = (
958 std::env::var("DECOMPOSE_TRACE"),
959 std::env::var("DECOMPOSE_PROBE_IN"),
960 ) else {
961 return;
962 };
963 let raw: Vec<f64> = std::fs::read(yp)
964 .unwrap()
965 .chunks(8)
966 .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
967 .collect();
968 let (nv, ne, b0) = (raw[0] as usize, raw[1] as usize, raw[2]);
969 let te = raw[3..3 + ne].to_vec();
970 let yv = |v: usize| -> Vec<(f64, f64)> {
971 let o = 3 + ne + v * 2 * ne;
972 (0..ne).map(|e| (raw[o + e], raw[o + ne + e])).collect()
973 };
974 assert!(nv > 0);
975 let calls = parse(&std::fs::read_to_string(tp).unwrap());
976 let dump_tag = std::env::var("DECOMPOSE_DUMP").ok();
977 let (mut ok, mut total) = (0, 0);
978 let mut worst: Vec<(f64, String)> = Vec::new();
979 let mut by_stage = [[0usize; 2]; 3];
980 let mut bit_same = [0usize; 3];
981 let only = std::env::var("DECOMPOSE_ONLY").ok();
982 for call in calls.iter().filter(|c| c.stage.is_some() && only.as_deref().is_none_or(|o| o == c.tag)) {
983 let (v, _, st, fx) = call.stage.unwrap();
984 let yc = yv(v);
985 let logyc: Vec<(f64, f64)> = yc.iter().map(|&z| matlab_log(z)).collect();
986 let q = Fit {
987 c: [fx[3], fx[4], fx[5]],
988 r0: fx[2],
989 chi: [fx[0], fx[1]],
990 };
991 let log_resid = |q: &Fit, out: &mut [f64]| {
992 for e in 0..ne {
993 let (lr, la) = matlab_log(signal_model(te[e], q, b0));
994 out[e] = lr - logyc[e].0;
995 out[ne + e] = -la - -logyc[e].1;
996 }
997 };
998 let r = match st {
999 1 => replay(call, |x, out| {
1000 let q = Fit {
1001 c: [x[0], x[1], x[2]],
1002 ..q
1003 };
1004 for e in 0..ne {
1005 let z = signal_model(te[e], &q, b0);
1006 out[e] = z.0 - yc[e].0;
1007 out[ne + e] = -z.1 - -yc[e].1;
1008 }
1009 }),
1010 2 => replay(call, |x, out| log_resid(&Fit { r0: x[0], ..q }, out)),
1011 _ => replay(call, |x, out| {
1012 log_resid(
1013 &Fit {
1014 chi: [x[0], x[1]],
1015 ..q
1016 },
1017 out,
1018 )
1019 }),
1020 };
1021 let d = compare(call, &r, 1e-6);
1022 total += 1;
1023 let good = d.same_counts && d.same_exit && d.first_divergence.is_none();
1024 if d.first_bit.is_none() {
1025 bit_same[st - 1] += 1;
1026 }
1027 if std::env::var("DECOMPOSE_BITS").is_ok() {
1028 eprintln!("BITS {} {} {:?} {}", d.tag, st, d.first_bit, good);
1029 }
1030 by_stage[st - 1][usize::from(good)] += 1;
1031 if good {
1032 ok += 1;
1033 }
1034 worst.push((d.x_rel, format!("{d:?}")));
1035 if !good && std::env::var("DECOMPOSE_LIST").is_ok() {
1036 eprintln!("FAIL {} fd={:?} x_rel={:.1e} M={:?} R={:?}", d.tag, d.first_divergence, d.x_rel, d.matlab, d.rust);
1037 }
1038 if dump_tag.as_deref() == Some(call.tag.as_str()) {
1039 eprintln!("{}", dump(call, &r));
1040 }
1041 }
1042 worst.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
1043 for w in worst.iter().take(15) {
1044 eprintln!("{}", w.1);
1045 }
1046 eprintln!("{ok} of {total} stage calls identical (counts, exit flag, every evaluation to 1e-6); by stage [differ, same]: {by_stage:?}");
1047 eprintln!("bit-identical throughout (every evaluation point and sum of squares) by stage: {bit_same:?}");
1048 }
1049}