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//! Like the QSM-CI reference, the *phase* is synthesized from the provided
18//! conventional QSM (χ_total) rather than re-derived from raw multi-echo phase —
19//! this isolates DECOMPOSE's separation step. The observed complex data per echo
20//! is `y = |mag_norm| · exp(−i·(2/3)·χ_total·γ·B0·TE)` (magnitude normalised by its
21//! global maximum).
22//!
23//! The per-voxel fit is a 3-stage alternating bounded least-squares, 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.** Every phase the fit and the reconstruction see arrives
34//! through `atan2`, on `(−π, π]`. A compartment accumulates `(2/3)·χ·γ·B0·TE`
35//! radians, so above `|χ_total| = π / ((2/3)·γ·B0·TE_max)` the late echoes fold
36//! back onto that branch — 0.09 ppm at 7 T with a 28 ms last echo, which is
37//! ordinary globus pallidus. Left folded, the alternation converges to a
38//! degenerate fixed point (χ− pinned at `chi_bound`, R2*₀ and the amplitudes at
39//! zero) and returns χ+ = 0 exactly, so the most paramagnetic structure in the
40//! volume reads as a hole. Both the stage-2/3 residuals and `recon_phase`
41//! therefore walk the echoes in order and keep each phase on the branch nearest
42//! its predecessor ([`unwrap_near`]); the synthesized data phase is known in
43//! closed form and is used unwrapped rather than round-tripped through `atan2`.
44//! The original method, published at 3 T, does not meet this limit in practice —
45//! the threshold there is ~0.2 ppm.
46//!
47//! Unwrapping is only unambiguous while the phase step *between* echoes stays
48//! under π, so the χ search is additionally capped at
49//! `π / ((2/3)·γ·B0·ΔTE_max)` — 0.31 ppm at 7 T with 8 ms spacing, inside the
50//! 0.5 ppm default [`DecomposeParams::chi_bound`]. Past that the model matches a
51//! given χ on more than one branch, the stage-3 landscape grows spurious minima,
52//! and scattered voxels fall into them; capping the search is what removes the
53//! speckle of dropouts that unwrapping alone left in the recovered pallidum.
54//!
55//! Note that `−Σ angle / den` remains a *compressive* estimator of χ: its gain
56//! rises with χ itself (≈0.03 at white-matter χ+, ≈0.87 in the pallidum on the
57//! QSM-CI phantom), so it flattens contrast between weak sources. That is
58//! inherent to the reference's reconstruction, not to this port — the fitted
59//! compartment amplitudes set the gain, and they are only weakly identifiable
60//! from data that carries no sub-voxel compartment signature.
61//!
62//! **Output-mapping note.** The QSM-CI reference *comment* claims a para/dia
63//! output swap (χ+ = |DSC|); on the qsm-forward phantom that swap anti-correlates
64//! with the ground truth, while the physically-consistent mapping used here
65//! (χ+ = |PSC|, from the paramagnetic sub-model) correlates strongly. We therefore
66//! do not replicate the reference's swap.
67//!
68//! Reference:
69//! Chen, J., et al. (2021). "Decompose quantitative susceptibility mapping (QSM)
70//! to sub-voxel diamagnetic and paramagnetic components based on gradient-echo MRI
71//! data." NeuroImage 242:118735. https://doi.org/10.1016/j.neuroimage.2021.118735
72//! Reference implementation (QSM-CI port of Tim Ho's open MATLAB DECOMPOSE-QSM).
73
74#[cfg(feature = "parallel")]
75use rayon::prelude::*;
76
77use std::f64::consts::PI;
78
79/// Gyromagnetic constant used by the DECOMPOSE reference: `42.58 · 2π`.
80const GAMMA: f64 = 42.58 * 2.0 * PI;
81
82/// Parameters for [`decompose`].
83#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
84#[derive(Clone, Debug)]
85pub struct DecomposeParams {
86    /// Main field strength in Tesla.
87    pub b0: f64,
88    /// Number of alternating 3-stage fit passes per voxel (reference default: 10).
89    pub n_inner: usize,
90    /// Upper bound on |χ| in the fit, ppm (reference default: 0.5).
91    pub chi_bound: f64,
92    /// Max Levenberg–Marquardt iterations per fit stage (reference lsqcurvefit
93    /// defaults to a trust-region solve; 30 is ample for these 1–3 parameter fits).
94    pub max_lm_iter: usize,
95}
96
97impl Default for DecomposeParams {
98    fn default() -> Self {
99        Self {
100            b0: 7.0,
101            n_inner: 10,
102            chi_bound: 0.5,
103            max_lm_iter: 30,
104        }
105    }
106}
107
108/// DECOMPOSE source separation from a provided QSM and multi-echo magnitude.
109///
110/// # Arguments
111/// * `chi_total` — Conventional QSM χ_total in **ppm** (`n_voxels`), used to
112///   synthesize the per-echo phase.
113/// * `magnitude` — Multi-echo magnitude, flattened as `(n_voxels, n_echoes)` in
114///   row-major order (echo fastest per voxel) — the same layout as
115///   [`crate::separation::r2star_qsm_from_magnitude`]. Normalised internally by
116///   its global maximum.
117/// * `echo_times` — Echo times in **seconds** (`n_echoes`).
118/// * `mask` — Binary brain mask (`n_voxels`, 1 = inside).
119/// * `params` — See [`DecomposeParams`].
120/// * `progress` — Progress callback `(voxels_done, voxels_total)`, called
121///   periodically over the fitted (masked) voxels.
122///
123/// # Returns
124/// `(chi_pos, chi_neg, chi_total)` in ppm, restricted to `mask` — matching the
125/// [`chi_sep_ilsqr`](super::chi_sep_ilsqr)/[`chi_sep_medi`](super::chi_sep_medi)
126/// convention: `chi_pos` ≥ 0 (paramagnetic), `chi_neg` ≤ 0 (diamagnetic, signed),
127/// and `chi_total = chi_pos + chi_neg`.
128pub fn decompose(
129    chi_total: &[f64],
130    magnitude: &[f64],
131    echo_times: &[f64],
132    mask: &[u8],
133    params: &DecomposeParams,
134    mut progress: impl FnMut(usize, usize),
135) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
136    let n = chi_total.len();
137    let ne = echo_times.len();
138    assert_eq!(
139        magnitude.len(),
140        n * ne,
141        "magnitude must be n_voxels * n_echoes"
142    );
143    assert_eq!(mask.len(), n, "mask length must match chi_total");
144    assert!(ne >= 3, "DECOMPOSE needs at least 3 echoes");
145
146    let b0 = params.b0;
147    let te = echo_times;
148
149    // Global-max magnitude normalisation (matches the reference).
150    let gmax = magnitude.iter().cloned().fold(0.0_f64, f64::max).max(1e-30);
151
152    // Reconstruction denominator: (2/3)·γ·B0·ΣTE.
153    let den = (2.0 / 3.0) * GAMMA * b0 * te.iter().sum::<f64>();
154
155    // Masked voxel indices (only these are fit).
156    let voxels: Vec<usize> = (0..n).filter(|&i| mask[i] != 0).collect();
157    let total = voxels.len();
158
159    // Per-voxel fit → (index, chi_pos, chi_neg). Embarrassingly parallel.
160    //
161    // The fit allocates nothing: every buffer lives in a `Scratch` that rayon
162    // hands out once per worker thread and we reuse across voxels. This matters
163    // far beyond the usual allocator overhead — the released binaries are static
164    // musl builds, whose allocator serialises under concurrent small
165    // allocations, and the churn this loop used to produce (~10⁴ allocations per
166    // voxel) made the loop get *slower* as threads were added.
167    let results: Vec<(usize, f64, f64)> = maybe_par_map_init!(
168        voxels,
169        || Scratch::new(ne),
170        |ws: &mut Scratch, &i: &usize| {
171            // Build the complex data y[e] = mag_norm · exp(-i·(2/3)·χ_total·γ·B0·TE).
172            let chi = chi_total[i];
173            let mut all_zero = true;
174            for e in 0..ne {
175                let m = magnitude[i * ne + e] / gmax;
176                if m > 0.0 {
177                    all_zero = false;
178                }
179                let ph = -(2.0 / 3.0) * chi * GAMMA * b0 * te[e];
180                ws.y[e] = (m * ph.cos(), m * ph.sin());
181                // log(y) for stages 2 & 3. `ph` is the phase we just synthesized,
182                // so keep it as it is rather than recovering a ±π-wrapped copy of
183                // it through `atan2` — see the note above `unwrap_near`.
184                ws.logy[e] = (0.5 * (m * m).max(1e-300).ln(), ph);
185            }
186            if all_zero {
187                return (i, 0.0, 0.0);
188            }
189
190            let (cp, cm, c0, r0, chip, chim) =
191                fit_voxel(te, &ws.y, &ws.logy, b0, params, &mut ws.lm);
192
193            // Reconstruct χ+ (from dscModel) and χ− (from pscModel), both as the
194            // per-compartment phase accumulation −Σangle/den.
195            let dsc = recon_phase(te, b0, den, |t| {
196                dsc_model(t, cp, cm, c0, chip, chim, r0, b0)
197            });
198            let psc = recon_phase(te, b0, den, |t| {
199                psc_model(t, cp, cm, c0, chip, chim, r0, b0)
200            });
201            // pscModel recovers the paramagnetic term (∝ χ+), dscModel the
202            // diamagnetic term (∝ |χ−|). We use this physically-correct mapping
203            // (χ+ = |PSC|, χ− = −|DSC|), which correlates positively with the
204            // qsm-forward phantom's ground truth. Note: the QSM-CI reference
205            // *comment* claims the opposite ("χ+ = |DSC|"); that swap anti-
206            // correlates with this GT, so we do not replicate it. See module docs.
207            (i, psc.abs(), -dsc.abs())
208        }
209    )
210    .collect();
211
212    let mut chi_pos = vec![0.0_f64; n];
213    let mut chi_neg = vec![0.0_f64; n];
214    let mut chi_out = vec![0.0_f64; n];
215    for (i, pos, neg) in results {
216        chi_pos[i] = pos;
217        chi_neg[i] = neg;
218        chi_out[i] = pos + neg;
219    }
220    progress(total, total);
221    (chi_pos, chi_neg, chi_out)
222}
223
224/// Reusable scratch for one voxel's fit. Every buffer is fully overwritten
225/// before it is read, so a `Scratch` shared across voxels (or across a rayon
226/// worker's whole share of them) cannot carry state between them.
227struct Scratch {
228    /// Complex data per echo.
229    y: Vec<(f64, f64)>,
230    /// `log(y)` per echo.
231    logy: Vec<(f64, f64)>,
232    /// Levenberg–Marquardt buffers.
233    lm: LmScratch,
234}
235
236impl Scratch {
237    fn new(ne: usize) -> Self {
238        Self {
239            y: vec![(0.0, 0.0); ne],
240            logy: vec![(0.0, 0.0); ne],
241            lm: LmScratch::new(2 * ne),
242        }
243    }
244}
245
246/// The three-stage alternating fit for a single voxel. Returns
247/// `(C+, C−, C₀, R2*₀, χ+, χ−)`.
248#[allow(clippy::too_many_arguments)]
249fn fit_voxel(
250    te: &[f64],
251    y: &[(f64, f64)],
252    logy: &[(f64, f64)],
253    b0: f64,
254    params: &DecomposeParams,
255    lm: &mut LmScratch,
256) -> (f64, f64, f64, f64, f64, f64) {
257    let ne = te.len();
258    // Initial values (reference).
259    let mut c = [0.3_f64, 0.3, 0.4]; // [C+, C−, C₀]
260    let mut r0 = 25.0_f64;
261    let mut chi = [0.05_f64, -0.05]; // [χ+, χ−]
262
263    // Cap the χ search at the largest |χ| the echo spacing can represent without
264    // aliasing. Unwrapping puts each echo's phase on the branch nearest the
265    // previous one, which is only unambiguous while the step between echoes stays
266    // under π; past that the model can match a given χ on more than one branch,
267    // the stage-3 landscape grows spurious minima, and scattered voxels fall into
268    // them. At 7 T with 8 ms spacing the limit is 0.31 ppm, well inside the 0.5
269    // ppm default — which is what left the recovered pallidum speckled with
270    // dropouts even after the phases were unwrapped.
271    let dte = te.windows(2).map(|w| w[1] - w[0]).fold(te[0], f64::max);
272    let ub = params.chi_bound.min(PI / ((2.0 / 3.0) * GAMMA * b0 * dte));
273    let inf = f64::INFINITY;
274    let maxit = params.max_lm_iter;
275
276    for _ in 0..params.n_inner {
277        // Stage 1: amplitudes C against the linear signal y.
278        {
279            let resid = |x: &[f64], out: &mut [f64]| {
280                for e in 0..ne {
281                    let m = signal_model(te[e], x[0], x[1], x[2], chi[0], chi[1], r0, b0);
282                    out[e] = m.0 - y[e].0;
283                    out[ne + e] = -(m.1 - y[e].1);
284                }
285            };
286            let x = lm_bounded(resid, &c, &[0.0, 0.0, 0.0], &[inf, inf, inf], maxit, lm);
287            c = [x[0], x[1], x[2]];
288        }
289        // Stage 2: R2*₀ against log(y).
290        {
291            let resid = |x: &[f64], out: &mut [f64]| {
292                let mut prev = 0.0;
293                for e in 0..ne {
294                    let m = clog(signal_model(
295                        te[e], c[0], c[1], c[2], chi[0], chi[1], x[0], b0,
296                    ));
297                    let ang = if e == 0 { m.1 } else { unwrap_near(m.1, prev) };
298                    prev = ang;
299                    out[e] = m.0 - logy[e].0;
300                    out[ne + e] = -(ang - logy[e].1);
301                }
302            };
303            let x = lm_bounded(resid, &[r0], &[0.0], &[inf], maxit, lm);
304            r0 = x[0];
305        }
306        // Stage 3: [χ+, χ−] against log(y).
307        {
308            let resid = |x: &[f64], out: &mut [f64]| {
309                let mut prev = 0.0;
310                for e in 0..ne {
311                    let m = clog(signal_model(te[e], c[0], c[1], c[2], x[0], x[1], r0, b0));
312                    let ang = if e == 0 { m.1 } else { unwrap_near(m.1, prev) };
313                    prev = ang;
314                    out[e] = m.0 - logy[e].0;
315                    out[ne + e] = -(ang - logy[e].1);
316                }
317            };
318            let x = lm_bounded(resid, &chi, &[0.0, -ub], &[ub, 0.0], maxit, lm);
319            chi = [x[0], x[1]];
320        }
321    }
322    (c[0], c[1], c[2], r0, chi[0], chi[1])
323}
324
325/// Complex DECOMPOSE signal model at echo time `t` (seconds). Returns `(re, im)`.
326#[allow(clippy::too_many_arguments)]
327#[inline]
328fn signal_model(
329    t: f64,
330    cp: f64,
331    cm: f64,
332    c0: f64,
333    chip: f64,
334    chim: f64,
335    r0: f64,
336    b0: f64,
337) -> (f64, f64) {
338    let a = (2.0 * PI * GAMMA * b0) / (9.0 * 3.0_f64.sqrt());
339    // term1: paramagnetic
340    let d1 = a * chip + r0;
341    let w1 = (2.0 / 3.0) * chip * GAMMA * b0;
342    let t1 = cexp_decay(cp, d1, w1, t);
343    // term2: diamagnetic (χ− ≤ 0, so −a·χ− ≥ 0 adds decay)
344    let d2 = -a * chim + r0;
345    let w2 = (2.0 / 3.0) * chim * GAMMA * b0;
346    let t2 = cexp_decay(cm, d2, w2, t);
347    // term3: neutral
348    let t3 = (c0 * (-r0 * t).exp(), 0.0);
349    (t1.0 + t2.0 + t3.0, t1.1 + t2.1 + t3.1)
350}
351
352/// Paramagnetic-only reconstruction sub-model (`pscModel.m`).
353#[allow(clippy::too_many_arguments)]
354#[inline]
355fn psc_model(
356    t: f64,
357    cp: f64,
358    cm: f64,
359    c0: f64,
360    chip: f64,
361    _chim: f64,
362    r0: f64,
363    b0: f64,
364) -> (f64, f64) {
365    let a = (2.0 * PI * GAMMA * b0) / (9.0 * 3.0_f64.sqrt());
366    let d1 = a * chip + r0;
367    let w1 = (2.0 / 3.0) * chip * GAMMA * b0;
368    let t1 = cexp_decay(cp, d1, w1, t);
369    let t3 = ((c0 + cm) * (-r0 * t).exp(), 0.0);
370    (t1.0 + t3.0, t1.1 + t3.1)
371}
372
373/// Diamagnetic-only reconstruction sub-model (`dscModel.m`). Note the flipped
374/// imaginary sign `(2/3)·(−χ−)·γ·B0` relative to [`signal_model`].
375#[allow(clippy::too_many_arguments)]
376#[inline]
377fn dsc_model(
378    t: f64,
379    cp: f64,
380    cm: f64,
381    c0: f64,
382    _chip: f64,
383    chim: f64,
384    r0: f64,
385    b0: f64,
386) -> (f64, f64) {
387    let a = (2.0 * PI * GAMMA * b0) / (9.0 * 3.0_f64.sqrt());
388    let d2 = -a * chim + r0;
389    let w2 = (2.0 / 3.0) * (-chim) * GAMMA * b0; // flipped sign
390    let t2 = cexp_decay(cm, d2, w2, t);
391    let t3 = ((c0 + cp) * (-r0 * t).exp(), 0.0);
392    (t2.0 + t3.0, t2.1 + t3.1)
393}
394
395/// `amp · exp(−(decay + i·freq)·t)` as `(re, im)`.
396#[inline]
397fn cexp_decay(amp: f64, decay: f64, freq: f64, t: f64) -> (f64, f64) {
398    let mag = amp * (-decay * t).exp();
399    let ang = -freq * t;
400    (mag * ang.cos(), mag * ang.sin())
401}
402
403/// Complex natural log: `log(z) = log|z| + i·angle(z)`.
404#[inline]
405fn clog(z: (f64, f64)) -> (f64, f64) {
406    let mag2 = z.0 * z.0 + z.1 * z.1;
407    (0.5 * mag2.max(1e-300).ln(), z.1.atan2(z.0))
408}
409
410/// `2π`, the period of the phase branch cut.
411const TAU: f64 = 2.0 * PI;
412
413/// Put `ang` on the 2π-branch nearest `prev`.
414///
415/// Every phase here reaches us through `atan2`, which folds onto `(−π, π]`. That
416/// fold is not harmless: a compartment accumulates `(2/3)·χ·γ·B0·TE` radians, so
417/// at `|χ_total| > π / ((2/3)·γ·B0·TE_max)` — 0.09 ppm at 7 T with a 28 ms last
418/// echo, i.e. inside the globus pallidus — the late echoes fold back and the
419/// alternating fit chases the fold into a degenerate fixed point (χ− pinned at
420/// its bound, R2*₀ and the amplitudes driven to zero, χ+ stuck at 0). Walking the
421/// echoes in order and keeping each phase on the branch nearest its predecessor
422/// restores the monotone ramp the model assumes.
423///
424/// The first echo is left on its principal branch: it anchors the series, and at
425/// any sane `TE₁` its phase is well inside `(−π, π]` for `|χ| ≤ chi_bound`.
426#[inline]
427fn unwrap_near(ang: f64, prev: f64) -> f64 {
428    ang + ((prev - ang) / TAU).round() * TAU
429}
430
431/// Reconstruct a source value: `−Σ angle(model(TE)) / den`, with the phase
432/// unwrapped along the echo axis so a strong source is not folded back to zero
433/// (see [`unwrap_near`]).
434fn recon_phase(te: &[f64], _b0: f64, den: f64, model: impl Fn(f64) -> (f64, f64)) -> f64 {
435    let mut s = 0.0;
436    let mut prev = 0.0;
437    for (e, &t) in te.iter().enumerate() {
438        let z = model(t);
439        let raw = z.1.atan2(z.0);
440        let ang = if e == 0 { raw } else { unwrap_near(raw, prev) };
441        prev = ang;
442        s += ang;
443    }
444    -s / den
445}
446
447// ---------------------------------------------------------------------------
448// Bounded Levenberg–Marquardt for small (n ≤ 3) least-squares problems.
449// ---------------------------------------------------------------------------
450
451/// Largest parameter count any DECOMPOSE stage fits (stage 1: `[C+, C−, C₀]`).
452/// Keeps the LM state on the stack.
453const LM_MAX_N: usize = 3;
454
455/// Scratch buffers for [`lm_bounded`], sized once for a residual of length `m`
456/// and reused across every stage, LM iteration and voxel. Contents are always
457/// written before they are read.
458struct LmScratch {
459    /// Residual at the current `x`.
460    r: Vec<f64>,
461    /// Residual at the finite-difference probe point.
462    rp: Vec<f64>,
463    /// Residual at the trial point.
464    rn: Vec<f64>,
465    /// Jacobian, `m × LM_MAX_N`, column-major.
466    jac: Vec<f64>,
467}
468
469impl LmScratch {
470    fn new(m: usize) -> Self {
471        Self {
472            r: vec![0.0; m],
473            rp: vec![0.0; m],
474            rn: vec![0.0; m],
475            jac: vec![0.0; m * LM_MAX_N],
476        }
477    }
478}
479
480/// Minimise ‖resid(x)‖² over the box `[lb, ub]` by Levenberg–Marquardt with a
481/// forward finite-difference Jacobian and per-step projection onto the box.
482/// Sized for the 1–3 parameter DECOMPOSE stages.
483///
484/// `resid` writes the residual for a parameter vector into the caller's buffer
485/// rather than returning a fresh one, and all other working storage is either a
486/// stack array (`n ≤ LM_MAX_N`) or a buffer in `s`, so a solve allocates
487/// nothing. Only the first `x0.len()` entries of the returned array are
488/// meaningful.
489fn lm_bounded<F>(
490    resid: F,
491    x0: &[f64],
492    lb: &[f64],
493    ub: &[f64],
494    max_iter: usize,
495    s: &mut LmScratch,
496) -> [f64; LM_MAX_N]
497where
498    F: Fn(&[f64], &mut [f64]),
499{
500    let n = x0.len();
501    debug_assert!(n <= LM_MAX_N);
502    let m = s.r.len();
503    let mut x = [0.0_f64; LM_MAX_N];
504    for d in 0..n {
505        x[d] = x0[d].clamp(lb[d], ub[d]);
506    }
507    resid(&x[..n], &mut s.r);
508    let mut cost = dot(&s.r, &s.r);
509    let mut lambda = 1e-3;
510    let ftol = 1e-10;
511
512    for _ in 0..max_iter {
513        // Forward-difference Jacobian J (m×n), column-major in `s.jac`.
514        for j in 0..n {
515            let h = 1e-6 * x[j].abs().max(1e-3);
516            let mut xp = x;
517            xp[j] = (x[j] + h).clamp(lb[j], ub[j]);
518            let hj = xp[j] - x[j];
519            let step = if hj.abs() < 1e-30 { h } else { hj };
520            if hj.abs() < 1e-30 {
521                xp[j] = x[j] + h; // allow out-of-box probe when pinned at a bound
522            }
523            resid(&xp[..n], &mut s.rp);
524            for k in 0..m {
525                s.jac[j * m + k] = (s.rp[k] - s.r[k]) / step;
526            }
527        }
528        // Normal equations A = JᵀJ (n×n), g = Jᵀr (n).
529        let mut a = [0.0_f64; LM_MAX_N * LM_MAX_N];
530        let mut g = [0.0_f64; LM_MAX_N];
531        for jc in 0..n {
532            for jr in 0..n {
533                let mut acc = 0.0;
534                for k in 0..m {
535                    acc += s.jac[jr * m + k] * s.jac[jc * m + k];
536                }
537                a[jr * n + jc] = acc;
538            }
539            let mut acc = 0.0;
540            for k in 0..m {
541                acc += s.jac[jc * m + k] * s.r[k];
542            }
543            g[jc] = acc;
544        }
545
546        // Inner loop: inflate lambda until a step decreases the cost.
547        let mut improved = false;
548        for _ in 0..30 {
549            let mut al = a;
550            for d in 0..n {
551                al[d * n + d] += lambda * a[d * n + d].max(1e-12);
552            }
553            let Some(dx) = solve_small(&al, &g, n) else {
554                lambda *= 2.5;
555                continue;
556            };
557            let mut xn = [0.0_f64; LM_MAX_N];
558            for d in 0..n {
559                xn[d] = (x[d] - dx[d]).clamp(lb[d], ub[d]);
560            }
561            resid(&xn[..n], &mut s.rn);
562            let cn = dot(&s.rn, &s.rn);
563            if cn < cost {
564                let rel = (cost - cn) / cost.max(1e-300);
565                x = xn;
566                s.r.copy_from_slice(&s.rn);
567                cost = cn;
568                lambda = (lambda * 0.4).max(1e-12);
569                improved = true;
570                if rel < ftol {
571                    return x;
572                }
573                break;
574            } else {
575                lambda *= 2.5;
576                if lambda > 1e12 {
577                    break;
578                }
579            }
580        }
581        if !improved {
582            break;
583        }
584    }
585    x
586}
587
588#[inline]
589fn dot(a: &[f64], b: &[f64]) -> f64 {
590    a.iter().zip(b).map(|(&x, &y)| x * y).sum()
591}
592
593/// Solve `A x = b` for small `n` (≤ `LM_MAX_N`) by Gaussian elimination with
594/// partial pivoting. `a` is row-major `n×n`. Returns `None` if singular. Only
595/// the first `n` entries of the inputs and the result are used.
596fn solve_small(
597    a: &[f64; LM_MAX_N * LM_MAX_N],
598    b: &[f64; LM_MAX_N],
599    n: usize,
600) -> Option<[f64; LM_MAX_N]> {
601    let mut m = *a;
602    let mut y = *b;
603    for col in 0..n {
604        // Partial pivot.
605        let mut piv = col;
606        let mut best = m[col * n + col].abs();
607        for r in (col + 1)..n {
608            let v = m[r * n + col].abs();
609            if v > best {
610                best = v;
611                piv = r;
612            }
613        }
614        if best < 1e-300 {
615            return None;
616        }
617        if piv != col {
618            for c in 0..n {
619                m.swap(col * n + c, piv * n + c);
620            }
621            y.swap(col, piv);
622        }
623        let d = m[col * n + col];
624        for r in (col + 1)..n {
625            let f = m[r * n + col] / d;
626            if f != 0.0 {
627                for c in col..n {
628                    m[r * n + c] -= f * m[col * n + c];
629                }
630                y[r] -= f * y[col];
631            }
632        }
633    }
634    let mut x = [0.0_f64; LM_MAX_N];
635    for col in (0..n).rev() {
636        let mut acc = y[col];
637        for c in (col + 1)..n {
638            acc -= m[col * n + c] * x[c];
639        }
640        x[col] = acc / m[col * n + col];
641    }
642    Some(x)
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn lm_recovers_linear_fit() {
651        // Fit y = m·t + c via LM on residual [m·t+c − data].
652        let t = [0.0, 1.0, 2.0, 3.0];
653        let data = [1.0, 3.0, 5.0, 7.0]; // m=2, c=1
654        let resid = |x: &[f64], out: &mut [f64]| {
655            for (k, (&ti, d)) in t.iter().zip(data).enumerate() {
656                out[k] = x[0] * ti + x[1] - d;
657            }
658        };
659        let mut lm = LmScratch::new(t.len());
660        let x = lm_bounded(
661            resid,
662            &[0.0, 0.0],
663            &[-10.0, -10.0],
664            &[10.0, 10.0],
665            50,
666            &mut lm,
667        );
668        assert!((x[0] - 2.0).abs() < 1e-6, "slope {}", x[0]);
669        assert!((x[1] - 1.0).abs() < 1e-6, "intercept {}", x[1]);
670    }
671
672    #[test]
673    fn solve_small_3x3() {
674        // A x = b with a known solution.
675        let a: [f64; 9] = [2.0, 1.0, 1.0, 1.0, 3.0, 2.0, 1.0, 0.0, 0.0];
676        let x_true = [1.0, 2.0, 3.0];
677        let b: [f64; 3] = [
678            a[0] * x_true[0] + a[1] * x_true[1] + a[2] * x_true[2],
679            a[3] * x_true[0] + a[4] * x_true[1] + a[5] * x_true[2],
680            a[6] * x_true[0] + a[7] * x_true[1] + a[8] * x_true[2],
681        ];
682        let x = solve_small(&a, &b, 3).unwrap();
683        for i in 0..3 {
684            assert!((x[i] - x_true[i]).abs() < 1e-9, "x{i} = {}", x[i]);
685        }
686    }
687
688    /// Sign/plumbing check on a synthetic voxel. Verifies the output sign
689    /// convention and the physically-correct mapping: χ+ = |PSC| (paramagnetic
690    /// sub-model), χ− = −|DSC| (diamagnetic sub-model). A voxel with a large
691    /// paramagnetic fit amplitude/χ therefore yields a dominant χ+.
692    #[test]
693    fn decompose_signs_on_synthetic_voxel() {
694        let te = [0.004, 0.012, 0.020, 0.028];
695        let b0 = 7.0;
696        let params = DecomposeParams {
697            b0,
698            n_inner: 12,
699            chi_bound: 0.5,
700            max_lm_iter: 40,
701        };
702
703        // Forward: paramagnetic-dominant fit parameters.
704        let (cp, cm, c0, chip, chim, r0) = (0.5, 0.1, 0.4, 0.10, -0.02, 20.0);
705        let sig: Vec<(f64, f64)> = te
706            .iter()
707            .map(|&t| signal_model(t, cp, cm, c0, chip, chim, r0, b0))
708            .collect();
709        let chi_total_val = chip + chim;
710        let n = 1;
711        let ne = te.len();
712        let mut mag = vec![0.0_f64; n * ne];
713        for e in 0..ne {
714            mag[e] = (sig[e].0 * sig[e].0 + sig[e].1 * sig[e].1).sqrt();
715        }
716        let chi_total = vec![chi_total_val; n];
717        let mask = vec![1u8; n];
718        let (pos, neg, tot) = decompose(&chi_total, &mag, &te, &mask, &params, |_, _| {});
719        // Sign convention + invariant.
720        assert!(pos[0] >= 0.0, "χ+ must be ≥ 0");
721        assert!(neg[0] <= 0.0, "χ− must be ≤ 0");
722        assert!(
723            (tot[0] - (pos[0] + neg[0])).abs() < 1e-12,
724            "χ_total = χ+ + χ−"
725        );
726        // Physically-correct mapping: paramagnetic fit → χ+ (|PSC|) dominates.
727        assert!(
728            pos[0] > neg[0].abs(),
729            "expected χ+ dominant: χ+={} χ−={}",
730            pos[0],
731            neg[0]
732        );
733    }
734
735    /// Regression: χ+ must survive past the phase-wrap threshold.
736    ///
737    /// A compartment accumulates `(2/3)·χ·γ·B0·TE` radians, so with `atan2`'s
738    /// `(−π, π]` fold and no unwrapping the alternating fit collapses to a
739    /// degenerate fixed point above `|χ_total| = π / ((2/3)·γ·B0·TE_max)` and
740    /// returns χ+ = 0 exactly. At the phantom's 7 T and 28 ms last echo that
741    /// threshold is 0.090 ppm — inside the globus pallidus, which consequently
742    /// reconstructed as a hole. Sweep across it and require a response that is
743    /// non-zero and increasing, not a dropout.
744    #[test]
745    fn chi_pos_survives_the_phase_wrap_threshold() {
746        let te = [0.004, 0.012, 0.020, 0.028]; // 7 T phantom sampling
747        let b0 = 7.0;
748        let params = DecomposeParams {
749            b0,
750            n_inner: 10,
751            chi_bound: 0.5,
752            max_lm_iter: 30,
753        };
754        let threshold = PI / ((2.0 / 3.0) * GAMMA * b0 * te[te.len() - 1]);
755        assert!(
756            (0.06..0.12).contains(&threshold),
757            "test assumes a threshold inside the swept range, got {threshold}"
758        );
759
760        // One voxel forward-simulated from the model, for a range of true χ+ that
761        // straddles the threshold.
762        let (cp, cm, c0, chim, r0) = (0.35, 0.25, 0.40, -0.03, 25.0);
763        let mut prev = 0.0_f64;
764        for k in 4..=13 {
765            let chip = 0.02 * k as f64;
766            let mag: Vec<f64> = te
767                .iter()
768                .map(|&t| {
769                    let z = signal_model(t, cp, cm, c0, chip, chim, r0, b0);
770                    (z.0 * z.0 + z.1 * z.1).sqrt()
771                })
772                .collect();
773            let (pos, _neg, _tot) =
774                decompose(&[chip + chim], &mag, &te, &[1u8], &params, |_, _| {});
775            assert!(
776                pos[0] > 0.0,
777                "χ+ collapsed to zero at χ+={chip} (χ_total={}, threshold {threshold:.4})",
778                chip + chim
779            );
780            assert!(
781                pos[0] > prev,
782                "χ+ must increase with the true source: {} -> {} at χ+={chip}",
783                prev,
784                pos[0]
785            );
786            prev = pos[0];
787        }
788    }
789
790    /// Regression: no scattered dropouts across the deep-grey χ+ range.
791    ///
792    /// Unwrapping alone was not enough. The χ search ran to `chi_bound` (0.5 ppm
793    /// by default), past the 0.31 ppm that 8 ms echo spacing can represent
794    /// without the per-echo phase step exceeding π. In that aliased region the
795    /// stage-3 landscape grows spurious minima, and voxels at particular
796    /// (χ, R2*) combinations fell into them — leaving a recovered pallidum that
797    /// was bright but speckled with black. Feed single-compartment data built the
798    /// way the phantom builds it (magnitude decaying at R2* = R2 + Dr·|χ|, phase
799    /// from χ_total) and require every voxel across the band to recover.
800    #[test]
801    fn chi_pos_has_no_dropouts_across_deep_grey() {
802        let te = [0.004, 0.012, 0.020, 0.028];
803        let b0 = 7.0;
804        let params = DecomposeParams {
805            b0,
806            n_inner: 10,
807            chi_bound: 0.5,
808            max_lm_iter: 30,
809        };
810        let (dr_pos, dr_neg, r2) = (320.0_f64, 397.0_f64, 30.0_f64);
811        let chim = -0.015_f64;
812        for k in 3..=30 {
813            let chip = 0.01 * k as f64;
814            let r2s = r2 + dr_pos * chip + dr_neg * chim.abs();
815            let mag: Vec<f64> = te.iter().map(|&t| (-r2s * t).exp()).collect();
816            let (pos, _neg, _tot) =
817                decompose(&[chip + chim], &mag, &te, &[1u8], &params, |_, _| {});
818            assert!(
819                pos[0] > 0.3 * chip,
820                "dropout at χ+={chip:.2}: recovered {:.5} (gain {:.3})",
821                pos[0],
822                pos[0] / chip
823            );
824        }
825    }
826
827    /// Multi-voxel run exercising the masked-out, all-zero, and progress paths.
828    #[test]
829    fn decompose_multivoxel_mask_and_zero_paths() {
830        let te = [0.004, 0.012, 0.020, 0.028];
831        let b0 = 7.0;
832        let ne = te.len();
833        let params = DecomposeParams { b0, n_inner: 4, chi_bound: 0.5, max_lm_iter: 20 };
834
835        // A real signal for the fitted voxels.
836        let (cp, cm, c0, chip, chim, r0) = (0.4, 0.2, 0.4, 0.06, -0.05, 22.0);
837        let sig: Vec<(f64, f64)> = te
838            .iter()
839            .map(|&t| signal_model(t, cp, cm, c0, chip, chim, r0, b0))
840            .collect();
841        let smag: Vec<f64> = sig.iter().map(|z| (z.0 * z.0 + z.1 * z.1).sqrt()).collect();
842
843        // 4 voxels: [0]=masked-out, [1]=all-zero magnitude, [2],[3]=real signal.
844        let n = 4;
845        let mut mag = vec![0.0_f64; n * ne];
846        for e in 0..ne {
847            mag[2 * ne + e] = smag[e];
848            mag[3 * ne + e] = smag[e];
849        }
850        let chi_total = vec![chip + chim; n];
851        let mask = vec![0u8, 1, 1, 1];
852
853        let mut last = (0usize, 0usize);
854        let (pos, neg, tot) =
855            decompose(&chi_total, &mag, &te, &mask, &params, |i, t| last = (i, t));
856
857        // Masked-out and all-zero voxels stay at zero.
858        assert_eq!((pos[0], neg[0], tot[0]), (0.0, 0.0, 0.0), "masked voxel");
859        assert_eq!((pos[1], neg[1], tot[1]), (0.0, 0.0, 0.0), "all-zero voxel");
860        // Fitted voxels obey the sign convention + invariant.
861        for i in [2usize, 3] {
862            assert!(pos[i] >= 0.0 && neg[i] <= 0.0, "signs at voxel {i}");
863            assert!((tot[i] - (pos[i] + neg[i])).abs() < 1e-12, "invariant at voxel {i}");
864        }
865        // Progress reported completion over the 3 masked voxels.
866        assert_eq!(last, (3, 3), "final progress");
867    }
868}