Skip to main content

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 = &params.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], &params, |_, _| {}).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, &params, &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], &params, |_, _| {}).unwrap();
767        let varying = decompose(&[0.05, 0.06, 0.07, 0.08], &mag, &te, &[1u8], &params, |_, _| {}).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], &params, |_, _| {}).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], &params, |_, _| {}).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, &params, |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], &params, |_, _| {}).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}