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, ¶ms, |_, _| {});
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], ¶ms, |_, _| {});
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], ¶ms, |_, _| {});
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, ¶ms, |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}