qsm_core/utils/multi_echo.rs
1//! Multi-echo field mapping utilities
2//!
3//! Provides phase offset removal, weighted B0 estimation, bipolar correction,
4//! and multi-echo linear fit for multi-echo GRE data.
5//!
6//! Phase offset removal is based on the HIP (Hermitian Inner Product) technique
7//! from ASPIRE/MCPC-3D-S:
8//! Eckstein, K., et al. (2018). "Computationally Efficient Combination of
9//! Multi-channel Phase Data From Multi-echo Acquisitions (ASPIRE)."
10//! Magnetic Resonance in Medicine, 79:2996-3006. https://doi.org/10.1002/mrm.26963
11//!
12//! # Reference pipeline
13//!
14//! The reference implementations are the authors' MriResearchTools.jl (korbinian90, commit
15//! 771eadf) and the `romeo` CLI in ROMEO.jl (08776d4); file:line refer to those trees.
16//!
17//! - **MCPC-3D-S** ([`phase_offset_removal`]): `mcpc3ds` uses echoes 1 and 2 for the HIP
18//! (`echoes=[1,2]`), unwraps the HIP with ROMEO weighted by `sqrt(|HIP|)` inside its own
19//! `robustmask` of that weight, scales by `TE₁/ΔTE`, smooths the offset with
20//! `gaussiansmooth3d_phase` at `sigma=[10,10,5]` *voxels*, and removes it from every echo
21//! (`src/mcpc3ds.jl:16-17,39-52`). [`phase_offset_removal`] makes the same `robustmask` and,
22//! as `mcpc3ds`, leaves the offset and the corrected phase NaN where the masked smoothing
23//! cannot reach; it returns those voxels as invalid ([`PhaseOffsetRemoval::valid`]; until
24//! 2026-11 they kept their raw phase, which caused ring artefacts on GE and Philips data, see
25//! [`phase_offset_removal`]). The pipeline's default sigma (`field_mapping.phase_offset_sigma_mm`)
26//! is the ROMEO CLI's 7 mm per axis divided by the voxel size (not `mcpc3ds`'s `[10, 10, 5]`
27//! voxels); the CLI says to reduce it "to a few voxels" on a small field of view and 0 along a
28//! single-slice axis (ROMEO.jl `ext/RomeoApp/argparse.jl:79-91`, `caller.jl:137-141`).
29//! [`PhaseOffsetParams::default`]'s `[4, 4, 4]` matches neither and is not used by the
30//! pipeline. TE₁ must differ from TE₂ (`caller.jl:118-120`).
31//! - **Bipolar correction** ([`bipolar_correction`]): MriResearchTools' `bipolar_correction!`,
32//! after Eckstein's thesis §3.1.3 (`src/mcpc3ds.jl:97-129`): needs ≥ 3 echoes, uses echoes
33//! 1–3, smooths the artefact with the MCPC-3D-S sigma and unwraps it with ROMEO
34//! (`correctglobal=true`). Off by default in `mcpc3ds` and the CLI. Agrees with
35//! `bipolar_correction!` to 8.9e-16 rad on the 4-echo phantom of `tests/mritools_parity.rs`
36//! (it was up to 0.48 rad off before its smoothing was MriResearchTools'; astewartau/QSM.rs#319,
37//! see [`bipolar_correction`]).
38//! - **Weighted average** ([`calculate_b0_weighted`]): `calculateB0_unwrapped` with
39//! `:phase_snr` (`mag·TE`) by default (`src/romeofunctions.jl:13-24`; CLI
40//! `--B0-phase-weighting`, `argparse.jl:67-70`); "the phase offsets have to be removed prior"
41//! (`romeofunctions.jl:3-7`). MriResearchTools' `:simulated_mag` (`exp(−TE/20)·TE`,
42//! `romeofunctions.jl:33-35`) is not ported; [`B0WeightType::AssumedDecay`] is UK Biobank's.
43//! Every ported weighting matches `calculateB0_unwrapped` to 3e-16 relative
44//! (`tests/mritools_parity.rs`, measured 2026-10-30).
45//! - **Linear fit** ([`multi_echo_linear_fit`]): after QSM.jl's `multi_echo_linear_fit`
46//! (kamesy/QSM.jl fde4b92, `src/utils/multi_echo.jl:209-260`; phase offset modelled by
47//! default, `W` the square root of the weights, e.g. `mag/σ`) and QSMART's `echofit.m`. Each
48//! echo is weighted by its magnitude, which is QSM.jl's fit with `W = sqrt(mag)`; with that,
49//! `tests/linearfit_qsmjl_parity.rs` matches QSM.jl (in the verified QSM-CI `julia-qsm` image,
50//! QSMxT/QSM-CI#299) to 5e-16 relative in the slope and 4e-15 rad in the offset, with and
51//! without the offset (measured 2026-10-30).
52//! - **Robust fit** ([`multi_echo_robust_fit`]) is QSM.rs's own (IRLS with Tukey/Huber loss);
53//! it has no external reference, and its defaults rest on the measurements recorded on
54//! [`RobustLoss`] and [`RobustFitParams`].
55//!
56//! None of these assumes a field strength, resolution or B0 direction; the smoothing sigmas are
57//! in voxels.
58
59/// Parameters for phase offset removal.
60#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
61#[derive(Clone, Debug)]
62pub struct PhaseOffsetParams {
63 /// Gaussian smoothing sigma in voxels [x, y, z] for phase offset estimation
64 pub sigma: [f64; 3],
65}
66
67impl Default for PhaseOffsetParams {
68 fn default() -> Self {
69 Self {
70 sigma: [4.0, 4.0, 4.0],
71 }
72 }
73}
74
75/// Parameters for multi-echo linear fit.
76#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
77#[derive(Clone, Debug, PartialEq)]
78pub struct LinearFitParams {
79 /// Estimate and remove constant phase offset
80 pub estimate_offset: bool,
81}
82
83impl Default for LinearFitParams {
84 fn default() -> Self {
85 Self {
86 estimate_offset: true,
87 }
88 }
89}
90
91use std::f64::consts::PI;
92use crate::Grid;
93use crate::error::{check_len, InputError};
94use crate::unwrap::romeo::{unwrap_romeo, RomeoParams};
95use crate::unwrap::laplacian::{laplacian_unwrap, LaplacianSolver};
96use crate::unwrap::bestpath::{unwrap_bestpath, BestPathParams};
97use crate::unwrap::prelude::unwrap_prelude;
98use crate::unwrap::UnwrapMethod;
99
100const TWO_PI: f64 = 2.0 * PI;
101
102/// Wrap angle to [-π, π]
103#[inline]
104fn wrap_to_pi(angle: f64) -> f64 {
105 let mut a = angle % TWO_PI;
106 if a > PI {
107 a -= TWO_PI;
108 } else if a < -PI {
109 a += TWO_PI;
110 }
111 a
112}
113
114/// Index into 3D array (Fortran/column-major order)
115#[cfg(test)]
116#[inline(always)]
117fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
118 i + j * nx + k * nx * ny
119}
120
121/// B0 weighting types. All but [`B0WeightType::AssumedDecay`] match MriResearchTools.jl.
122#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub enum B0WeightType {
125 /// mag * TE - optimal for phase SNR (default)
126 PhaseSNR,
127 /// mag² * TE² - based on phase variance
128 PhaseVar,
129 /// Uniform weights
130 Average,
131 /// TE only
132 TEs,
133 /// Magnitude only
134 Mag,
135 /// Fixed per-echo weights computed from the echo times and an *assumed* T2*:
136 /// UK Biobank's echo-combination weighting.
137 ///
138 /// The UK Biobank QSM pipeline (Wang et al. 2022, "Phenotypic and genetic associations of
139 /// quantitative magnetic susceptibility in UK Biobank brain imaging", *Nat. Neurosci.*
140 /// 25:818-831, doi:10.1038/s41593-022-01074-w) averages the unwrapped *phases* with
141 /// `Wₑ = TEₑ·exp(−TEₑ/T2*)`, T2* = 40 ms for every participant, and divides by the
142 /// equally weighted mean echo time: `f = Σ Wₑ φₑ / Σ Wₑ TEₑ`. In the `φ/TE` form used for
143 /// every weight type here (`f = Σ wₑ (φₑ/TEₑ) / Σ wₑ`) that is `wₑ = TEₑ²·exp(−TEₑ/T2*)`.
144 ///
145 /// No T2* map and no magnitude enter it: the T2* is a single assumed constant, so every
146 /// voxel gets the same echo weights. It is a heuristic, not the inverse-variance weighting
147 /// for that decay. When magnitude images are available, [`Self::PhaseSNR`], which uses the
148 /// measured magnitude in each voxel, is generally preferable.
149 AssumedDecay {
150 /// Assumed T2* in seconds (same unit as the echo times); must be positive.
151 t2star_s: f64,
152 },
153}
154
155impl B0WeightType {
156 /// T2* assumed by [`B0WeightType::AssumedDecay`] when none is given: 40 ms, UK Biobank's value.
157 pub const DEFAULT_ASSUMED_T2STAR_S: f64 = 0.040;
158
159 /// [`B0WeightType::AssumedDecay`] with [`Self::DEFAULT_ASSUMED_T2STAR_S`].
160 pub fn assumed_decay_default() -> Self {
161 B0WeightType::AssumedDecay { t2star_s: Self::DEFAULT_ASSUMED_T2STAR_S }
162 }
163
164 /// Per-echo weight in the `φ/TE` form, `B0 = Σ w (φ/TE) / Σ w`.
165 #[inline]
166 pub fn weight(self, te: f64, mag: f64) -> f64 {
167 match self {
168 B0WeightType::PhaseSNR => mag * te,
169 B0WeightType::PhaseVar => mag * mag * te * te,
170 B0WeightType::Average => 1.0,
171 B0WeightType::TEs => te,
172 B0WeightType::Mag => mag,
173 B0WeightType::AssumedDecay { t2star_s } => te * te * (-te / t2star_s).exp(),
174 }
175 }
176
177 // Not `FromStr`: that trait returns `Result`, and this is deliberately infallible --
178 // an unrecognised name falls back to `PhaseSNR` so a stale config string degrades to the
179 // default weighting instead of failing the run. Renaming it would be a breaking change
180 // for the downstream config layers that call it.
181 #[allow(clippy::should_implement_trait)]
182 pub fn from_str(s: &str) -> Self {
183 match s.to_lowercase().as_str() {
184 "phase_snr" | "phasesnr" => B0WeightType::PhaseSNR,
185 "phase_var" | "phasevar" => B0WeightType::PhaseVar,
186 "average" | "uniform" => B0WeightType::Average,
187 "tes" | "te" => B0WeightType::TEs,
188 "mag" | "magnitude" => B0WeightType::Mag,
189 "assumed-decay" => B0WeightType::assumed_decay_default(),
190 _ => B0WeightType::PhaseSNR, // default
191 }
192 }
193}
194
195/// Compute Hermitian Inner Product (HIP) between two echoes
196///
197/// HIP = conj(echo1) * echo2 = mag1 * mag2 * exp(i * (phase2 - phase1))
198///
199/// Returns (hip_phase, hip_mag) where:
200/// - hip_phase = phase2 - phase1 (wrapped to [-π, π])
201/// - hip_mag = mag1 * mag2
202pub fn hermitian_inner_product(
203 phase1: &[f64], mag1: &[f64],
204 phase2: &[f64], mag2: &[f64],
205 mask: &[u8],
206 n: usize,
207) -> (Vec<f64>, Vec<f64>) {
208 let mut hip_phase = vec![0.0; n];
209 let mut hip_mag = vec![0.0; n];
210
211 for i in 0..n {
212 if mask[i] > 0 {
213 hip_phase[i] = wrap_to_pi(phase2[i] - phase1[i]);
214 hip_mag[i] = mag1[i] * mag2[i];
215 }
216 }
217
218 (hip_phase, hip_mag)
219}
220
221/// `mcpc3ds`'s HIP over the whole volume: `hip = conj(echo₁)·echo₂` as `(angle(hip),
222/// sqrt(|hip|))`, the phase and the weight (`src/mcpc3ds.jl:42-44`, 771eadf).
223fn hip_weight(
224 phases: &[impl AsRef<[f64]>],
225 mags: &[impl AsRef<[f64]>],
226 echoes: [usize; 2],
227 n_total: usize,
228) -> (Vec<f64>, Vec<f64>) {
229 let [e1, e2] = echoes;
230 let all = vec![1u8; n_total];
231 let (hip_phase, hip_mag) = hermitian_inner_product(
232 phases[e1].as_ref(), mags[e1].as_ref(),
233 phases[e2].as_ref(), mags[e2].as_ref(),
234 &all, n_total
235 );
236 (hip_phase, hip_mag.into_iter().map(f64::sqrt).collect())
237}
238
239/// MCPC-3D-S's own mask, `robustmask(sqrt(|hip|))` of the HIP of `echoes`, as MriResearchTools'
240/// `mcpc3ds` makes it (`src/mcpc3ds.jl:44-46`, 771eadf). It is the mask [`phase_offset_removal`]
241/// unwraps and smooths the offset in, and the one `mcpc3ds` hands to `bipolar_correction!`
242/// (`mcpc3ds.jl:55`), so a caller that pairs the two with [`bipolar_correction`] passes this mask
243/// to match the reference.
244pub fn mcpc3ds_mask(
245 phases: &[impl AsRef<[f64]>],
246 mags: &[impl AsRef<[f64]>],
247 echoes: [usize; 2],
248 grid: &Grid,
249) -> Vec<u8> {
250 let (nx, ny, nz) = grid.dims;
251 let (_, weight) = hip_weight(phases, mags, echoes, nx * ny * nz);
252 crate::utils::robust_mask(&weight, grid)
253}
254
255/// Result of [`phase_offset_removal`] (MCPC-3D-S on coil-combined echoes).
256#[derive(Clone, Debug)]
257pub struct PhaseOffsetRemoval {
258 /// The offset-corrected phase per echo, wrapped to [-π, π]; **NaN** wherever the offset is
259 /// undefined (`valid == 0`), as in MriResearchTools' `mcpc3ds`.
260 pub phases: Vec<Vec<f64>>,
261 /// The smoothed phase offset; NaN where it is undefined.
262 pub offset: Vec<f64>,
263 /// 1 where the smoothed offset is defined: MCPC-3D-S's own `robustmask` of `sqrt(|HIP|)`
264 /// plus the margin the masked smoothing extrapolates into. Only these voxels carry a phase
265 /// the field can be estimated from; restrict every later step to them (the pipeline returns
266 /// `brain mask ∩ valid` as [`FieldMappingResult::valid_mask`](crate::pipeline::FieldMappingResult::valid_mask)).
267 pub valid: Vec<u8>,
268}
269
270impl PhaseOffsetRemoval {
271 /// Number of voxels of `mask` outside [`valid`](Self::valid).
272 pub fn excluded_from(&self, mask: &[u8]) -> usize {
273 mask.iter().zip(&self.valid).filter(|(&m, &v)| m != 0 && v == 0).count()
274 }
275}
276
277/// Remove phase offset from multi-echo phase data using HIP (Hermitian Inner Product):
278/// MCPC-3D-S, as MriResearchTools.jl's `mcpc3ds` does it for one channel (`src/mcpc3ds.jl:38-57`,
279/// 771eadf; v3.8.0).
280///
281/// 1. `hip = conj(echo₁)·echo₂` over the whole volume, `weight = sqrt(|hip|)`.
282/// 2. `mask = robustmask(weight)` ([`robust_mask`](crate::utils::robust_mask)); `mcpc3ds` makes
283/// its own mask and takes none from the caller.
284/// 3. The HIP phase unwrapped inside that mask (ROMEO with the default weights and `weight` as the
285/// magnitude, `romeo(angle.(hip); mag=weight, mask)`), scaled by `TE₁/ΔTE` to the phase that
286/// evolved by `TE₁`; the offset is `φ₁` minus that, at every voxel.
287/// 4. The offset smoothed inside the same mask with MriResearchTools' `gaussiansmooth3d_phase`
288/// ([`nan_box_smooth_3d_phase`]), and removed from every echo at every voxel
289/// (`combinewithPO`).
290///
291/// # Where the offset is undefined
292///
293/// The masked smoothing leaves the offset NaN outside its mask, beyond the margin it
294/// extrapolates into, and so the reference's corrected phase is NaN there too. ROMEO.jl then
295/// never grows through those voxels: every edge that touches a NaN phase has a NaN weight, which
296/// `rescale` makes invalid (ROMEO.jl `src/weights.jl`), so they stay NaN and so does their B0
297/// (`calculateB0_unwrapped`). This function returns exactly that: NaN phase and offset there, and
298/// [`PhaseOffsetRemoval::valid`] marking the voxels that are defined.
299///
300/// Until 2026-11 QSM.rs set the offset to 0 there instead and kept the raw phase. That was QSM.rs's
301/// own deviation and it was wrong: a brain mask is not inside MCPC-3D-S's mask (scalp, the
302/// periphery and, on Philips, the scanner-masked background lie outside it), and the raw phase
303/// there carries the full coil-combination offset. On GE (real/imaginary, 7 echoes) and Philips
304/// (8 echoes) 3 T data those few voxels (0.6 % and 1.7 % of the brain mask) bridged the ROMEO
305/// growth, gave each echo a path-dependent number of turns and left ring artefacts in the field
306/// and in χ; given the same NaN→raw semantics, ROMEO.jl reproduces those rings almost voxel for
307/// voxel, and with its own NaN semantics it does not (χ correlation with Laplacian-unwrapped χ
308/// 0.21 → 0.78 on the GE scan; Q0748 audit, workstream C, ROMEO.jl 1.6.0 / MriResearchTools 3.8.0,
309/// 2026-11-04). On Siemens adaptive-combine data, with small offsets, those voxels were harmless.
310/// No option keeps the old behaviour.
311///
312/// # Arguments
313/// * `phases` - Wrapped phase per echo (n_echoes arrays of nx*ny*nz)
314/// * `mags` - Magnitude per echo
315/// * `tes` - Echo times (any consistent unit)
316/// * `sigma` - Gaussian smoothing sigma in voxels [x, y, z]
317/// * `echoes` - Which two echoes to use for HIP [e1, e2]
318/// * `unwrap_method` - Method to unwrap the HIP phase (`mcpc3ds` uses ROMEO; the others are
319/// QSM.rs options)
320/// * `grid` - Volume grid (dimensions and voxel sizes)
321pub fn phase_offset_removal(
322 phases: &[impl AsRef<[f64]>],
323 mags: &[impl AsRef<[f64]>],
324 tes: &[f64],
325 sigma: [f64; 3],
326 echoes: [usize; 2],
327 unwrap_method: UnwrapMethod,
328 grid: &Grid,
329) -> PhaseOffsetRemoval {
330 let (nx, ny, nz) = grid.dims;
331 let n_echoes = phases.len();
332 let n_total = nx * ny * nz;
333
334 let e1 = echoes[0];
335 let e2 = echoes[1];
336
337 // ΔTE = TEs[echo2] - TEs[echo1]
338 let delta_te = tes[e2] - tes[e1];
339
340 let (hip_phase, weight) = hip_weight(phases, mags, echoes, n_total);
341 // mcpc3ds: mask = robustmask(weight)
342 let mask = crate::utils::robust_mask(&weight, grid);
343
344 // Unwrap HIP phase
345 let unwrapped_hip = match unwrap_method {
346 UnwrapMethod::Romeo => {
347 unwrap_romeo(&hip_phase, &weight, None, 0.0, 0.0, &mask, &RomeoParams::default(), grid)
348 }
349 UnwrapMethod::Laplacian => {
350 // The HIP is a phase *difference*; removing its harmonic component would
351 // discard part of the offset this function exists to estimate.
352 laplacian_unwrap(&hip_phase, &mask, grid, LaplacianSolver::Dct)
353 }
354 UnwrapMethod::BestPath => {
355 unwrap_bestpath(&hip_phase, &mask, &BestPathParams::default(), grid)
356 }
357 UnwrapMethod::Prelude(params) => unwrap_prelude(&hip_phase, &mask, grid, ¶ms),
358 UnwrapMethod::MediRegionGrowing => crate::unwrap::unwrap_medi_region_growing(&hip_phase, &weight, grid),
359 };
360 drop(hip_phase);
361 drop(weight);
362
363 // po = φ₁ − (TE₁/ΔTE)·unwrapped HIP, at every voxel (mcpc3ds.jl:48-49); not wrapped here
364 let scale = tes[e1] / delta_te;
365 let phase_offset: Vec<f64> = (0..n_total)
366 .map(|i| phases[e1].as_ref()[i] - scale * unwrapped_hip[i])
367 .collect();
368 drop(unwrapped_hip);
369
370 // mcpc3ds.jl:51: po .= gaussiansmooth3d_phase(po, sigma; mask): MriResearchTools' NaN-aware
371 // box filter with mask (4 passes, alternating direction), [`nan_box_smooth_3d_phase`]. NaN
372 // where it cannot extrapolate, as in the reference (see *Where the offset is undefined*).
373 let offset = nan_box_smooth_3d_phase(&phase_offset, sigma, &mask, grid);
374 drop(phase_offset);
375 let valid: Vec<u8> = offset.iter().map(|v| u8::from(v.is_finite())).collect();
376
377 // combinewithPO for one channel: angle(|c|·c·exp(−i·po)) = wrap(φ − po), at every voxel; NaN
378 // where po is NaN
379 let corrected_phases = (0..n_echoes)
380 .map(|e| {
381 let p = phases[e].as_ref();
382 (0..n_total).map(|i| wrap_to_pi(p[i] - offset[i])).collect()
383 })
384 .collect();
385
386 PhaseOffsetRemoval { phases: corrected_phases, offset, valid }
387}
388
389/// Box-filter widths approximating a Gaussian of `sigma` (voxels) with `n` passes — the
390/// `getboxsizes` of MriResearchTools.jl (Kovesi's "fast almost-Gaussian" construction).
391/// Returns all-ones (i.e. no smoothing) when `sigma` is not positive.
392pub fn gaussian_box_sizes(sigma: f64, n: usize) -> Vec<usize> {
393 fn round_half_even(x: f64) -> f64 {
394 let r = x.round();
395 if (x - x.trunc()).abs() == 0.5 && r % 2.0 != 0.0 { r - x.signum() } else { r }
396 }
397 // `!(sigma > 0.0)` rather than `sigma <= 0.0` so that a NaN sigma also takes the early
398 // return; the two are not equivalent on NaN, which is the whole point of the spelling.
399 #[allow(clippy::neg_cmp_op_on_partial_ord)]
400 if !(sigma > 0.0) || n == 0 {
401 return vec![1; n];
402 }
403 let nf = n as f64;
404 let w_ideal = (12.0 * sigma * sigma / nf + 1.0).sqrt();
405 let wl = round_half_even(w_ideal - (w_ideal + 1.0) % 2.0); // next lower odd integer
406 let wu = wl + 2.0;
407 let m_ideal = (12.0 * sigma * sigma - nf * wl * wl - 4.0 * nf * wl - 3.0 * nf) / (-4.0 * wl - 4.0);
408 let m = round_half_even(m_ideal);
409 (1..=n).map(|i| if (i as f64) <= m { wl as usize } else { wu as usize }).collect()
410}
411
412/// MriResearchTools' `nanboxfilterline!`; lives in [`crate::utils::bias_correction`] (which
413/// `makehomogeneous` and CLEAR-SWI also use) so that module stays free of the unwrappers.
414pub use crate::utils::bias_correction::nan_box_filter_line;
415
416/// Masked 3D smoothing by repeated NaN-aware box filtering — the `mask` branch of
417/// `gaussiansmooth3d!` in MriResearchTools.jl (4 passes per axis, alternating direction on
418/// even passes, box widths from [`gaussian_box_sizes`]). Voxels outside `mask` become `NaN`
419/// before filtering and are only given values where the filter extrapolates across the
420/// boundary; the result is written in place.
421pub fn nan_box_smooth_3d(image: &mut [f64], sigma: [f64; 3], mask: &[u8], grid: &Grid) {
422 let (nx, ny, nz) = grid.dims;
423 let dims = [nx, ny, nz];
424 let n_total = nx * ny * nz;
425 assert_eq!(image.len(), n_total);
426 assert_eq!(mask.len(), n_total);
427 const NBOX: usize = 4;
428 for i in 0..n_total {
429 if mask[i] == 0 {
430 image[i] = f64::NAN;
431 }
432 }
433 let mut boxsizes: Vec<Vec<usize>> = sigma.iter().map(|&s| gaussian_box_sizes(s, NBOX)).collect();
434 // checkboxsizes!: odd widths, no wider than half the axis
435 for d in 0..3 {
436 for b in boxsizes[d].iter_mut() {
437 if *b % 2 == 0 { *b += 1; }
438 if *b as f64 > dims[d] as f64 / 2.0 {
439 let mut v = dims[d] / 2;
440 if v % 2 == 0 { v += 1; }
441 *b = v;
442 }
443 }
444 }
445 let mut line: Vec<f64> = Vec::new();
446 let mut scratch: Vec<f64> = Vec::new();
447 for ibox in 0..NBOX {
448 for d in 0..3 {
449 let bsize = boxsizes[d][ibox];
450 let len = dims[d];
451 if len == 1 || bsize < 3 {
452 continue;
453 }
454 let reverse = ibox % 2 == 1;
455 let (stride, n_lines_a, stride_a, n_lines_b, stride_b) = match d {
456 0 => (1, ny, nx, nz, nx * ny),
457 1 => (nx, nx, 1, nz, nx * ny),
458 _ => (nx * ny, nx, 1, ny, nx),
459 };
460 line.resize(len, 0.0);
461 for b in 0..n_lines_b {
462 for a in 0..n_lines_a {
463 let base = a * stride_a + b * stride_b;
464 for k in 0..len {
465 let kk = if reverse { len - 1 - k } else { k };
466 line[k] = image[base + kk * stride];
467 }
468 nan_box_filter_line(&mut line, bsize, &mut scratch);
469 for k in 0..len {
470 let kk = if reverse { len - 1 - k } else { k };
471 image[base + kk * stride] = line[k];
472 }
473 }
474 }
475 }
476 }
477}
478
479/// Masked phase smoothing via the complex representation, using [`nan_box_smooth_3d`] —
480/// `gaussiansmooth3d_phase(phase, sigma; mask)` of MriResearchTools.jl. Returns `NaN` where
481/// the smoothed complex value is undefined (far outside the mask).
482pub fn nan_box_smooth_3d_phase(phase: &[f64], sigma: [f64; 3], mask: &[u8], grid: &Grid) -> Vec<f64> {
483 let n = phase.len();
484 let mut re: Vec<f64> = phase.iter().map(|p| p.cos()).collect();
485 let mut im: Vec<f64> = phase.iter().map(|p| p.sin()).collect();
486 nan_box_smooth_3d(&mut re, sigma, mask, grid);
487 nan_box_smooth_3d(&mut im, sigma, mask, grid);
488 (0..n).map(|i| im[i].atan2(re[i])).collect()
489}
490
491/// Result of MCPC-3D-S multi-coil combination ([`mcpc3ds_combine`]).
492#[derive(Debug, Clone)]
493pub struct CoilCombinationResult {
494 /// Combined phase per echo, wrapped to [-π, π]
495 pub phases: Vec<Vec<f64>>,
496 /// Combined magnitude per echo: `sqrt(|Σ_c |S_c|² · exp(i(φ_c − po_c))|)`
497 pub magnitudes: Vec<Vec<f64>>,
498 /// Mask used for the phase-offset estimation (robust threshold on √|HIP|)
499 pub mask: Vec<u8>,
500}
501
502/// MCPC-3D-S multi-coil phase combination (Eckstein et al., MRM 2018), as in
503/// `MriResearchTools.jl`'s `mcpc3ds` for 5D (multi-echo, uncombined) input.
504///
505/// Each receive coil carries its own TE-independent phase offset, so uncombined channels
506/// cannot simply be summed. The algorithm estimates the offsets from the coil-summed
507/// Hermitian inner product (HIP) of two echoes and combines the channels coherently:
508///
509/// 1. `HIP = Σ_c |S_{1,c}| |S_{2,c}| exp(i(φ_{2,c} − φ_{1,c}))` — the coil offsets cancel in
510/// the inter-echo phase difference, so the HIP phase is pure field evolution over ΔTE.
511/// 2. The HIP phase is unwrapped once (weighted by √|HIP|, on a robust mask of that weight).
512/// 3. Per coil, `po_c = φ_{1,c} − (TE₁/ΔTE)·unwrapped_HIP`, smoothed in the complex domain
513/// with the same masked box-filter Gaussian approximation as MriResearchTools
514/// ([`nan_box_smooth_3d_phase`]; `sigma` in voxels, `mcpc3ds`'s default is [10, 10, 5]; the pipeline passes the ROMEO command line's 7 mm, see [`crate::pipeline::FieldMappingConfig::phase_offset_sigma_mm`]).
515/// 4. Per echo, `S_e = Σ_c |S_{e,c}|² exp(i(φ_{e,c} − po_c))`; output phase `arg(S_e)` and
516/// magnitude `sqrt(|S_e|)`.
517///
518/// With a single coil this reduces to [`phase_offset_removal`] (with the robust HIP mask).
519///
520/// # Arguments
521/// * `phases` - Wrapped phase, coil-major: `phases[coil][echo]` (each `nx*ny*nz`)
522/// * `mags` - Magnitude, same layout as `phases`
523/// * `tes` - Echo times (any consistent unit; only the ratio `TE₁/ΔTE` is used)
524/// * `sigma` - Gaussian smoothing sigma in voxels [x, y, z] for the phase offsets
525/// * `echoes` - Which two echoes form the HIP, default `[0, 1]`
526/// * `unwrap_method` - How to unwrap the HIP phase (ROMEO or Laplacian)
527/// * `grid` - Volume grid
528///
529/// # Errors
530/// If there are no coils, fewer than two echoes (the HIP needs two; a single-echo uncombined
531/// acquisition cannot be combined this way), the coils do not all have the same number of
532/// echoes, magnitude and phase layouts differ, `tes` does not have one time per echo, the two
533/// `echoes` are the same or out of range, their echo times are equal, or a volume does not
534/// match `grid`.
535pub fn mcpc3ds_combine<P: AsRef<[f64]>, M: AsRef<[f64]>>(
536 phases: &[Vec<P>],
537 mags: &[Vec<M>],
538 tes: &[f64],
539 sigma: [f64; 3],
540 echoes: [usize; 2],
541 unwrap_method: UnwrapMethod,
542 grid: &Grid,
543) -> Result<CoilCombinationResult, InputError> {
544 let (nx, ny, nz) = grid.dims;
545 let n = nx * ny * nz;
546 let n_coils = phases.len();
547 let fail = |msg: String| Err(InputError(format!("mcpc3ds_combine: {msg}")));
548 if n_coils == 0 {
549 return fail("no coils".into());
550 }
551 if mags.len() != n_coils {
552 return fail(format!("{} magnitude coils but {n_coils} phase coils", mags.len()));
553 }
554 let n_echoes = phases[0].len();
555 if n_echoes < 2 {
556 return fail(format!(
557 "at least two echoes are required (the coil phase offsets come from the phase \
558 difference of two echoes), got {n_echoes}"
559 ));
560 }
561 if tes.len() != n_echoes {
562 return fail(format!("{} echo times for {n_echoes} echoes", tes.len()));
563 }
564 let [e1, e2] = echoes;
565 if !(e1 < n_echoes && e2 < n_echoes && e1 != e2) {
566 return fail(format!("HIP echoes {echoes:?} must be two different echoes of {n_echoes}"));
567 }
568 if !(tes[e1].is_finite() && tes[e2].is_finite() && tes[e2] != tes[e1]) {
569 return fail(format!("HIP echoes need different, finite echo times, got {} and {}", tes[e1], tes[e2]));
570 }
571 for c in 0..n_coils {
572 if phases[c].len() != n_echoes || mags[c].len() != n_echoes {
573 return fail(format!(
574 "coil {c} has {} phase and {} magnitude echoes, coil 0 has {n_echoes}",
575 phases[c].len(), mags[c].len()
576 ));
577 }
578 for e in 0..n_echoes {
579 check_len(&format!("mcpc3ds_combine: coil {c} echo {e} phase"), phases[c][e].as_ref().len(), n)?;
580 check_len(&format!("mcpc3ds_combine: coil {c} echo {e} magnitude"), mags[c][e].as_ref().len(), n)?;
581 }
582 }
583
584 // 1. Coil-summed Hermitian inner product between the two HIP echoes.
585 let mut hip_re = vec![0.0f64; n];
586 let mut hip_im = vec![0.0f64; n];
587 for c in 0..n_coils {
588 let (p1, p2) = (phases[c][e1].as_ref(), phases[c][e2].as_ref());
589 let (m1, m2) = (mags[c][e1].as_ref(), mags[c][e2].as_ref());
590 for i in 0..n {
591 let a = m1[i] * m2[i];
592 let d = p2[i] - p1[i];
593 hip_re[i] += a * d.cos();
594 hip_im[i] += a * d.sin();
595 }
596 }
597 let hip_phase: Vec<f64> = (0..n).map(|i| hip_im[i].atan2(hip_re[i])).collect();
598 // weight = sqrt(|HIP|)
599 let weight: Vec<f64> = (0..n).map(|i| (hip_re[i] * hip_re[i] + hip_im[i] * hip_im[i]).sqrt().sqrt()).collect();
600 drop(hip_re);
601 drop(hip_im);
602
603 // 2. Robust mask on the HIP weight, then unwrap the HIP phase once.
604 let mask = crate::utils::bias_correction::robust_mask(&weight, grid);
605 let unwrapped_hip = match unwrap_method {
606 UnwrapMethod::Romeo => unwrap_romeo(&hip_phase, &weight, None, 0.0, 0.0, &mask, &RomeoParams::default(), grid),
607 UnwrapMethod::Laplacian => laplacian_unwrap(&hip_phase, &mask, grid, LaplacianSolver::Dct),
608 UnwrapMethod::BestPath => unwrap_bestpath(&hip_phase, &mask, &BestPathParams::default(), grid),
609 UnwrapMethod::Prelude(params) => unwrap_prelude(&hip_phase, &mask, grid, ¶ms),
610 UnwrapMethod::MediRegionGrowing => crate::unwrap::unwrap_medi_region_growing(&hip_phase, &weight, grid),
611 };
612 drop(hip_phase);
613 drop(weight);
614
615 // 3./4. Per-coil offset → smooth → accumulate the magnitude²-weighted complex sum per echo.
616 let scale = tes[e1] / (tes[e2] - tes[e1]);
617 let mut acc_re: Vec<Vec<f64>> = (0..n_echoes).map(|_| vec![0.0f64; n]).collect();
618 let mut acc_im: Vec<Vec<f64>> = (0..n_echoes).map(|_| vec![0.0f64; n]).collect();
619 let mut po = vec![0.0f64; n];
620 for c in 0..n_coils {
621 let p1 = phases[c][e1].as_ref();
622 for i in 0..n {
623 po[i] = if mask[i] > 0 { p1[i] - scale * unwrapped_hip[i] } else { 0.0 };
624 }
625 // Smooth as MriResearchTools does (NaN-aware box passes). Where the smoothed offset is
626 // undefined MriResearchTools' `combinewithPO` gives NaN; here those voxels are combined
627 // with offset 0 (kept: the combination's magnitude is still wanted for masking there).
628 // The field mapping that follows runs its own MCPC-3D-S on the combined echoes and marks
629 // its own undefined voxels invalid ([`phase_offset_removal`]).
630 let mut po_s = nan_box_smooth_3d_phase(&po, sigma, &mask, grid);
631 for v in po_s.iter_mut() {
632 if !v.is_finite() { *v = 0.0; }
633 }
634 for e in 0..n_echoes {
635 let (p, m) = (phases[c][e].as_ref(), mags[c][e].as_ref());
636 let (re, im) = (&mut acc_re[e], &mut acc_im[e]);
637 for i in 0..n {
638 let w = m[i] * m[i];
639 let ang = p[i] - po_s[i];
640 re[i] += w * ang.cos();
641 im[i] += w * ang.sin();
642 }
643 }
644 }
645 drop(po);
646 drop(unwrapped_hip);
647
648 let mut phases_out = Vec::with_capacity(n_echoes);
649 let mut mags_out = Vec::with_capacity(n_echoes);
650 for e in 0..n_echoes {
651 let (re, im) = (&acc_re[e], &acc_im[e]);
652 phases_out.push((0..n).map(|i| im[i].atan2(re[i])).collect());
653 mags_out.push((0..n).map(|i| (re[i] * re[i] + im[i] * im[i]).sqrt().sqrt()).collect());
654 }
655
656 Ok(CoilCombinationResult { phases: phases_out, magnitudes: mags_out, mask })
657}
658
659/// Calculate B0 field from unwrapped phase using weighted averaging
660///
661/// Implements calculateB0_unwrapped from MriResearchTools.jl
662///
663/// Formula: B0 = (1 / 2π) * Σ(phase / TE * weight) / Σ(weight)
664///
665/// # Arguments
666/// * `unwrapped_phases` - Unwrapped phase for each echo [n_echoes][nx*ny*nz]
667/// * `mags` - Magnitude for each echo (used for some weighting types)
668/// * `tes` - Echo times in seconds
669/// * `mask` - Binary mask
670/// * `weight_type` - Type of weighting to use
671/// * `grid` - Volume grid (dimensions and voxel sizes)
672///
673/// # Returns
674/// B0 field in Hz
675pub fn calculate_b0_weighted(
676 unwrapped_phases: &[impl AsRef<[f64]>],
677 mags: &[impl AsRef<[f64]>],
678 tes: &[f64],
679 mask: &[u8],
680 weight_type: B0WeightType,
681 grid: &Grid,
682) -> Vec<f64> {
683 let n_total = grid.n_total();
684 let n_echoes = unwrapped_phases.len();
685 let mut b0 = vec![0.0; n_total];
686
687 // Compute inline to avoid allocating per-echo weight arrays
688
689 // B0 = (1 / 2π) * Σ(phase / TE * weight) / Σ(weight)
690 let scale = 1.0 / TWO_PI;
691
692 for i in 0..n_total {
693 if mask[i] == 0 {
694 continue;
695 }
696
697 let mut weighted_sum = 0.0;
698 let mut weight_sum = 0.0;
699
700 for e in 0..n_echoes {
701 let te = tes[e];
702 let mag_val = mags[e].as_ref()[i];
703 let phase_over_te = unwrapped_phases[e].as_ref()[i] / te;
704
705 let w = weight_type.weight(te, mag_val);
706
707 weighted_sum += phase_over_te * w;
708 weight_sum += w;
709 }
710
711 if weight_sum > 1e-10 {
712 b0[i] = scale * weighted_sum / weight_sum;
713 }
714 }
715
716 b0
717}
718
719// =========================================================================
720// Bipolar Correction
721// =========================================================================
722
723/// Bipolar gradient correction for multi-echo phase data.
724///
725/// Removes linear phase artefact caused by bipolar readout gradients.
726/// Requires at least 3 echoes.
727///
728/// Reference: Eckstein PhD thesis (2021), Section 3.1.3
729/// https://doi.org/10.34726/hss.2021.43447
730///
731/// MriResearchTools' `bipolar_correction!` (`src/mcpc3ds.jl:97-129`, 771eadf), step for step:
732/// 1. `artefact`: with `k = (TE₁ + TE₃) / TE₂`, `fG = φ₁ + φ₃ − k·φ₂`, where φ₂ is first unwrapped
733/// by `romeo(φ₂; mag = mag₂)` when `k` is within 0.01 of an integer. Over the whole volume: no
734/// mask, and `fG` is not rewrapped.
735/// 2. `gaussiansmooth3d_phase(fG, sigma; mask)`: MriResearchTools' masked box-filter smoothing of
736/// `exp(i·fG)` ([`nan_box_smooth_3d_phase`]), NaN where it cannot extrapolate.
737/// 3. `romeo!(fG; mag = mag₁, correctglobal = true)`: ROMEO on the whole volume, again with no mask
738/// (edges touching a NaN are invalid there and here), then the global `n·2π` correction over
739/// every finite voxel.
740/// 4. `remove_artefact!`: echo `e` loses `t·fG` (`t = m/f` for odd, `(m + 1)/f` for even echoes;
741/// `m = TE₁/(TE₂ − TE₁)`, `f = (2 − k)m − k`) and is rewrapped (`rem2pi`).
742///
743/// **Deviation:** MriResearchTools writes NaN wherever `fG` is NaN (far from the mask); here such
744/// voxels keep their input phase. Everywhere `fG` is finite the result is the original's.
745///
746/// **Agreement** (astewartau/QSM.rs#319): until 2026-10-30 this smoothed `fG` with a true
747/// Gaussian kernel (radius 3σ) instead of MriResearchTools' 4-pass NaN-aware box filter, unwrapped
748/// φ₂ and `fG` inside the mask only, and corrected only inside the mask. On the 4-echo, 20×18×14,
749/// 1×1×1.5 mm phantom of `tests/mritools_parity.rs` (verified QSM-CI `julia-qsm` image,
750/// QSMxT/QSM-CI#299) the artefact estimate was up to 0.48 rad from the original's (corrected phase
751/// 0.24 rad on odd, 0.48 rad on even echoes). Now it agrees to 4.4e-16 / 8.9e-16 rad (measured
752/// 2026-10-30). The smoothing was the whole gap there: with the new smoothing but ROMEO still
753/// masked, the phantom also agrees to 8.9e-16 rad. The unmasked ROMEO is ported because the
754/// original does it; it matters only where the growth inside the mask depends on paths outside it.
755///
756/// # Arguments
757/// * `phases` - Mutable phase data per echo (modified in-place)
758/// * `mags` - Magnitude data per echo (echo 1's and echo 2's guide ROMEO; empty for none)
759/// * `tes` - Echo times (any consistent unit; only ratios are used)
760/// * `mask` - Binary mask: where the artefact is smoothed (MCPC-3D-S's `robustmask` of √|HIP| in
761/// MriResearchTools' `mcpc3ds`)
762/// * `sigma` - Smoothing sigma in voxels for the artefact estimation
763/// * `grid` - Volume grid (dimensions and voxel sizes)
764pub fn bipolar_correction<P: AsMut<[f64]> + AsRef<[f64]>>(
765 phases: &mut [P],
766 mags: &[impl AsRef<[f64]>],
767 tes: &[f64],
768 mask: &[u8],
769 sigma: [f64; 3],
770 grid: &Grid,
771) {
772 let n_echoes = phases.len();
773 if n_echoes < 3 {
774 return; // Need at least 3 echoes
775 }
776 let n_total = grid.n_total();
777 // `romeo(...)` without a mask: ROMEO.jl's `mask = trues(size(wrapped))`
778 let everywhere = vec![1u8; n_total];
779 let mag = |e: usize| if mags.is_empty() { &[] as &[f64] } else { mags[e].as_ref() };
780
781 // getm, getk
782 let m = tes[0] / (tes[1] - tes[0]);
783 let k = (tes[0] + tes[2]) / tes[1];
784
785 // Step 1: artefact(I, TEs) = ϕ1 .+ ϕ3 .- k .* ϕ2, ϕ2 unwrapped when k is near an integer
786 let phi2 = if (k - k.round_ties_even()).abs() < 0.01 {
787 unwrap_romeo(phases[1].as_ref(), mag(1), None, 0.0, 0.0, &everywhere, &RomeoParams::default(), grid)
788 } else {
789 phases[1].as_ref().to_vec()
790 };
791 let (p1, p3) = (phases[0].as_ref(), phases[2].as_ref());
792 let artefact: Vec<f64> = (0..n_total).map(|i| p1[i] + p3[i] - k * phi2[i]).collect();
793
794 // Step 2: gaussiansmooth3d_phase(fG, sigma; mask)
795 let smoothed = nan_box_smooth_3d_phase(&artefact, sigma, mask, grid);
796
797 // Step 3: romeo!(fG; mag = mag₁, correctglobal = true), no mask
798 let romeo_params = RomeoParams { correct_global: true, ..Default::default() };
799 let artefact = unwrap_romeo(&smoothed, mag(0), None, 0.0, 0.0, &everywhere, &romeo_params, grid);
800
801 // Step 4: remove_artefact!
802 let f = (2.0 - k) * m - k;
803 for (ieco, phase) in phases.iter_mut().enumerate() {
804 // Julia's echo index is 1-based: `iseven(ieco)` there is an odd index here
805 let t = if (ieco + 1) % 2 == 0 { (m + 1.0) / f } else { m / f };
806 let phase = phase.as_mut();
807 for i in 0..n_total {
808 if artefact[i].is_finite() {
809 phase[i] = wrap_to_pi(phase[i] - t * artefact[i]);
810 }
811 }
812 }
813}
814
815
816//=============================================================================
817// Multi-Echo Linear Fit
818//=============================================================================
819
820/// Result of multi-echo linear fit
821#[derive(Clone, Debug)]
822pub struct LinearFitResult {
823 /// Field map (slope) in rad/s (divide by 2π for Hz)
824 pub field: Vec<f64>,
825 /// Phase offset (intercept) in radians
826 pub phase_offset: Vec<f64>,
827 /// Fit residual (normalized by magnitude sum)
828 pub fit_residual: Vec<f64>,
829}
830
831/// Multi-echo linear fit with magnitude weighting
832///
833/// Fits a linear model: phase = slope * TE + intercept
834/// using weighted least squares with magnitude as weights.
835///
836/// Based on QSM.jl multi_echo_linear_fit and QSMART echofit.m
837///
838/// # Arguments
839/// * `unwrapped_phases` - Unwrapped phase for each echo [n_echoes][nx*ny*nz]
840/// * `mags` - Magnitude for each echo [n_echoes][nx*ny*nz]
841/// * `tes` - Echo times in seconds
842/// * `mask` - Binary mask
843/// * `estimate_offset` - If true, estimate phase offset (intercept)
844///
845/// # Returns
846/// LinearFitResult containing field, phase_offset, fit_residual
847pub fn multi_echo_linear_fit(
848 unwrapped_phases: &[impl AsRef<[f64]>],
849 mags: &[impl AsRef<[f64]>],
850 tes: &[f64],
851 mask: &[u8],
852 estimate_offset: bool,
853) -> LinearFitResult {
854 let n_echoes = unwrapped_phases.len();
855 let n_total = unwrapped_phases[0].as_ref().len();
856
857 let mut field = vec![0.0; n_total];
858 let mut phase_offset = vec![0.0; n_total];
859 let mut fit_residual = vec![0.0; n_total];
860
861 if estimate_offset {
862 // Weighted linear fit with intercept: phase = α + β * TE
863 // Using centered data approach for numerical stability
864 //
865 // β = Σ w*(TE - TE_mean)*(phase - phase_mean) / Σ w*(TE - TE_mean)²
866 // α = phase_mean - β * TE_mean (weighted means)
867
868 // Precompute weighted TE mean and sum of squared deviations
869 // (These are per-voxel because weights vary)
870 for v in 0..n_total {
871 if mask[v] == 0 {
872 continue;
873 }
874
875 // Compute weighted means
876 let mut sum_w = 0.0;
877 let mut sum_w_te = 0.0;
878 let mut sum_w_phase = 0.0;
879
880 for e in 0..n_echoes {
881 let w = mags[e].as_ref()[v];
882 sum_w += w;
883 sum_w_te += w * tes[e];
884 sum_w_phase += w * unwrapped_phases[e].as_ref()[v];
885 }
886
887 if sum_w < 1e-10 {
888 continue;
889 }
890
891 let te_mean = sum_w_te / sum_w;
892 let phase_mean = sum_w_phase / sum_w;
893
894 // Compute slope using centered data
895 let mut sum_w_te_centered_sq = 0.0;
896 let mut sum_w_te_centered_phase_centered = 0.0;
897
898 for e in 0..n_echoes {
899 let w = mags[e].as_ref()[v];
900 let te_centered = tes[e] - te_mean;
901 let phase_centered = unwrapped_phases[e].as_ref()[v] - phase_mean;
902 sum_w_te_centered_sq += w * te_centered * te_centered;
903 sum_w_te_centered_phase_centered += w * te_centered * phase_centered;
904 }
905
906 if sum_w_te_centered_sq > 1e-10 {
907 let slope = sum_w_te_centered_phase_centered / sum_w_te_centered_sq;
908 let intercept = phase_mean - slope * te_mean;
909 field[v] = slope;
910 phase_offset[v] = intercept;
911
912 // Compute weighted residual
913 let mut sum_w_resid_sq = 0.0;
914 for e in 0..n_echoes {
915 let w = mags[e].as_ref()[v];
916 let predicted = intercept + slope * tes[e];
917 let diff = unwrapped_phases[e].as_ref()[v] - predicted;
918 sum_w_resid_sq += w * diff * diff;
919 }
920 // Normalize by sum of weights and number of echoes (matching echofit.m)
921 fit_residual[v] = sum_w_resid_sq / sum_w * n_echoes as f64;
922 }
923 }
924 } else {
925 // Weighted linear fit through origin: phase = β * TE
926 // β = Σ w*TE*phase / Σ w*TE²
927 // (matching echofit.m line 40)
928
929 for v in 0..n_total {
930 if mask[v] == 0 {
931 continue;
932 }
933
934 let mut sum_w_te_phase = 0.0;
935 let mut sum_w_te_sq = 0.0;
936 let mut sum_w = 0.0;
937
938 for e in 0..n_echoes {
939 let w = mags[e].as_ref()[v];
940 let te = tes[e];
941 let phase = unwrapped_phases[e].as_ref()[v];
942 sum_w_te_phase += w * te * phase;
943 sum_w_te_sq += w * te * te;
944 sum_w += w;
945 }
946
947 if sum_w_te_sq > 1e-10 {
948 let slope = sum_w_te_phase / sum_w_te_sq;
949 field[v] = slope;
950
951 // Compute weighted residual
952 let mut sum_w_resid_sq = 0.0;
953 for e in 0..n_echoes {
954 let w = mags[e].as_ref()[v];
955 let predicted = slope * tes[e];
956 let diff = unwrapped_phases[e].as_ref()[v] - predicted;
957 sum_w_resid_sq += w * diff * diff;
958 }
959 // Normalize by sum of weights and number of echoes
960 if sum_w > 1e-10 {
961 fit_residual[v] = sum_w_resid_sq / sum_w * n_echoes as f64;
962 }
963 }
964 }
965 }
966
967 LinearFitResult {
968 field,
969 phase_offset,
970 fit_residual,
971 }
972}
973
974// =========================================================================
975// Motion-robust echo combination
976// =========================================================================
977
978/// How a standardised residual becomes a weight.
979///
980/// Measured on the four-echo 7 T phantom, one echo given a slab dropout plus a phase scramble.
981/// "Clean-series cost" is how far the robust fit moves the field map when there is *nothing*
982/// wrong — the price of insurance; "corrupted-series error" is how far the field map sits from
983/// the clean answer when there is. Both are relative errors against the plain magnitude-weighted
984/// fit of the clean data.
985///
986/// | | clean-series cost | corrupted-series error | flagged the right echo |
987/// |---|---|---|---|
988/// | no robust fit at all | 0 by definition | 0.0424 | — |
989/// | [`Huber`](Self::Huber) at `c = 1.345` | 0.0142 | 0.0304 | yes |
990/// | [`Tukey`](Self::Tukey) at `c = 4.685` | **0.0059** | **0.0268** | yes |
991/// | Tukey with the degeneracy guard removed | — | 0.320 | yes |
992///
993/// The last row is why [`multi_echo_robust_fit`] has that guard, and the reason the obvious
994/// a-priori argument — that a rejecting loss must be wrong for a four-echo train, because
995/// throwing an echo away leaves three points to fit two parameters — is only half right. It is
996/// exactly right about the danger and wrong about the remedy: the fix is to stop the fit
997/// becoming under-determined, not to stop rejecting.
998#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
999#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1000pub enum RobustLoss {
1001 /// `w = (1 - u²)²` inside the tuning radius and **zero** outside: an outlier past the radius
1002 /// is discarded entirely. Conventional tuning constant `4.685`.
1003 ///
1004 /// The default, on the evidence above — it both protects a corrupted series better *and*
1005 /// disturbs a clean one less, because it leaves everything inside the radius at full weight
1006 /// instead of shading the whole distribution the way Huber's tail does.
1007 #[default]
1008 Tukey,
1009 /// `w = 1` inside the tuning radius, `w = c/|u|` outside: an outlier's influence is capped
1010 /// but never removed. Conventional tuning constant `1.345`.
1011 ///
1012 /// Never rejects, so [`EchoQuality::rejected_fraction`] is always zero and the fit can never
1013 /// become under-determined no matter how the data behaves. Worth choosing when that
1014 /// guarantee is worth more than the accuracy, or on a series short enough that the guard
1015 /// would be firing constantly.
1016 Huber,
1017}
1018
1019impl RobustLoss {
1020 /// The conventional tuning constant for this loss — Tukey `4.685`, Huber `1.345`, both the
1021 /// values that give 95% efficiency against purely Gaussian noise.
1022 ///
1023 /// Used when [`RobustFitParams::tuning`] is `None`. On the phantom above these are also
1024 /// where the corrupted-series error bottoms out, so there is no tension between the textbook
1025 /// choice and the measured one: for Tukey the sweep gave 0.0397 / 0.0341 / **0.0268** /
1026 /// 0.0289 / 0.0361 at `c =` 2.5 / 3.5 / 4.685 / 6 / 8.
1027 pub fn conventional_tuning(self) -> f64 {
1028 match self {
1029 RobustLoss::Tukey => 4.685,
1030 RobustLoss::Huber => 1.345,
1031 }
1032 }
1033
1034 /// Weight for a residual already divided by `tuning × scale`.
1035 #[inline]
1036 fn weight(self, u: f64) -> f64 {
1037 let a = u.abs();
1038 match self {
1039 RobustLoss::Huber => {
1040 if a <= 1.0 {
1041 1.0
1042 } else {
1043 1.0 / a
1044 }
1045 }
1046 RobustLoss::Tukey => {
1047 if a <= 1.0 {
1048 let t = 1.0 - a * a;
1049 t * t
1050 } else {
1051 0.0
1052 }
1053 }
1054 }
1055 }
1056}
1057
1058/// Parameters for [`multi_echo_robust_fit`].
1059#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
1060#[derive(Clone, Debug, PartialEq)]
1061pub struct RobustFitParams {
1062 /// Which robust loss turns a residual into a weight.
1063 pub loss: RobustLoss,
1064 /// Tuning constant, in units of the robust scale; smaller is more aggressive.
1065 ///
1066 /// `None` uses [`RobustLoss::conventional_tuning`] for whichever loss is selected, which is
1067 /// what you want unless you have measured otherwise. It is `Option` rather than a number
1068 /// because the two losses' natural constants differ by about 3.5x: a fixed default would
1069 /// silently mean something quite different after changing [`Self::loss`].
1070 pub tuning: Option<f64>,
1071 /// IRLS reweighting passes. Three is plenty: the weights are a bounded function of a
1072 /// residual that is already close after the first pass, and more iterations mostly
1073 /// re-confirm the same decision.
1074 ///
1075 /// `0` is allowed and means *report only*: every robust weight stays at 1, so the field map
1076 /// is exactly [`multi_echo_linear_fit`]'s, and [`EchoQuality`] (including
1077 /// [`EchoQuality::flagged`], which reads the final fit's residuals) is still computed.
1078 pub iterations: usize,
1079 /// Robust weight below which an echo counts as *downweighted* at a voxel, for
1080 /// [`EchoQuality::downweighted_fraction`]. Purely a reporting threshold — it changes no
1081 /// weight and no fit.
1082 pub downweight_level: f64,
1083 /// How far the worst [`EchoQuality::outlier_score`] in the series has to sit above the best
1084 /// before [`EchoQuality::flagged`] names anything. Reporting only; nothing is dropped.
1085 ///
1086 /// Relative, not absolute, because an absolute residual has no fixed meaning across field
1087 /// strengths, echo trains and SNR. Measured on the four-echo 7 T phantom, where the ratio is
1088 /// `max(outlier_score) / min(outlier_score)`:
1089 ///
1090 /// | series | ratio | flagged at `3.0` |
1091 /// |---|---|---|
1092 /// | clean | 2.5 | no |
1093 /// | one echo, 35% slab dropout + phase scramble | 40.1 | yes |
1094 /// | one echo, +1 rad everywhere | 28.2 | yes |
1095 /// | one echo, +2.5 rad everywhere | 35.6 | yes |
1096 /// | one echo, 12% slab dropout | 9.8 | yes |
1097 ///
1098 /// `3.0` sits in the gap between the clean series and the smallest true positive.
1099 pub flag_ratio: f64,
1100
1101 /// Estimate and remove a constant phase offset, as in [`multi_echo_linear_fit`]. Costs one
1102 /// degree of freedom, which matters here — see [`multi_echo_robust_fit`].
1103 pub estimate_offset: bool,
1104}
1105
1106impl RobustFitParams {
1107 /// Fewest echoes [`multi_echo_robust_fit`] can act on: the model's parameter count plus two,
1108 /// so 4 with [`Self::estimate_offset`] and 3 without. Below this it has no residual degrees
1109 /// of freedom to judge an echo with and returns the plain fit.
1110 pub fn min_echoes(&self) -> usize {
1111 if self.estimate_offset { 4 } else { 3 }
1112 }
1113
1114 /// Check every field is in range, naming the first one that is not.
1115 ///
1116 /// | field | valid |
1117 /// |---|---|
1118 /// | `tuning` | `None`, or finite and `> 0` |
1119 /// | `iterations` | any (`0` = report only) |
1120 /// | `downweight_level` | `0 < level ≤ 1` |
1121 /// | `flag_ratio` | `≥ 1` (`+∞` never flags) |
1122 pub fn validate(&self) -> Result<(), String> {
1123 if let Some(t) = self.tuning {
1124 if !(t.is_finite() && t > 0.0) {
1125 return Err(format!("robust fit tuning must be finite and positive, got {t}"));
1126 }
1127 }
1128 if self.downweight_level.is_nan() || self.downweight_level <= 0.0 || self.downweight_level > 1.0 {
1129 return Err(format!(
1130 "robust fit downweight level must be in (0, 1], got {}", self.downweight_level
1131 ));
1132 }
1133 if self.flag_ratio.is_nan() || self.flag_ratio < 1.0 {
1134 return Err(format!("robust fit flag ratio must be at least 1, got {}", self.flag_ratio));
1135 }
1136 Ok(())
1137 }
1138}
1139
1140impl Default for RobustFitParams {
1141 fn default() -> Self {
1142 Self {
1143 loss: RobustLoss::default(),
1144 tuning: None,
1145 iterations: 3,
1146 downweight_level: 0.75,
1147 flag_ratio: 3.0,
1148 estimate_offset: true,
1149 }
1150 }
1151}
1152
1153/// Per-echo evidence that an echo did not fit the model the others agree on.
1154///
1155/// Read [`Self::voxels_examined`] before trusting a clean report: every statistic here is taken
1156/// over the mask, so a mask that selects nothing yields a perfect bill of health.
1157///
1158/// The per-echo fields are one value per echo, in input order. These are **diagnostics**: the robust
1159/// fit has already acted on the per-voxel weights these summarise, and nothing here drops an
1160/// echo or changes a field map. Deciding to re-acquire, exclude an echo and refit, or accept
1161/// the map is the caller's.
1162#[derive(Clone, Debug)]
1163pub struct EchoQuality {
1164 /// Fraction of in-mask voxels where this echo's robust weight fell below
1165 /// [`RobustFitParams::downweight_level`].
1166 ///
1167 /// The headline number. A clean echo sits near the rate the loss produces on pure noise
1168 /// (a few per cent for Huber at the default tuning); a corrupted one is far above it.
1169 /// Read it *relative to the other echoes in the same series*, not against an absolute
1170 /// threshold — the baseline moves with SNR, echo count and how much real signal departs
1171 /// from a straight line.
1172 pub downweighted_fraction: Vec<f64>,
1173 /// Fraction of in-mask voxels where the weight reached exactly zero. Always `0` for
1174 /// [`RobustLoss::Huber`], which never rejects; the number that matters for
1175 /// [`RobustLoss::Tukey`], where it is the fraction of the volume that echo contributed
1176 /// nothing to.
1177 pub rejected_fraction: Vec<f64>,
1178 /// Median robust weight over the mask. `1.0` means untouched.
1179 pub median_weight: Vec<f64>,
1180 /// Median absolute residual over the mask, in radians, at the final fit. Unlike the
1181 /// fractions above this is a physical quantity, so it is comparable between runs.
1182 pub median_abs_residual_rad: Vec<f64>,
1183 /// The quantity flagging is decided on: this echo's **90th-percentile** magnitude-weighted
1184 /// absolute residual at the final fit.
1185 ///
1186 /// Two choices in that sentence, both of which were measured rather than assumed.
1187 ///
1188 /// *At the final fit*, because by then a rejected echo carries almost no weight, so its
1189 /// residual is close to what it would be had the model been fitted without it. An in-fit
1190 /// residual shrinks toward zero for whichever echo is pulling the fit hardest, which is
1191 /// precisely the echo you are trying to catch.
1192 ///
1193 /// *90th percentile* rather than the median, because corruption need not cover the volume.
1194 /// An echo wrong over a third of the brain still has a perfectly ordinary median, since its
1195 /// median sits in the two thirds that are fine, and the corrupted echo then scores barely
1196 /// more than its neighbours. A high quantile reaches into the affected part while still
1197 /// ignoring the handful of voxels any echo has trouble with. Swapping this back to a median
1198 /// is caught by the tests, which is the cheapest way to see what it is buying.
1199 pub outlier_score: Vec<f64>,
1200 /// How many voxels were actually examined — the size of the mask this was run over.
1201 ///
1202 /// Present so that "nothing was flagged" can be told apart from "nothing was looked at".
1203 /// Every other field here is a statistic over the mask, so an empty mask produces a
1204 /// flawless report: no echo flagged, no voxel downweighted, every score zero. That is the
1205 /// shape of result a failed brain extraction upstream produces, and it is indistinguishable
1206 /// from a clean series unless something says how much was weighed. Check this before
1207 /// believing a clean report; `0` means the report is vacuous, not good.
1208 pub voxels_examined: usize,
1209 /// Echoes the series' own spread of [`Self::outlier_score`] says are out of line.
1210 ///
1211 /// Empty unless the worst score exceeds the best by [`RobustFitParams::flag_ratio`]; when it
1212 /// does, this names the worst echo and any other scoring within 80% of it, so a series with
1213 /// two genuinely bad echoes reports both. Nothing is dropped and no field map changes as a
1214 /// result — see the type-level note on these being diagnostics.
1215 pub flagged: Vec<usize>,
1216}
1217
1218impl EchoQuality {
1219 /// Number of echoes described.
1220 pub fn len(&self) -> usize {
1221 self.downweighted_fraction.len()
1222 }
1223
1224 /// Whether no echoes are described.
1225 pub fn is_empty(&self) -> bool {
1226 self.downweighted_fraction.is_empty()
1227 }
1228
1229 /// The echo with the largest [`Self::downweighted_fraction`], and that fraction.
1230 ///
1231 /// On a clean series this still names an echo — one of them has to be the worst. Compare
1232 /// it against the rest before reading anything into it.
1233 pub fn worst(&self) -> Option<(usize, f64)> {
1234 self.downweighted_fraction
1235 .iter()
1236 .enumerate()
1237 .max_by(|a, b| a.1.total_cmp(b.1))
1238 .map(|(e, &f)| (e, f))
1239 }
1240}
1241
1242/// A robust multi-echo fit: the field map, and the evidence about each echo that produced it.
1243#[derive(Clone, Debug)]
1244pub struct RobustFitResult {
1245 /// Same shape as [`multi_echo_linear_fit`]'s output, so this is a drop-in replacement:
1246 /// `field` is the slope in rad/s ([`field_to_hz`] converts it).
1247 pub fit: LinearFitResult,
1248 /// Per-echo diagnostics.
1249 pub quality: EchoQuality,
1250 /// Final robust weight per echo per voxel, on `[0, 1]`, **before** magnitude weighting is
1251 /// multiplied in. `1.0` everywhere means the loss never fired.
1252 pub robust_weights: Vec<Vec<f64>>,
1253}
1254
1255/// Value at `q` (0..1) of `v`, which is reordered. `None` if empty.
1256fn quantile_in_place(v: &mut [f64], q: f64) -> Option<f64> {
1257 if v.is_empty() {
1258 return None;
1259 }
1260 let k = (((v.len() - 1) as f64) * q).round() as usize;
1261 let (_, m, _) = v.select_nth_unstable_by(k.min(v.len() - 1), f64::total_cmp);
1262 Some(*m)
1263}
1264
1265/// Median of `v`, which is reordered. `None` if empty.
1266///
1267/// The true median, averaging the two central values at even length rather than taking the
1268/// upper one. That matters where this is applied to a handful of per-echo numbers: with four
1269/// echoes and one of them corrupted, the upper-median is the *largest clean* value, which is
1270/// the most forgiving possible reference to compare the corrupted one against.
1271fn median_in_place(v: &mut [f64]) -> Option<f64> {
1272 if v.is_empty() {
1273 return None;
1274 }
1275 let n = v.len();
1276 // Linear-time selection, so the per-iteration global scale does not cost a full sort of
1277 // every echo at every voxel.
1278 let (lower, m, _) = v.select_nth_unstable_by(n / 2, f64::total_cmp);
1279 let upper = *m;
1280 if n % 2 == 1 {
1281 return Some(upper);
1282 }
1283 let lo = lower.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1284 Some(0.5 * (lo + upper))
1285}
1286
1287/// Multi-echo fit that downweights echoes which disagree with the rest, and reports which.
1288///
1289/// Iteratively reweighted least squares around [`multi_echo_linear_fit`]'s model
1290/// (`phase = intercept + slope·TE`, magnitude-weighted), with a robust loss applied to each
1291/// echo's residual. An echo that is corrupted — through-plane motion, a spin-history dropout,
1292/// a shot artefact, anything that is not a straight line in TE — is pulled out of the fit at
1293/// the voxels where it misbehaves, instead of dragging the slope at every voxel it touches.
1294///
1295/// Supported by Biondetti et al., *MRM* 2022, which found weighted and nonlinear-fit echo
1296/// combination clearly outperforms unweighted averaging or plain linear fitting.
1297///
1298/// # The scale is global, and it has to be
1299///
1300/// IRLS needs a scale to measure "large" against. The textbook choice is a per-observation-set
1301/// MAD, which here means the MAD of one voxel's `n_echoes` residuals — and that is unusable on
1302/// this data. A four-echo fit with an intercept has **two** degrees of freedom, so a per-voxel
1303/// MAD is the MAD of four numbers of which two are structurally near zero; it collapses toward
1304/// zero at a large fraction of voxels, and dividing by it rejects healthy echoes essentially at
1305/// random.
1306///
1307/// So the scale is estimated **once per iteration over the whole mask**, from residuals
1308/// standardised by magnitude. Phase noise goes as `1/SNR`, so `residual × magnitude` is roughly
1309/// homoscedastic across the volume and a single scale is meaningful for all of it. That is also
1310/// what makes the per-echo fractions in [`EchoQuality`] comparable between echoes: every echo is
1311/// being measured against the same ruler.
1312///
1313/// # What it does not detect
1314///
1315/// **Most of what follows is measured at four echoes, which is this function's own minimum.**
1316/// With an intercept the model has two parameters, so four is the fewest that leaves anything to
1317/// detect with, and the tests sit exactly on that boundary. More echoes improve both halves
1318/// markedly — on the same corruption, going from four to six tightens the clean-series score
1319/// spread from 1.88 to 1.34 (more headroom under the flagging threshold) and takes the slope
1320/// correction from 9% to 28% of the plain fit's error. Read the limitations below as the
1321/// four-echo case, not as the method.
1322///
1323/// **It reports more reliably than it repairs.** Detection and correction are separate
1324/// capabilities with different sensitivities, and the gap is worth knowing about. On the phantom,
1325/// a dropout over 12% of the volume is flagged (score ratio 9.8) while the field map it produces
1326/// is neither better nor worse than the plain fit (0.0259 against 0.0246). The same is true of
1327/// corruption severe enough to pull the per-voxel fit at the voxels it affects: the echo is
1328/// named every time, but the weighting recovers progressively less of the field as the
1329/// corruption grows. Treat a flag as "look at this echo", not as "this has been handled".
1330///
1331/// **Corruption of the first or last echo, as readily as a middle one.** The endpoints of a
1332/// linear fit carry the most leverage, so the fit follows them and their residuals shrink. An
1333/// echo in the middle of the train is the easiest to catch and the first and last the hardest.
1334///
1335/// **A series in which an echo carries no signal.** This wants `n_params + 2` echoes *carrying
1336/// signal*, not merely present, and it cannot tell the difference: a near-dead echo scores ~0
1337/// because the score is magnitude-weighted, and the remaining three points then put all their
1338/// leverage on the middle echo, which gets flagged with nothing wrong with it. Drop a dead echo
1339/// before believing this. `a_dead_echo_causes_a_known_false_positive` pins the behaviour and
1340/// records the two fixes that do not work.
1341///
1342/// **Anything that is still linear in TE.** A per-echo scaling of the whole field, or a
1343/// corruption that happens to look like a different slope, fits the model perfectly well and
1344/// leaves no residual to find. What this detects is departure from a straight line, which is
1345/// what motion, dropout and shot artefacts produce — not every way an echo can be wrong.
1346///
1347/// # How many echoes this needs
1348///
1349/// Robustness costs degrees of freedom that a short echo train does not have. With
1350/// `estimate_offset` the model has two parameters, so `n_echoes` must be at least 4 for a
1351/// corrupted echo to be distinguishable from the fit; without it, at least 3. Below that there
1352/// is nothing to detect with and this returns the ordinary magnitude-weighted fit unchanged,
1353/// with every quality figure zeroed and nothing flagged — not an error, because a short series
1354/// is not a mistake, but the caller should not read a clean report as evidence of a clean series.
1355/// Check [`EchoQuality::len`] against the echo count if that distinction matters.
1356///
1357/// Even at 4 echoes this is working near its limit: one corrupted echo is 25% of the data, and
1358/// the fit it is being measured against is itself pulled by that echo. Expect it to catch an
1359/// echo that is badly wrong and to be unreliable about one that is marginally wrong.
1360///
1361/// # Arguments
1362///
1363/// Identical to [`multi_echo_linear_fit`] except that the two fit options are carried in
1364/// `params`: unwrapped phase and magnitude per echo, echo times in **seconds**, a mask, and
1365/// [`RobustFitParams`].
1366pub fn multi_echo_robust_fit(
1367 unwrapped_phases: &[impl AsRef<[f64]>],
1368 mags: &[impl AsRef<[f64]>],
1369 tes: &[f64],
1370 mask: &[u8],
1371 params: &RobustFitParams,
1372) -> RobustFitResult {
1373 let n_echoes = unwrapped_phases.len();
1374 let n_total = if n_echoes > 0 {
1375 unwrapped_phases[0].as_ref().len()
1376 } else {
1377 0
1378 };
1379 let n_params = if params.estimate_offset { 2 } else { 1 };
1380
1381 // Not enough echoes to have an opinion: fall back to the ordinary fit rather than
1382 // manufacturing weights out of a fit with no residual degrees of freedom to spare.
1383 if n_echoes < params.min_echoes() {
1384 let fit = multi_echo_linear_fit(
1385 unwrapped_phases,
1386 mags,
1387 tes,
1388 mask,
1389 params.estimate_offset,
1390 );
1391 return RobustFitResult {
1392 fit,
1393 quality: EchoQuality {
1394 downweighted_fraction: vec![0.0; n_echoes],
1395 rejected_fraction: vec![0.0; n_echoes],
1396 median_weight: vec![1.0; n_echoes],
1397 median_abs_residual_rad: vec![0.0; n_echoes],
1398 outlier_score: vec![0.0; n_echoes],
1399 voxels_examined: mask.iter().filter(|&&m| m != 0).count(),
1400 flagged: Vec::new(),
1401 },
1402 robust_weights: vec![vec![1.0; n_total]; n_echoes],
1403 };
1404 }
1405
1406 // Typical size of a standardised observation, so the scale floor below can be relative.
1407 let signal_scale = {
1408 let mut v: Vec<f64> = Vec::new();
1409 for (i, &m) in mask.iter().enumerate().take(n_total) {
1410 if m != 0 {
1411 for e in 0..n_echoes {
1412 v.push((unwrapped_phases[e].as_ref()[i] * mags[e].as_ref()[i]).abs());
1413 }
1414 }
1415 }
1416 median_in_place(&mut v).unwrap_or(1.0).max(f64::MIN_POSITIVE)
1417 };
1418
1419 let mut robust = vec![vec![1.0f64; n_total]; n_echoes];
1420 let mut slope = vec![0.0f64; n_total];
1421 let mut intercept = vec![0.0f64; n_total];
1422
1423 // Scratch for the global scale: residual × magnitude at every in-mask (echo, voxel).
1424 let mut scratch: Vec<f64> = Vec::new();
1425
1426 for _ in 0..params.iterations {
1427 // --- weighted least squares with the current robust weights -----------------------
1428 for v in 0..n_total {
1429 if mask[v] == 0 {
1430 continue;
1431 }
1432 let w = |e: usize| mags[e].as_ref()[v] * robust[e][v];
1433
1434 if params.estimate_offset {
1435 let (mut sw, mut swt, mut swp) = (0.0, 0.0, 0.0);
1436 for e in 0..n_echoes {
1437 let we = w(e);
1438 sw += we;
1439 swt += we * tes[e];
1440 swp += we * unwrapped_phases[e].as_ref()[v];
1441 }
1442 if sw < 1e-10 {
1443 continue;
1444 }
1445 let (te_mean, ph_mean) = (swt / sw, swp / sw);
1446 let (mut sxx, mut sxy) = (0.0, 0.0);
1447 for e in 0..n_echoes {
1448 let we = w(e);
1449 let dt = tes[e] - te_mean;
1450 sxx += we * dt * dt;
1451 sxy += we * dt * (unwrapped_phases[e].as_ref()[v] - ph_mean);
1452 }
1453 if sxx > 1e-10 {
1454 slope[v] = sxy / sxx;
1455 intercept[v] = ph_mean - slope[v] * te_mean;
1456 }
1457 } else {
1458 let (mut swtp, mut swtt) = (0.0, 0.0);
1459 for e in 0..n_echoes {
1460 let we = w(e);
1461 swtp += we * tes[e] * unwrapped_phases[e].as_ref()[v];
1462 swtt += we * tes[e] * tes[e];
1463 }
1464 if swtt > 1e-10 {
1465 slope[v] = swtp / swtt;
1466 }
1467 intercept[v] = 0.0;
1468 }
1469 }
1470
1471 // --- one global robust scale, over magnitude-standardised residuals ---------------
1472 scratch.clear();
1473 for v in 0..n_total {
1474 if mask[v] == 0 {
1475 continue;
1476 }
1477 for e in 0..n_echoes {
1478 let r = unwrapped_phases[e].as_ref()[v] - (intercept[v] + slope[v] * tes[e]);
1479 scratch.push((r * mags[e].as_ref()[v]).abs());
1480 }
1481 }
1482 // 1.4826 makes the MAD a consistent estimator of sigma for Gaussian data. The
1483 // residuals are already centred on zero by construction, so this is a median of
1484 // absolute values rather than of absolute deviations from a median.
1485 //
1486 // The floor is relative, not `> 0.0`: data that fits the model exactly leaves residuals
1487 // of floating-point dust, a strictly positive scale of ~1e-16, and then every genuine
1488 // value is thousands of "sigma" out and the loss rejects the entire series. Measuring
1489 // the scale against the signal it came from is what tells "no noise" apart from "very
1490 // little noise".
1491 let scale = match median_in_place(&mut scratch) {
1492 Some(m) if 1.4826 * m > 1e-10 * signal_scale => 1.4826 * m,
1493 // A perfect fit everywhere: nothing to measure, so leave every weight at 1.
1494 _ => break,
1495 };
1496
1497 // --- reweight ---------------------------------------------------------------------
1498 let denom = params.tuning.unwrap_or_else(|| params.loss.conventional_tuning()) * scale;
1499 for v in 0..n_total {
1500 if mask[v] == 0 {
1501 continue;
1502 }
1503 let mut surviving = 0usize;
1504 for e in 0..n_echoes {
1505 let r = unwrapped_phases[e].as_ref()[v] - (intercept[v] + slope[v] * tes[e]);
1506 let w = params.loss.weight(r * mags[e].as_ref()[v] / denom);
1507 robust[e][v] = w;
1508 if w > 1e-3 {
1509 surviving += 1;
1510 }
1511 }
1512 // Never leave a voxel worse-determined than the ordinary fit would have left it.
1513 // A rejecting loss can take away so many echoes that the remaining ones fit the
1514 // model exactly, and an exact fit through two points has an unconstrained slope —
1515 // which is how a robust fit produces a *wilder* field map than the one it was meant
1516 // to protect.
1517 //
1518 // Restore the *best-fitting* rejected echoes, in order, until enough survive —
1519 // rather than resetting the whole voxel to magnitude weighting. Resetting
1520 // everything looks simpler and quietly destroys the detection it is there to
1521 // protect: at a corrupted voxel the bad echo is rejected first and a second echo
1522 // often follows it over the line, and a blanket reset then rescues the bad one too.
1523 // Measured that way, a four-echo series with 35% of one echo corrupted had only
1524 // 3.3% of its voxels downweighted instead of 27%, and nothing was flagged at all.
1525 // Never leave a voxel worse-determined than the ordinary fit would have left it. A
1526 // rejecting loss can take away so many echoes that the remaining ones fit the model
1527 // exactly, and an exact fit through two points has an unconstrained slope — which is
1528 // how a robust fit produces a *wilder* field map than the one it was meant to
1529 // protect. Without this guard, Tukey on a four-echo series with one corrupted echo
1530 // landed 0.320 from the clean answer against Huber's 0.030; with it, 0.027.
1531 //
1532 // Restoring *every* echo here, rather than only the best-fitting `n_params + 1` of
1533 // them, is deliberate and was measured both ways. Keeping the worst echo rejected
1534 // sharpens the per-voxel weight diagnostic, but it also means that at any voxel
1535 // where the data is noisy enough for half the echoes to look like outliers, one of
1536 // them stays excluded on no real evidence — and that costs seven times more damage
1537 // to a *clean* series (0.040 against 0.0059) while making the corrupted case worse
1538 // than not doing it at all (0.067 against a plain fit's 0.042). When the robust
1539 // machinery cannot tell which echo is wrong, the honest answer is that it does not
1540 // know, and the ordinary fit is the right fallback. Detection does not need this
1541 // voxel anyway: it is decided on `EchoQuality::outlier_score`, which reads the final
1542 // fit's residuals rather than counting weights.
1543 if surviving < n_params + 1 {
1544 for w in robust.iter_mut() {
1545 w[v] = 1.0;
1546 }
1547 }
1548 }
1549 }
1550
1551 // --- final fit with the converged weights, reusing the ordinary estimator --------------
1552 // The product magnitude × robust is just another weight, so the established fit function
1553 // computes the field, the offset and the residual exactly as it
1554 // would for any other weighting. Nothing about the model changes; only the weights do.
1555 let weighted_mags: Vec<Vec<f64>> = (0..n_echoes)
1556 .map(|e| {
1557 let m = mags[e].as_ref();
1558 (0..n_total).map(|v| m[v] * robust[e][v]).collect()
1559 })
1560 .collect();
1561 let fit = multi_echo_linear_fit(
1562 unwrapped_phases,
1563 &weighted_mags,
1564 tes,
1565 mask,
1566 params.estimate_offset,
1567 );
1568
1569 // --- per-echo diagnostics --------------------------------------------------------------
1570 let n_mask = mask.iter().filter(|&&m| m != 0).count().max(1) as f64;
1571 let mut downweighted_fraction = Vec::with_capacity(n_echoes);
1572 let mut rejected_fraction = Vec::with_capacity(n_echoes);
1573 let mut median_weight = Vec::with_capacity(n_echoes);
1574 let mut median_abs_residual_rad = Vec::with_capacity(n_echoes);
1575 let mut outlier_score = Vec::with_capacity(n_echoes);
1576 for e in 0..n_echoes {
1577 let mut weights: Vec<f64> = Vec::new();
1578 let mut resid: Vec<f64> = Vec::new();
1579 let mut zresid: Vec<f64> = Vec::new();
1580 let (mut low, mut zero) = (0usize, 0usize);
1581 for v in 0..n_total {
1582 if mask[v] == 0 {
1583 continue;
1584 }
1585 let w = robust[e][v];
1586 if w < params.downweight_level {
1587 low += 1;
1588 }
1589 if w <= 0.0 {
1590 zero += 1;
1591 }
1592 weights.push(w);
1593 let r = (unwrapped_phases[e].as_ref()[v]
1594 - (fit.phase_offset[v] + fit.field[v] * tes[e]))
1595 .abs();
1596 resid.push(r);
1597 zresid.push(r * mags[e].as_ref()[v]);
1598 }
1599 downweighted_fraction.push(low as f64 / n_mask);
1600 rejected_fraction.push(zero as f64 / n_mask);
1601 median_weight.push(median_in_place(&mut weights).unwrap_or(1.0));
1602 median_abs_residual_rad.push(median_in_place(&mut resid).unwrap_or(0.0));
1603 outlier_score.push(quantile_in_place(&mut zresid, 0.90).unwrap_or(0.0));
1604 }
1605 // Flag relative to the series' own median rather than against an absolute rate — see
1606 // `RobustFitParams::flag_ratio`. The median over the echoes is itself robust to the one or
1607 // two corrupted ones it is meant to expose.
1608 // Compared against the *best-fitting* echo, not the median. The median is contaminated once
1609 // the corruption covers a decent share of the volume: at a corrupted voxel every echo's
1610 // residual rises, because the fit is being pulled, so a high quantile of a clean echo reads
1611 // the corrupted region too. The best echo in the series is the one place that contamination
1612 // has not reached, which makes "how much worse is this echo than the best one" both the more
1613 // stable question and the more natural one.
1614 let reference = outlier_score
1615 .iter()
1616 .copied()
1617 .fold(f64::INFINITY, f64::min)
1618 .max(f64::MIN_POSITIVE);
1619 // Two steps, because "is anything wrong with this series" and "which echo" are different
1620 // questions and only the first has a clean threshold. The spread between the worst-scoring
1621 // echo and the best-scoring one decides the first: measured, a clean series spreads 2.5x and
1622 // the corrupted ones 9.8x to 40x. Attribution then names the worst echo and any other within
1623 // `COMPANION` of it, so a series with two genuinely bad echoes reports both.
1624 //
1625 // Flagging everything that merely clears the ratio does not work: corruption raises the
1626 // neighbouring echoes' scores as well, because the fit is pulled, and the same phantom then
1627 // reports three of four echoes bad when one is.
1628 const COMPANION: f64 = 0.8;
1629 let worst_score = outlier_score.iter().copied().fold(0.0f64, f64::max);
1630 let flagged: Vec<usize> = if worst_score > params.flag_ratio * reference {
1631 (0..n_echoes)
1632 .filter(|&e| outlier_score[e] >= COMPANION * worst_score)
1633 .collect()
1634 } else {
1635 Vec::new()
1636 };
1637
1638 RobustFitResult {
1639 fit,
1640 quality: EchoQuality {
1641 downweighted_fraction,
1642 rejected_fraction,
1643 median_weight,
1644 median_abs_residual_rad,
1645 outlier_score,
1646 voxels_examined: mask.iter().filter(|&&m| m != 0).count(),
1647 flagged,
1648 },
1649 robust_weights: robust,
1650 }
1651}
1652
1653/// Convert field from rad/s to Hz
1654#[inline]
1655pub fn field_to_hz(field: &[f64]) -> Vec<f64> {
1656 field.iter().map(|&f| f / TWO_PI).collect()
1657}
1658
1659/// Settings of [`fit_ppm_complex`], the `opts` of `Fit_ppm_complex.m` (defaults as there).
1660#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
1661#[derive(Clone, Copy, Debug, PartialEq)]
1662pub struct FitPpmComplexParams {
1663 /// The Gauss-Newton iterations stop once `‖Δp1‖ ≤ reltol · ‖p1₀‖`, both norms taken over the
1664 /// whole volume (`opts.reltol`, default 1e-4).
1665 pub reltol: f64,
1666 /// Iteration cap (`opts.max_iter`, default 30).
1667 pub max_iter: usize,
1668}
1669
1670impl Default for FitPpmComplexParams {
1671 fn default() -> Self {
1672 Self { reltol: 1e-4, max_iter: 30 }
1673 }
1674}
1675
1676/// Output of [`fit_ppm_complex`]. Each map has one value per voxel.
1677#[derive(Clone, Debug)]
1678pub struct FitPpmComplexResult {
1679 /// Field as phase accrued per echo spacing (radians), **wrapped** to `[-π, π]`: it still needs
1680 /// spatial unwrapping. Divide by [`Self::echo_spacing`] for rad/s. Sign: that of the input
1681 /// phase (it increases with TE for a positive field), the convention of QSM.rs's other field
1682 /// maps. MATLAB's `p1` is minus this, because `Fit_ppm_complex.m` fits `conj(M)`.
1683 pub field: Vec<f64>,
1684 /// A priori standard deviation of [`Self::field`], radians per echo spacing (`dp1`, which
1685 /// MEDI passes on as `N_std`). Depends only on the echo magnitudes; 0 where the fit is
1686 /// undefined. Same as [`fit_ppm_complex_noise`].
1687 pub noise: Vec<f64>,
1688 /// Fitted phase at the first echo (radians, not wrapped). MATLAB's `p0` is minus this.
1689 pub phase0: Vec<f64>,
1690 /// Relative residual `Σₑ|Sₑ − Ŝₑ|² / Σₑ|Sₑ|²` (`relres`); 0 where undefined.
1691 pub relres: Vec<f64>,
1692 /// The echo spacing the field is per (s): `TE₂ − TE₁`, or `TE₁` for a single echo, which is
1693 /// how MEDI's readers set `delta_TE`.
1694 pub echo_spacing: f64,
1695 /// Gauss-Newton iterations run (the same for every voxel, as in the reference).
1696 pub iterations: usize,
1697}
1698
1699/// The inverse of the magnitude-weighted normal matrix of the fit of phase to echo index,
1700/// `(ai11, ai12, ai22)`, from `|Sₑ|` for echo indices `e = 0, 1, …` (`Fit_ppm_complex.m`'s
1701/// `a11 … ai22`, operation for operation, so the results round as MATLAB's do).
1702fn fit_ppm_complex_normal_inverse(abs_m: impl Iterator<Item = f64>) -> (f64, f64, f64) {
1703 let (mut a11, mut a12, mut a22) = (0.0f64, 0.0f64, 0.0f64);
1704 for (e, s) in abs_m.enumerate() {
1705 let w = s * s;
1706 let t = e as f64;
1707 a11 += w;
1708 a12 += w * t;
1709 a22 += w * (t * t);
1710 }
1711 let d = a11 * a22 - a12 * a12;
1712 (a22 / d, -a12 / d, a11 / d)
1713}
1714
1715/// `sqrt(ai22)` with NaN and ±Inf set to 0, as `dp1` at the end of `Fit_ppm_complex.m` (a
1716/// negative `ai22` gives a complex root there whose real part is kept, i.e. 0; NaN here).
1717fn fit_ppm_complex_std(ai22: f64) -> f64 {
1718 let v = ai22.sqrt();
1719 if v.is_finite() { v } else { 0.0 }
1720}
1721
1722/// Noise standard deviation of the field fit, as `Fit_ppm_complex.m` in the MEDI toolbox returns
1723/// it (`dp1`, MEDI's `N_std`): the a priori error of the frequency in a magnitude-weighted linear
1724/// fit of phase to echo index, `sqrt(a11 / (a11·a22 − a12²))` with `a_jk = Σ_e |S_e|² e^(j+k−2)`
1725/// over echo indices `e = 0, 1, …`. It depends only on the echo magnitudes, so this is
1726/// [`fit_ppm_complex`]'s `noise` without the phase. A single echo is treated as
1727/// `Fit_ppm_complex.m` does, as two echoes of the same magnitude. Voxels where the fit is
1728/// undefined get 0, as there (MEDI's data weight then ignores them).
1729///
1730/// `magnitude` holds `n_echoes` volumes of `n` voxels each, echo slowest.
1731pub fn fit_ppm_complex_noise(magnitude: &[f64], n_echoes: usize) -> Vec<f64> {
1732 assert!(n_echoes > 0 && magnitude.len().is_multiple_of(n_echoes), "magnitude is not n_echoes volumes");
1733 let n = magnitude.len() / n_echoes;
1734 (0..n)
1735 .map(|i| {
1736 let echoes = n_echoes.max(2);
1737 let abs_m = (0..echoes).map(|e| magnitude[i + n * e.min(n_echoes - 1)].abs());
1738 fit_ppm_complex_std(fit_ppm_complex_normal_inverse(abs_m).2)
1739 })
1740 .collect()
1741}
1742
1743/// How far, in seconds, each echo spacing may be from the first for [`fit_ppm_complex`] and
1744/// MEDI's noise map ([`fit_ppm_complex_noise`]) to count the echoes as evenly spaced: 0.02 ms.
1745///
1746/// `Fit_ppm_complex.m` models the phase in echo index and takes no echo times, so it has no
1747/// tolerance of its own; the check is QSM.rs's, to refuse trains the model does not describe.
1748/// The tolerance is absolute and set by how echo times are stored, not by the spacing: BIDS
1749/// sidecars and DICOM headers round them, typically to 0.01 ms, so each spacing can be off by
1750/// 0.01 ms and two spacings of an evenly spaced train can differ by 0.02 ms (for example
1751/// `[4.92, 9.84, 14.77, 19.68]` ms, spacings `[4.92, 4.93, 4.91]` ms). A relative 1e-3 of the
1752/// spacing, used before, is 0.005 ms on a 5 ms train and refused such sidecars. A train that
1753/// really is uneven (a different first interval, a missing echo) is off by far more.
1754pub const FIT_PPM_COMPLEX_SPACING_TOLERANCE_S: f64 = 2e-5;
1755
1756/// Why [`fit_ppm_complex`] (and so MEDI's field map and noise map, which every pipeline entry
1757/// point that runs them checks with this) cannot use these echo times, or `None` if it can: they
1758/// must be finite, increasing (a single echo after `TE = 0`), and evenly spaced, each spacing
1759/// within [`FIT_PPM_COMPLEX_SPACING_TOLERANCE_S`] of the first. A host can call this to reject a
1760/// run before any work starts, and so applies exactly the rule and tolerance QSM.rs does.
1761pub fn echo_spacing_problem(echo_times: &[f64]) -> Option<String> {
1762 let first = match echo_times {
1763 [] => return Some("needs at least one echo time".to_string()),
1764 [te] => *te,
1765 [te1, te2, ..] => te2 - te1,
1766 };
1767 if echo_times.iter().any(|t| !t.is_finite()) || first <= 0.0 {
1768 return Some(format!("needs finite, increasing echo times; got {echo_times:?} s"));
1769 }
1770 if echo_times.windows(2).any(|w| ((w[1] - w[0]) - first).abs() > FIT_PPM_COMPLEX_SPACING_TOLERANCE_S) {
1771 return Some(format!(
1772 "needs evenly spaced echoes (each spacing within {} ms of the first); got {echo_times:?} s",
1773 FIT_PPM_COMPLEX_SPACING_TOLERANCE_S * 1e3
1774 ));
1775 }
1776 None
1777}
1778
1779/// Complex multi-echo field fit, a port of `Fit_ppm_complex.m` (MEDI toolbox 2024.11.26; Liu et
1780/// al., MRM 2013;69:467; Kressler et al., IEEE TMI 2010;29:273; de Rochefort et al., MRM
1781/// 2008;60:1003).
1782///
1783/// Fits `Sₑ = |Sₑ| exp(i(p0 + e·p1))` over echo indices `e = 0, 1, …` to the complex echoes by
1784/// magnitude-weighted Gauss-Newton, starting from a linear fit to the temporally unwrapped phase
1785/// of the first three echoes, and returns `p1`, the phase per echo spacing, wrapped to `[-π, π]`.
1786/// MEDI then unwraps it spatially (`unwrapPhase`, a region-growing unwrapper) and divides by
1787/// `delta_TE`; [`run_field_mapping`](crate::pipeline::run_field_mapping) does the same with
1788/// QSM.rs's own unwrappers when
1789/// [`B0EstimationMethod::ComplexFit`](crate::pipeline::B0EstimationMethod::ComplexFit) is chosen.
1790///
1791/// As in the reference:
1792/// - the echoes must be evenly spaced (`echo_times` is only used to check that, with
1793/// [`echo_spacing_problem`], and to report the spacing; the model is in echo index);
1794/// - the stopping test and the iteration count are global over the volume, not per voxel;
1795/// - a single echo is fitted as `cat(4, abs(M), M)`, i.e. with zero phase at `TE = 0`, so the
1796/// field is the echo's phase over `TE₁`;
1797/// - a zero magnitude has phase 0 (MATLAB's `angle(0)`), and NaN updates are set to 0.
1798///
1799/// Not ported, because a QSM.rs caller does not need them: the coil combination of a 5th
1800/// (channel) dimension (pass coil-combined echoes) and the splitting of inputs over 1e9 bytes
1801/// into slabs (memory only; the result is the same, apart from the global stopping test being
1802/// evaluated per slab there).
1803///
1804/// Sign: MATLAB fits `conj(M)`, so its `p1` and `p0` are minus [`FitPpmComplexResult::field`] and
1805/// [`FitPpmComplexResult::phase0`]. Here the conjugate is taken as the first step and undone as
1806/// the last, so every intermediate is MATLAB's and the outputs follow QSM.rs's convention (the
1807/// sign of the input phase, as the ROMEO and Laplacian field maps).
1808///
1809/// # Reference pipeline
1810/// MEDI's pipeline script (`README.m:80-103` of the 2024.11.26 release) computes
1811/// `iMag = sqrt(sum(abs(iField).^2,4))`, then `[iFreq_raw, N_std] = Fit_ppm_complex(iField)` on
1812/// the complex (coil-combined or per-channel) echoes with the default `opts`, unwraps `iFreq_raw`
1813/// with `unwrapPhase(iMag, iFreq_raw, matrix_size)` (region-growing), and hands the field and
1814/// `N_std` (both in radians per `delta_TE`) to `PDF` and `MEDI_L1`. For uneven echo spacing the
1815/// toolbox has `Fit_ppm_complex_TE(iField, TE)` and for bipolar readouts
1816/// `Fit_ppm_complex_bipolar` (unwrapped at twice the phase and halved, README.m:98-102); neither
1817/// is ported. Nothing in the fit depends on the field strength.
1818///
1819/// `magnitudes[e]` and `phases[e]` are echo `e` (phase in radians, wrapped or not).
1820pub fn fit_ppm_complex(
1821 magnitudes: &[&[f64]],
1822 phases: &[&[f64]],
1823 echo_times: &[f64],
1824 params: &FitPpmComplexParams,
1825) -> Result<FitPpmComplexResult, InputError> {
1826 use num_complex::Complex64;
1827 let n_in = phases.len();
1828 if n_in == 0 {
1829 return Err(InputError::new("fit_ppm_complex: no echoes"));
1830 }
1831 if magnitudes.len() != n_in || echo_times.len() != n_in {
1832 return Err(InputError(format!(
1833 "fit_ppm_complex: {} phase echoes, {} magnitude echoes and {} echo times",
1834 n_in, magnitudes.len(), echo_times.len()
1835 )));
1836 }
1837 let n = phases[0].len();
1838 for e in 0..n_in {
1839 check_len(&format!("fit_ppm_complex: phase of echo {}", e + 1), phases[e].len(), n)?;
1840 check_len(&format!("fit_ppm_complex: magnitude of echo {}", e + 1), magnitudes[e].len(), n)?;
1841 }
1842 if let Some(problem) = echo_spacing_problem(echo_times) {
1843 return Err(InputError(format!("fit_ppm_complex {problem}")));
1844 }
1845 let echo_spacing = if n_in == 1 { echo_times[0] } else { echo_times[1] - echo_times[0] };
1846 if !(params.reltol.is_finite() && params.reltol >= 0.0) {
1847 return Err(InputError(format!("fit_ppm_complex: reltol must be >= 0, got {}", params.reltol)));
1848 }
1849
1850 // M = mag .* exp(1i*phase); one echo: M = cat(4, abs(M), M); M = conj(M)
1851 let echo = |e: usize, i: usize| Complex64::from_polar(magnitudes[e][i], phases[e][i]);
1852 let nechos = n_in.max(2);
1853 let m: Vec<Vec<Complex64>> = if n_in == 1 {
1854 vec![
1855 (0..n).map(|i| Complex64::new(echo(0, i).norm(), 0.0)).collect(),
1856 (0..n).map(|i| echo(0, i).conj()).collect(),
1857 ]
1858 } else {
1859 (0..n_in).map(|e| (0..n).map(|i| echo(e, i).conj()).collect()).collect()
1860 };
1861 let abs_m: Vec<Vec<f64>> = m.iter().map(|me| me.iter().map(|z| z.norm()).collect()).collect();
1862
1863 // Initial guess: temporally unwrap the phase of the first (up to) three echoes against the
1864 // first difference, then fit p0 + e·p1 to them by least squares.
1865 let k = nechos.min(3);
1866 let mut p0 = vec![0.0f64; n];
1867 let mut p1 = vec![0.0f64; n];
1868 for i in 0..n {
1869 let mut y = [0.0f64; 3];
1870 for (e, ye) in y.iter_mut().enumerate().take(k) {
1871 // MATLAB's angle of a zero is 0 whatever the signs of its zero parts; atan2 would
1872 // give ±π for a negative-zero real part, which mag .* exp(1i*phase) produces.
1873 let z = m[e][i];
1874 *ye = if z.re == 0.0 && z.im == 0.0 { 0.0 } else { z.arg() };
1875 }
1876 // [m ind] = min([abs(c-2*pi), abs(c), abs(c+2*pi)], [], 2): the first minimum wins
1877 let mut c = y[1] - y[0];
1878 let cands = [(c - 2.0 * PI).abs(), c.abs(), (c + 2.0 * PI).abs()];
1879 let mut ind = 0;
1880 for j in 1..3 {
1881 if cands[j] < cands[ind] {
1882 ind = j;
1883 }
1884 }
1885 if ind == 0 {
1886 c -= 2.0 * PI;
1887 } else if ind == 2 {
1888 c += 2.0 * PI;
1889 }
1890 for nn in 0..(nechos - 1).min(2) {
1891 let cd = (y[nn + 1] - y[nn]) - c;
1892 if cd < -PI {
1893 for ye in y.iter_mut().take(k).skip(nn + 1) {
1894 *ye += 2.0 * PI;
1895 }
1896 }
1897 if cd > PI {
1898 for ye in y.iter_mut().take(k).skip(nn + 1) {
1899 *ye -= 2.0 * PI;
1900 }
1901 }
1902 }
1903 // ip = A \ Y' with A = [1 0; 1 1; 1 2] (first k rows)
1904 if k == 2 {
1905 p0[i] = y[0];
1906 p1[i] = y[1] - y[0];
1907 } else {
1908 p0[i] = (5.0 * y[0] + 2.0 * y[1] - y[2]) / 6.0;
1909 p1[i] = (y[2] - y[0]) / 2.0;
1910 }
1911 }
1912
1913 let norm = |v: &[f64]| v.iter().map(|x| x * x).sum::<f64>().sqrt();
1914 let tol = norm(&p1) * params.reltol;
1915 let mut dp1 = p1.clone();
1916 let mut dp0 = vec![0.0f64; n];
1917 let ai: Vec<(f64, f64, f64)> = (0..n)
1918 .map(|i| fit_ppm_complex_normal_inverse((0..nechos).map(|e| abs_m[e][i])))
1919 .collect();
1920
1921 // Weighted Gauss-Newton, globally stopped as in the reference.
1922 let mut iter = 0;
1923 while norm(&dp1) > tol && iter < params.max_iter {
1924 iter += 1;
1925 for i in 0..n {
1926 let (mut pr1, mut pr2) = (Complex64::new(0.0, 0.0), Complex64::new(0.0, 0.0));
1927 for e in 0..nechos {
1928 let t = e as f64;
1929 let w = abs_m[e][i] * Complex64::new(0.0, p0[i] + p1[i] * t).exp();
1930 let g = (Complex64::i() * w).conj();
1931 let r = m[e][i] - w;
1932 pr1 += g * r;
1933 pr2 += (g * t) * r;
1934 }
1935 let (ai11, ai12, ai22) = ai[i];
1936 let d0 = ai11 * pr1.re + ai12 * pr2.re;
1937 let d1 = ai12 * pr1.re + ai22 * pr2.re;
1938 dp1[i] = if d1.is_nan() { 0.0 } else { d1 };
1939 dp0[i] = if d0.is_nan() { 0.0 } else { d0 };
1940 p1[i] += dp1[i];
1941 p0[i] += dp0[i];
1942 }
1943 }
1944
1945 let noise: Vec<f64> = ai.iter().map(|&(_, _, ai22)| fit_ppm_complex_std(ai22)).collect();
1946 let relres: Vec<f64> = (0..n)
1947 .map(|i| {
1948 let (mut num, mut den) = (0.0f64, 0.0f64);
1949 for e in 0..nechos {
1950 let fit = abs_m[e][i] * Complex64::new(0.0, p0[i] + p1[i] * e as f64).exp();
1951 num += (m[e][i] - fit).norm().powi(2);
1952 den += abs_m[e][i].powi(2);
1953 }
1954 let r = num / den;
1955 if r.is_nan() { 0.0 } else { r }
1956 })
1957 .collect();
1958 // p1(p1>pi) = mod(p1+pi, 2*pi) - pi, likewise below -pi; then undo the conjugate
1959 let field = p1.iter()
1960 .map(|&p| -(if (-PI..=PI).contains(&p) { p } else { (p + PI).rem_euclid(TWO_PI) - PI }))
1961 .collect();
1962 let phase0 = p0.iter().map(|&p| -p).collect();
1963 Ok(FitPpmComplexResult { field, noise, phase0, relres, echo_spacing, iterations: iter })
1964}
1965
1966#[cfg(test)]
1967mod tests {
1968 #[test]
1969 fn fit_ppm_complex_noise_matches_the_closed_form() {
1970 // two voxels, three echoes: |S| = (2, 1, 1) and a zero voxel
1971 let mag = [2.0, 0.0, 1.0, 0.0, 1.0, 0.0];
1972 let s = super::fit_ppm_complex_noise(&mag, 3);
1973 // a11 = 4+1+1 = 6, a12 = 0+1+2 = 3, a22 = 0+1+4 = 5; sqrt(6 / (30 - 9))
1974 assert!((s[0] - (6.0f64 / 21.0).sqrt()).abs() < 1e-15);
1975 assert_eq!(s[1], 0.0);
1976 // one echo is two echoes of the same magnitude: a11 = 2|S|², a12 = a22 = |S|², so 2/|S|²
1977 let one = super::fit_ppm_complex_noise(&[2.0], 1);
1978 assert!((one[0] - (2.0f64 / 4.0).sqrt()).abs() < 1e-15);
1979 }
1980
1981 /// Noise-free echoes `|S| exp(i(φ₀ + ω·TEₑ))`, wrapped, for a few voxels.
1982 fn complex_echoes(omegas: &[f64], phi0: f64, tes: &[f64]) -> (Vec<Vec<f64>>, Vec<Vec<f64>>) {
1983 let mags = tes.iter().enumerate()
1984 .map(|(e, _)| omegas.iter().map(|_| 2.0 - 0.2 * e as f64).collect())
1985 .collect();
1986 let phases = tes.iter()
1987 .map(|&te| omegas.iter().map(|&w| wrap_to_pi(phi0 + w * te)).collect())
1988 .collect();
1989 (mags, phases)
1990 }
1991
1992 #[test]
1993 fn fit_ppm_complex_recovers_the_wrapped_field_with_the_input_sign() {
1994 let tes = [0.004, 0.009, 0.014, 0.019];
1995 // per echo spacing: 0.25, -1.0, 2.5 and 4.0 (which wraps to 4 - 2π) radians
1996 let omegas = [50.0, -200.0, 500.0, 800.0];
1997 let (m, p) = complex_echoes(&omegas, 0.7, &tes);
1998 let (mr, pr): (Vec<&[f64]>, Vec<&[f64]>) =
1999 (m.iter().map(|v| v.as_slice()).collect(), p.iter().map(|v| v.as_slice()).collect());
2000 let r = fit_ppm_complex(&mr, &pr, &tes, &FitPpmComplexParams::default()).unwrap();
2001 assert!((r.echo_spacing - 0.005).abs() < 1e-15);
2002 for (i, &w) in omegas.iter().enumerate() {
2003 let want = wrap_to_pi(w * 0.005);
2004 assert!((r.field[i] - want).abs() < 1e-9, "voxel {i}: {} vs {want}", r.field[i]);
2005 assert!(wrap_to_pi(r.phase0[i] - (0.7 + w * tes[0])).abs() < 1e-9);
2006 assert!(r.relres[i] < 1e-20);
2007 }
2008 // the same noise map as fit_ppm_complex_noise, to rounding (the two paths can differ in
2009 // the last bit between builds)
2010 for (a, b) in r.noise.iter().zip(fit_ppm_complex_noise(&m.concat(), tes.len())) {
2011 assert!((a - b).abs() <= 1e-14 * b.abs(), "{a} vs {b}");
2012 }
2013 // 0.25 rad per spacing over 5 ms: positive phase slope, positive field
2014 assert!(r.field[0] > 0.0 && r.field[1] < 0.0);
2015 }
2016
2017 #[test]
2018 fn fit_ppm_complex_one_echo_is_its_phase() {
2019 // cat(4, abs(M), M): zero phase at TE = 0, so the field is the phase over TE₁
2020 let r = fit_ppm_complex(&[&[1.0, 3.0, 0.0]], &[&[0.3, -2.9, 1.0]], &[0.01], &FitPpmComplexParams::default())
2021 .unwrap();
2022 assert_eq!(r.echo_spacing, 0.01);
2023 assert!((r.field[0] - 0.3).abs() < 1e-12 && (r.field[1] + 2.9).abs() < 1e-12);
2024 // a zero magnitude has angle 0 and no noise estimate
2025 assert_eq!((r.field[2], r.noise[2]), (0.0, 0.0));
2026 // also when the zero's real part is negative (phase near ±π): angle 0, as MATLAB's
2027 // angle(0), not atan2's π. Echo 1 at -3 rad and two zero echoes: Y = (3, 0, 0) after the
2028 // conjugate, c = -3 is kept, p1 = (0 - 3) / 2, so the field is +1.5.
2029 let r = fit_ppm_complex(&[&[1.0], &[0.0], &[0.0]], &[&[-3.0], &[-3.0], &[-3.0]], &[0.01, 0.02, 0.03],
2030 &FitPpmComplexParams::default()).unwrap();
2031 assert!((r.field[0] - 1.5).abs() < 1e-12, "{}", r.field[0]);
2032 }
2033
2034 #[test]
2035 fn fit_ppm_complex_refuses_uneven_echoes() {
2036 let v = [1.0];
2037 let e = fit_ppm_complex(&[&v, &v, &v], &[&v, &v, &v], &[0.005, 0.010, 0.020], &FitPpmComplexParams::default());
2038 assert!(e.unwrap_err().0.contains("evenly spaced"));
2039 }
2040
2041 #[test]
2042 fn echo_spacing_accepts_sidecar_rounding() {
2043 // An evenly spaced train as its sidecars round it: spacings 4.92, 4.93, 4.91 ms.
2044 let te = [4.92e-3, 9.84e-3, 14.77e-3, 19.68e-3];
2045 assert_eq!(echo_spacing_problem(&te), None);
2046 let v = [1.0];
2047 let r = fit_ppm_complex(&[&v, &v, &v, &v], &[&v, &v, &v, &v], &te, &FitPpmComplexParams::default()).unwrap();
2048 assert_eq!(r.echo_spacing, te[1] - te[0]);
2049 // A train that is uneven by more than rounding, decreasing, non-finite or empty is not.
2050 for bad in [&[4.92e-3, 9.84e-3, 14.80e-3][..], &[0.03, 0.02, 0.01], &[0.005, f64::NAN], &[0.0], &[]] {
2051 assert!(echo_spacing_problem(bad).is_some(), "{bad:?}");
2052 }
2053 assert_eq!(echo_spacing_problem(&[0.005]), None);
2054 }
2055
2056 use super::*;
2057
2058 fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
2059 Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
2060 }
2061
2062 #[test]
2063 fn test_wrap_to_pi() {
2064 assert!((wrap_to_pi(0.0) - 0.0).abs() < 1e-10);
2065 assert!((wrap_to_pi(PI) - PI).abs() < 1e-10);
2066 assert!((wrap_to_pi(-PI) - (-PI)).abs() < 1e-10);
2067 assert!((wrap_to_pi(3.0 * PI) - PI).abs() < 1e-10);
2068 assert!((wrap_to_pi(-3.0 * PI) - (-PI)).abs() < 1e-10);
2069 }
2070
2071 #[test]
2072 fn test_hip() {
2073 let n = 8;
2074 let phase1 = vec![0.1; n];
2075 let phase2 = vec![0.3; n];
2076 let mag1 = vec![1.0; n];
2077 let mag2 = vec![1.0; n];
2078 let mask = vec![1u8; n];
2079
2080 let (hip_phase, hip_mag) = hermitian_inner_product(&phase1, &mag1, &phase2, &mag2, &mask, n);
2081
2082 for i in 0..n {
2083 assert!((hip_phase[i] - 0.2).abs() < 1e-10);
2084 assert!((hip_mag[i] - 1.0).abs() < 1e-10);
2085 }
2086 }
2087
2088 // =========================================================================
2089 // Helper to build synthetic multi-echo data on a small 3D grid
2090 // =========================================================================
2091
2092 /// Build synthetic multi-echo phase/magnitude data.
2093 ///
2094 /// The phase at each voxel is: phase_offset + slope * TE
2095 /// where `slope` is a spatially-varying linear ramp along x.
2096 /// TEs are in seconds. Magnitude is uniform (1.0) inside the mask.
2097 fn make_synthetic_multi_echo(
2098 nx: usize, ny: usize, nz: usize,
2099 tes: &[f64],
2100 ) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<u8>) {
2101 let n = nx * ny * nz;
2102 let n_echoes = tes.len();
2103
2104 // Constant phase offset (small, well within [-pi, pi])
2105 let phase_offset_val = 0.3;
2106 // Slope (rad/s) as a function of x: gentle ramp so phases stay in [-pi, pi]
2107 // Max slope = 50 rad/s at x=nx-1, so max phase ~ 0.3 + 50*7*0.015 = 5.55
2108 // which will wrap but that is fine.
2109 let slope_scale = 50.0;
2110
2111 let mask = vec![1u8; n];
2112 let mut phases: Vec<Vec<f64>> = Vec::with_capacity(n_echoes);
2113 let mut mags: Vec<Vec<f64>> = Vec::with_capacity(n_echoes);
2114
2115 for e in 0..n_echoes {
2116 let mut p = vec![0.0; n];
2117 let m = vec![1.0; n]; // uniform magnitude
2118 for k in 0..nz {
2119 for j in 0..ny {
2120 for i in 0..nx {
2121 let idx = idx3d(i, j, k, nx, ny);
2122 let slope = slope_scale * i as f64;
2123 p[idx] = wrap_to_pi(phase_offset_val + slope * tes[e]);
2124 }
2125 }
2126 }
2127 phases.push(p);
2128 mags.push(m);
2129 }
2130
2131 (phases, mags, mask)
2132 }
2133
2134 // =========================================================================
2135 // idx3d
2136 // =========================================================================
2137
2138 #[test]
2139 fn test_idx3d_basic() {
2140 assert_eq!(idx3d(0, 0, 0, 4, 4), 0);
2141 assert_eq!(idx3d(1, 0, 0, 4, 4), 1);
2142 assert_eq!(idx3d(0, 1, 0, 4, 4), 4);
2143 assert_eq!(idx3d(0, 0, 1, 4, 4), 16);
2144 assert_eq!(idx3d(3, 3, 3, 4, 4), 63);
2145 }
2146
2147 // =========================================================================
2148 // B0WeightType::from_str
2149 // =========================================================================
2150
2151 #[test]
2152 fn test_b0_weight_type_from_str() {
2153 assert_eq!(B0WeightType::from_str("phase_snr"), B0WeightType::PhaseSNR);
2154 assert_eq!(B0WeightType::from_str("phasesnr"), B0WeightType::PhaseSNR);
2155 assert_eq!(B0WeightType::from_str("PhaseSNR"), B0WeightType::PhaseSNR);
2156 assert_eq!(B0WeightType::from_str("phase_var"), B0WeightType::PhaseVar);
2157 assert_eq!(B0WeightType::from_str("phasevar"), B0WeightType::PhaseVar);
2158 assert_eq!(B0WeightType::from_str("average"), B0WeightType::Average);
2159 assert_eq!(B0WeightType::from_str("uniform"), B0WeightType::Average);
2160 assert_eq!(B0WeightType::from_str("tes"), B0WeightType::TEs);
2161 assert_eq!(B0WeightType::from_str("te"), B0WeightType::TEs);
2162 assert_eq!(B0WeightType::from_str("mag"), B0WeightType::Mag);
2163 assert_eq!(B0WeightType::from_str("magnitude"), B0WeightType::Mag);
2164 assert_eq!(B0WeightType::from_str("assumed-decay"), B0WeightType::AssumedDecay { t2star_s: 0.040 });
2165 assert_eq!(B0WeightType::from_str("Assumed-Decay"), B0WeightType::assumed_decay_default());
2166 // No "t2star" alias: the weighting does not use a T2* map.
2167 assert_eq!(B0WeightType::from_str("t2star"), B0WeightType::PhaseSNR);
2168 // Unknown string should default to PhaseSNR
2169 assert_eq!(B0WeightType::from_str("unknown"), B0WeightType::PhaseSNR);
2170 }
2171
2172 // =========================================================================
2173 // hermitian_inner_product (additional tests)
2174 // =========================================================================
2175
2176 #[test]
2177 fn test_hip_with_mask() {
2178 let n = 4;
2179 let phase1 = vec![0.5; n];
2180 let phase2 = vec![1.0; n];
2181 let mag1 = vec![2.0; n];
2182 let mag2 = vec![3.0; n];
2183 let mask = vec![1, 0, 1, 0];
2184
2185 let (hip_phase, hip_mag) = hermitian_inner_product(
2186 &phase1, &mag1, &phase2, &mag2, &mask, n
2187 );
2188
2189 // Masked-in voxels
2190 assert!((hip_phase[0] - 0.5).abs() < 1e-10);
2191 assert!((hip_mag[0] - 6.0).abs() < 1e-10);
2192 assert!((hip_phase[2] - 0.5).abs() < 1e-10);
2193 assert!((hip_mag[2] - 6.0).abs() < 1e-10);
2194
2195 // Masked-out voxels
2196 assert_eq!(hip_phase[1], 0.0);
2197 assert_eq!(hip_mag[1], 0.0);
2198 assert_eq!(hip_phase[3], 0.0);
2199 assert_eq!(hip_mag[3], 0.0);
2200 }
2201
2202 #[test]
2203 fn test_hip_wrapping() {
2204 // Test that phase difference wraps correctly
2205 let n = 1;
2206 let phase1 = vec![PI - 0.1];
2207 let phase2 = vec![-PI + 0.1];
2208 let mag1 = vec![1.0];
2209 let mag2 = vec![1.0];
2210 let mask = vec![1u8];
2211
2212 let (hip_phase, _) = hermitian_inner_product(
2213 &phase1, &mag1, &phase2, &mag2, &mask, n
2214 );
2215
2216 // phase2 - phase1 = (-PI + 0.1) - (PI - 0.1) = -2PI + 0.2 -> wraps to 0.2
2217 assert!((hip_phase[0] - 0.2).abs() < 1e-10,
2218 "HIP should wrap phase difference, got {}", hip_phase[0]);
2219 }
2220
2221 // =========================================================================
2222 // find_seed_point (now in romeo.rs, tested here for coverage)
2223 // =========================================================================
2224
2225 #[test]
2226 fn test_find_seed_point_full_mask() {
2227 use crate::unwrap::romeo::find_seed_point;
2228 let (nx, ny, nz) = (8, 8, 8);
2229 let mask = vec![1u8; nx * ny * nz];
2230 let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
2231 // Center of mass of a fully-filled cube should be approximately center
2232 assert_eq!(si, 3); // mean of 0..7 = 3.5, integer division = 3
2233 assert_eq!(sj, 3);
2234 assert_eq!(sk, 3);
2235 }
2236
2237 #[test]
2238 fn test_find_seed_point_empty_mask() {
2239 use crate::unwrap::romeo::find_seed_point;
2240 let (nx, ny, nz) = (8, 8, 8);
2241 let mask = vec![0u8; nx * ny * nz];
2242 let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
2243 // Fallback: center of volume
2244 assert_eq!(si, 4);
2245 assert_eq!(sj, 4);
2246 assert_eq!(sk, 4);
2247 }
2248
2249 #[test]
2250 fn test_find_seed_point_corner_mask() {
2251 use crate::unwrap::romeo::find_seed_point;
2252 let (nx, ny, nz) = (8, 8, 8);
2253 let mut mask = vec![0u8; nx * ny * nz];
2254 // Only set voxel (0,0,0)
2255 mask[idx3d(0, 0, 0, nx, ny)] = 1;
2256 let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
2257 assert_eq!(si, 0);
2258 assert_eq!(sj, 0);
2259 assert_eq!(sk, 0);
2260 }
2261
2262 // =========================================================================
2263 // field_to_hz
2264 // =========================================================================
2265
2266 #[test]
2267 fn test_field_to_hz() {
2268 let field = vec![TWO_PI, -TWO_PI, 0.0, PI];
2269 let hz = field_to_hz(&field);
2270 assert!((hz[0] - 1.0).abs() < 1e-10);
2271 assert!((hz[1] - (-1.0)).abs() < 1e-10);
2272 assert!((hz[2] - 0.0).abs() < 1e-10);
2273 assert!((hz[3] - 0.5).abs() < 1e-10);
2274 }
2275
2276 // =========================================================================
2277 // calculate_b0_weighted
2278 // =========================================================================
2279
2280 #[test]
2281 fn test_calculate_b0_weighted_phase_snr() {
2282 // For a constant slope (rad/s), all weight types should recover it.
2283 // phase[e] = slope * TE[e], so phase/TE = slope for each echo.
2284 // Weighted average of identical values = same value.
2285 // B0 = (1 / 2pi) * slope (Hz)
2286 let n = 64;
2287 let tes = [0.005, 0.010, 0.015]; // seconds
2288 let slope = 200.0; // rad/s
2289 let mask = vec![1u8; n];
2290
2291 let phases: Vec<Vec<f64>> = tes.iter()
2292 .map(|&te| vec![slope * te; n])
2293 .collect();
2294 let mags: Vec<Vec<f64>> = tes.iter()
2295 .map(|_| vec![1.0; n])
2296 .collect();
2297
2298 let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
2299
2300 let expected_hz = 1.0 / TWO_PI * slope;
2301 assert_eq!(b0.len(), n);
2302 for v in &b0 {
2303 assert!(v.is_finite());
2304 assert!((v - expected_hz).abs() < 1e-8,
2305 "expected {} Hz, got {}", expected_hz, v);
2306 }
2307 }
2308
2309 #[test]
2310 fn test_calculate_b0_weighted_all_weight_types() {
2311 let n = 16;
2312 let tes = [0.005, 0.010, 0.015]; // seconds
2313 let slope = 100.0; // rad/s
2314 let mask = vec![1u8; n];
2315
2316 let phases: Vec<Vec<f64>> = tes.iter()
2317 .map(|&te| vec![slope * te; n])
2318 .collect();
2319 let mags: Vec<Vec<f64>> = tes.iter()
2320 .map(|_| vec![2.0; n])
2321 .collect();
2322
2323 let expected_hz = 1.0 / TWO_PI * slope;
2324
2325 for wt in &[
2326 B0WeightType::PhaseSNR,
2327 B0WeightType::PhaseVar,
2328 B0WeightType::Average,
2329 B0WeightType::TEs,
2330 B0WeightType::Mag,
2331 ] {
2332 let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, *wt, &grid(n, 1, 1));
2333 assert_eq!(b0.len(), n);
2334 for v in &b0 {
2335 assert!(v.is_finite(), "weight type {:?} produced non-finite", wt);
2336 assert!((v - expected_hz).abs() < 1e-8,
2337 "weight type {:?}: expected {} Hz, got {}", wt, expected_hz, v);
2338 }
2339 }
2340 }
2341
2342 #[test]
2343 fn test_calculate_b0_weighted_masked_out() {
2344 let n = 8;
2345 let tes = [0.005, 0.010]; // seconds
2346 let mask = vec![0u8; n]; // all masked out
2347
2348 let phases: Vec<Vec<f64>> = tes.iter()
2349 .map(|&te| vec![500.0 * te; n])
2350 .collect();
2351 let mags: Vec<Vec<f64>> = tes.iter()
2352 .map(|_| vec![1.0; n])
2353 .collect();
2354
2355 let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
2356
2357 for v in &b0 {
2358 assert_eq!(*v, 0.0, "masked-out voxels should have B0=0");
2359 }
2360 }
2361
2362 #[test]
2363 fn test_calculate_b0_weighted_zero_magnitude() {
2364 // When magnitude is zero, weight is zero; result should be 0
2365 let n = 4;
2366 let tes = [0.005, 0.010, 0.015];
2367 let mask = vec![1u8; n];
2368
2369 let phases: Vec<Vec<f64>> = tes.iter()
2370 .map(|&te| vec![0.2 * te; n])
2371 .collect();
2372 let mags: Vec<Vec<f64>> = tes.iter()
2373 .map(|_| vec![0.0; n]) // zero magnitude
2374 .collect();
2375
2376 // PhaseSNR weight = mag * te = 0
2377 let b0 = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
2378 for v in &b0 {
2379 assert_eq!(*v, 0.0, "zero-magnitude voxels should yield B0=0");
2380 }
2381
2382 // Average weight = 1.0, should still work
2383 let b0_avg = calculate_b0_weighted(&phases, &mags, &tes, &mask, B0WeightType::Average, &grid(n, 1, 1));
2384 let expected = 1.0 / TWO_PI * 0.2;
2385 for v in &b0_avg {
2386 assert!((v - expected).abs() < 1e-8);
2387 }
2388 }
2389
2390 // =========================================================================
2391 // multi_echo_linear_fit
2392 // =========================================================================
2393
2394 #[test]
2395 fn test_multi_echo_linear_fit_no_offset() {
2396 // phase = slope * TE (no intercept)
2397 // Should recover the slope exactly.
2398 let n = 32;
2399 let tes = [0.005, 0.010, 0.015]; // in seconds
2400 let slope = 100.0; // rad/s
2401 let mask = vec![1u8; n];
2402
2403 let phases: Vec<Vec<f64>> = tes.iter()
2404 .map(|&te| vec![slope * te; n])
2405 .collect();
2406 let mags: Vec<Vec<f64>> = tes.iter()
2407 .map(|_| vec![1.0; n])
2408 .collect();
2409
2410 let result = multi_echo_linear_fit(
2411 &phases, &mags, &tes, &mask,
2412 false, // no offset estimation
2413 );
2414
2415 assert_eq!(result.field.len(), n);
2416 assert_eq!(result.phase_offset.len(), n);
2417 assert_eq!(result.fit_residual.len(), n);
2418
2419 for i in 0..n {
2420 assert!((result.field[i] - slope).abs() < 1e-6,
2421 "slope: expected {}, got {}", slope, result.field[i]);
2422 assert_eq!(result.phase_offset[i], 0.0,
2423 "offset should be 0 when estimate_offset=false");
2424 assert!(result.fit_residual[i] < 1e-10,
2425 "residual should be ~0 for perfect linear data");
2426 }
2427 }
2428
2429 #[test]
2430 fn test_multi_echo_linear_fit_with_offset() {
2431 // phase = intercept + slope * TE
2432 let n = 16;
2433 let tes = [0.005, 0.010, 0.015, 0.020];
2434 let slope = 200.0; // rad/s
2435 let intercept = 0.5; // rad
2436 let mask = vec![1u8; n];
2437
2438 let phases: Vec<Vec<f64>> = tes.iter()
2439 .map(|&te| vec![intercept + slope * te; n])
2440 .collect();
2441 let mags: Vec<Vec<f64>> = tes.iter()
2442 .map(|_| vec![1.0; n])
2443 .collect();
2444
2445 let result = multi_echo_linear_fit(
2446 &phases, &mags, &tes, &mask,
2447 true, // estimate offset
2448 );
2449
2450 for i in 0..n {
2451 assert!((result.field[i] - slope).abs() < 1e-4,
2452 "slope: expected {}, got {}", slope, result.field[i]);
2453 assert!((result.phase_offset[i] - intercept).abs() < 1e-4,
2454 "intercept: expected {}, got {}", intercept, result.phase_offset[i]);
2455 assert!(result.fit_residual[i] < 1e-8,
2456 "residual should be ~0 for perfect linear data, got {}", result.fit_residual[i]);
2457 }
2458 }
2459
2460 #[test]
2461 fn test_multi_echo_linear_fit_masked_out() {
2462 let n = 8;
2463 let tes = [0.005, 0.010, 0.015];
2464 let mask = vec![0u8; n];
2465
2466 let phases: Vec<Vec<f64>> = tes.iter()
2467 .map(|&te| vec![100.0 * te; n])
2468 .collect();
2469 let mags: Vec<Vec<f64>> = tes.iter()
2470 .map(|_| vec![1.0; n])
2471 .collect();
2472
2473 let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true);
2474
2475 for i in 0..n {
2476 assert_eq!(result.field[i], 0.0);
2477 assert_eq!(result.phase_offset[i], 0.0);
2478 assert_eq!(result.fit_residual[i], 0.0);
2479 }
2480 }
2481
2482 #[test]
2483 fn test_multi_echo_linear_fit_varying_slope() {
2484 // Each voxel has a different slope
2485 let n = 8;
2486 let tes = [0.005, 0.010, 0.015];
2487 let mask = vec![1u8; n];
2488
2489 let slopes: Vec<f64> = (0..n).map(|i| 50.0 * (i as f64 + 1.0)).collect();
2490
2491 let phases: Vec<Vec<f64>> = tes.iter()
2492 .map(|&te| {
2493 slopes.iter().map(|&s| s * te).collect()
2494 })
2495 .collect();
2496 let mags: Vec<Vec<f64>> = tes.iter()
2497 .map(|_| vec![1.0; n])
2498 .collect();
2499
2500 let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false);
2501
2502 for i in 0..n {
2503 assert!((result.field[i] - slopes[i]).abs() < 1e-6,
2504 "voxel {}: expected slope {}, got {}", i, slopes[i], result.field[i]);
2505 }
2506 }
2507
2508 #[test]
2509 fn test_multi_echo_linear_fit_residual_marks_noisy_voxels() {
2510 // Create data where some voxels have noisy fits
2511 let n = 100;
2512 let tes = [0.005, 0.010, 0.015];
2513 let mask = vec![1u8; n];
2514 let slope = 100.0;
2515
2516 let mut phases: Vec<Vec<f64>> = tes.iter()
2517 .map(|&te| vec![slope * te; n])
2518 .collect();
2519 let mags: Vec<Vec<f64>> = tes.iter()
2520 .map(|_| vec![1.0; n])
2521 .collect();
2522
2523 // Add large noise to last 10 voxels to increase their residuals
2524 for e in 0..tes.len() {
2525 for i in 90..100 {
2526 phases[e][i] += if e % 2 == 0 { 2.0 } else { -2.0 };
2527 }
2528 }
2529
2530 let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false);
2531
2532 // Clean voxels (0..90) fit exactly; the noisy ones (90..100) leave a residual.
2533 let max_clean_resid = result.fit_residual[0..90].iter()
2534 .cloned().fold(0.0f64, f64::max);
2535 let min_noisy_resid = result.fit_residual[90..100].iter()
2536 .cloned().fold(f64::INFINITY, f64::min);
2537 assert!(max_clean_resid < min_noisy_resid,
2538 "clean residuals ({}) should be less than noisy residuals ({})",
2539 max_clean_resid, min_noisy_resid);
2540
2541 }
2542
2543 #[test]
2544 fn test_multi_echo_linear_fit_zero_magnitude() {
2545 let n = 4;
2546 let tes = [0.005, 0.010, 0.015];
2547 let mask = vec![1u8; n];
2548
2549 let phases: Vec<Vec<f64>> = tes.iter()
2550 .map(|&te| vec![100.0 * te; n])
2551 .collect();
2552 let mags: Vec<Vec<f64>> = tes.iter()
2553 .map(|_| vec![0.0; n]) // zero magnitude
2554 .collect();
2555
2556 // Should not crash; field should be 0 because sum_w_te_sq ~ 0
2557 let result = multi_echo_linear_fit(&phases, &mags, &tes, &mask, false);
2558 for v in &result.field {
2559 assert!(v.is_finite());
2560 }
2561
2562 let result2 = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true);
2563 for v in &result2.field {
2564 assert!(v.is_finite());
2565 }
2566 }
2567
2568 // =========================================================================
2569 // mcpc3ds_single_coil
2570 // =========================================================================
2571
2572 /// `UnwrapMethod::Prelude` carries its settings into the HIP unwrap: a single partition
2573 /// labelled over the volume leaves a wrapping HIP as one region, so the offset it estimates
2574 /// differs from the defaults', which unwrap it.
2575 #[test]
2576 fn phase_offset_removal_runs_prelude_with_the_given_settings() {
2577 use crate::unwrap::{PreludeLabelling, PreludeParams};
2578 let (nx, ny, nz) = (16, 8, 8);
2579 let tes = [0.005, 0.010, 0.015];
2580 let (_, mags, _) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2581 let phases: Vec<Vec<f64>> = tes.iter()
2582 .map(|&te| (0..nx * ny * nz).map(|i| { let t = 300.0 * ((i % nx) as f64 - 7.5) * te; t.sin().atan2(t.cos()) }).collect())
2583 .collect();
2584 let run = |p: PreludeParams| {
2585 phase_offset_removal(&phases, &mags, &tes, [1.0; 3], [0, 1], UnwrapMethod::Prelude(p), &grid(nx, ny, nz)).offset
2586 };
2587 let one = PreludeParams { num_phase_partitions: 1, labelling: PreludeLabelling::Volume, correct_global: false };
2588 assert_eq!(run(PreludeParams::default()), run(PreludeParams::default()));
2589 assert_ne!(run(PreludeParams::default()), run(one));
2590 }
2591
2592 #[test]
2593 fn test_phase_offset_removal_output_sizes() {
2594 let (nx, ny, nz) = (8, 8, 8);
2595 let n = nx * ny * nz;
2596 let tes = [0.005, 0.010, 0.015];
2597 let (phases, mags, _mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2598
2599 let sigma = [1.0, 1.0, 1.0];
2600 let crate::utils::PhaseOffsetRemoval { phases: corrected, offset, .. } = phase_offset_removal(
2601 &phases, &mags, &tes, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2602 );
2603
2604 // Check sizes
2605 assert_eq!(corrected.len(), tes.len(), "should have one corrected phase per echo");
2606 for (e, cp) in corrected.iter().enumerate() {
2607 assert_eq!(cp.len(), n, "echo {} corrected phase should have {} voxels", e, n);
2608 }
2609 assert_eq!(offset.len(), n, "phase offset should have {} voxels", n);
2610 }
2611
2612 #[test]
2613 fn test_phase_offset_removal_finite_output() {
2614 let (nx, ny, nz) = (8, 8, 8);
2615 let tes = [0.005, 0.010, 0.015];
2616 let (phases, mags, _mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2617
2618 let sigma = [1.0, 1.0, 1.0];
2619 let crate::utils::PhaseOffsetRemoval { phases: corrected, offset, .. } = phase_offset_removal(
2620 &phases, &mags, &tes, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2621 );
2622
2623 for v in &offset {
2624 assert!(v.is_finite(), "phase offset should be finite");
2625 }
2626 for cp in &corrected {
2627 for v in cp {
2628 assert!(v.is_finite(), "corrected phase should be finite");
2629 }
2630 }
2631 }
2632
2633 #[test]
2634 fn test_phase_offset_removal_corrected_in_range() {
2635 let (nx, ny, nz) = (8, 8, 8);
2636 let tes = [0.005, 0.010, 0.015];
2637 let (phases, mags, _mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2638
2639 let sigma = [1.0, 1.0, 1.0];
2640 let crate::utils::PhaseOffsetRemoval { phases: corrected, .. } = phase_offset_removal(
2641 &phases, &mags, &tes, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2642 );
2643
2644 // Corrected phases should be in [-pi, pi] since wrap_to_pi is applied
2645 for cp in &corrected {
2646 for &v in cp {
2647 let (lo, hi) = (-PI - 1e-10, PI + 1e-10);
2648 assert!((lo..=hi).contains(&v),
2649 "corrected phase should be in [-pi, pi], got {}", v);
2650 }
2651 }
2652 }
2653
2654 #[test]
2655 fn test_phase_offset_removal_uniform_phase() {
2656 // Uniform phase across all echoes => offset should be approximately that phase
2657 let (nx, ny, nz) = (8, 8, 8);
2658 let n = nx * ny * nz;
2659 let tes = [0.005, 0.010, 0.015];
2660
2661 // All echoes have constant phase 0.5 (no TE dependence)
2662 let phases: Vec<Vec<f64>> = (0..3).map(|_| vec![0.5; n]).collect();
2663 let mags: Vec<Vec<f64>> = (0..3).map(|_| vec![1.0; n]).collect();
2664
2665 let sigma = [1.0, 1.0, 1.0];
2666 let crate::utils::PhaseOffsetRemoval { phases: corrected, offset: _offset, .. } = phase_offset_removal(
2667 &phases, &mags, &tes, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2668 );
2669
2670 // After removing offset, corrected phases should be close to 0
2671 for cp in &corrected {
2672 for &v in cp {
2673 assert!(v.abs() < 1.0,
2674 "after offset removal of uniform phase, corrected should be ~0, got {}", v);
2675 }
2676 }
2677 }
2678
2679 // =========================================================================
2680 // Composed field mapping pipeline (offset removal → unwrap → B0)
2681 // =========================================================================
2682
2683 #[test]
2684 fn test_field_mapping_composed() {
2685 use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
2686 let (nx, ny, nz) = (8, 8, 8);
2687 let n = nx * ny * nz;
2688 let tes = [0.005, 0.010, 0.015];
2689 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2690 let sigma = [1.0, 1.0, 1.0];
2691
2692 // Step 1: Phase offset removal
2693 let crate::utils::PhaseOffsetRemoval { phases: corrected, offset, .. } = phase_offset_removal(
2694 &phases, &mags, &tes, sigma, [0, 1],
2695 UnwrapMethod::Romeo, &grid(nx, ny, nz),
2696 );
2697 assert_eq!(corrected.len(), tes.len());
2698 assert_eq!(offset.len(), n);
2699
2700 // Step 2: Multi-echo unwrapping
2701 let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
2702 let unwrapped = unwrap_romeo_multi_echo(
2703 &corrected, &mag_refs, &tes, &mask,
2704 &RomeoParams::default(), &grid(nx, ny, nz),
2705 );
2706 assert_eq!(unwrapped.len(), tes.len());
2707
2708 // Step 3: Weighted B0
2709 let b0 = calculate_b0_weighted(
2710 &unwrapped, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(nx, ny, nz),
2711 );
2712 assert_eq!(b0.len(), n);
2713 for v in &b0 {
2714 assert!(v.is_finite(), "B0 should be finite");
2715 }
2716 }
2717
2718 #[test]
2719 fn test_field_mapping_different_weight_types() {
2720 use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
2721 let (nx, ny, nz) = (8, 8, 8);
2722 let n = nx * ny * nz;
2723 let tes = [0.005, 0.010, 0.015];
2724 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2725 let sigma = [1.0, 1.0, 1.0];
2726
2727 let crate::utils::PhaseOffsetRemoval { phases: corrected, .. } = phase_offset_removal(
2728 &phases, &mags, &tes, sigma, [0, 1],
2729 UnwrapMethod::Romeo, &grid(nx, ny, nz),
2730 );
2731 let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
2732 let unwrapped = unwrap_romeo_multi_echo(
2733 &corrected, &mag_refs, &tes, &mask,
2734 &RomeoParams::default(), &grid(nx, ny, nz),
2735 );
2736
2737 for wt in &[
2738 B0WeightType::PhaseSNR,
2739 B0WeightType::Average,
2740 B0WeightType::TEs,
2741 B0WeightType::Mag,
2742 B0WeightType::PhaseVar,
2743 ] {
2744 let b0 = calculate_b0_weighted(&unwrapped, &mags, &tes, &mask, *wt, &grid(n, 1, 1));
2745 for v in &b0 {
2746 assert!(v.is_finite(), "B0 with {:?} should be finite", wt);
2747 }
2748 }
2749 }
2750
2751 // =========================================================================
2752 // wrap_to_pi edge cases
2753 // =========================================================================
2754
2755 #[test]
2756 fn test_wrap_to_pi_near_boundaries() {
2757 // Values just beyond PI and -PI
2758 let v1 = wrap_to_pi(PI + 0.001);
2759 assert!(v1 < PI && v1 > -PI, "should wrap back into range");
2760
2761 let v2 = wrap_to_pi(-PI - 0.001);
2762 assert!(v2 > -PI && v2 < PI, "should wrap back into range");
2763
2764 // Large positive and negative values
2765 let v3 = wrap_to_pi(100.0 * PI);
2766 assert!((-PI..=PI).contains(&v3), "should be in [-pi, pi], got {}", v3);
2767
2768 let v4 = wrap_to_pi(-100.0 * PI);
2769 assert!((-PI..=PI).contains(&v4), "should be in [-pi, pi], got {}", v4);
2770 }
2771
2772 // =========================================================================
2773 // unwrap_romeo (tested indirectly through phase_offset_removal
2774 // but let's also test directly via the public API)
2775 // =========================================================================
2776
2777 #[test]
2778 fn test_unwrap_romeo_smooth_data() {
2779 use crate::unwrap::romeo::{unwrap_romeo, RomeoParams};
2780 let (nx, ny, nz) = (8, 8, 8);
2781 let n = nx * ny * nz;
2782 // Smooth phase that doesn't need unwrapping
2783 let phase: Vec<f64> = (0..n).map(|i| {
2784 let x = (i % nx) as f64 / nx as f64;
2785 0.5 * x // small smooth phase
2786 }).collect();
2787 let mag = vec![1.0; n];
2788 let mask = vec![1u8; n];
2789
2790 let unwrapped = unwrap_romeo(
2791 &phase, &mag, None, 0.0, 0.0,
2792 &mask, &RomeoParams::default(), &grid(nx, ny, nz),
2793 );
2794
2795 assert_eq!(unwrapped.len(), n);
2796 for (i, &v) in unwrapped.iter().enumerate() {
2797 assert!(v.is_finite(), "unwrapped voxel {} should be finite", i);
2798 }
2799 }
2800
2801 // =========================================================================
2802 // Integration: linear fit on mcpc3ds output
2803 // =========================================================================
2804
2805 #[test]
2806 fn test_linear_fit_on_mcpc3ds_output() {
2807 let (nx, ny, nz) = (8, 8, 8);
2808 let n = nx * ny * nz;
2809 let tes = [0.005, 0.010, 0.015];
2810 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2811
2812 let sigma = [1.0, 1.0, 1.0];
2813 let crate::utils::PhaseOffsetRemoval { phases: corrected, offset: _offset, .. } = phase_offset_removal(
2814 &phases, &mags, &tes, sigma, [0, 1], UnwrapMethod::Romeo, &grid(nx, ny, nz),
2815 );
2816
2817 // Run linear fit on corrected phases. `tes` is already in seconds; the
2818 // /1000 that used to be here was a leftover from the millisecond era and
2819 // fed the fit 5-15 microsecond echo times.
2820 let result = multi_echo_linear_fit(
2821 &corrected, &mags, &tes, &mask, true,
2822 );
2823
2824 assert_eq!(result.field.len(), n);
2825 assert_eq!(result.phase_offset.len(), n);
2826 assert_eq!(result.fit_residual.len(), n);
2827
2828 for v in &result.field {
2829 assert!(v.is_finite(), "field should be finite");
2830 }
2831 for v in &result.phase_offset {
2832 assert!(v.is_finite(), "phase_offset should be finite");
2833 }
2834 }
2835
2836 // =========================================================================
2837 // bipolar_correction
2838 // =========================================================================
2839
2840 #[test]
2841 fn test_bipolar_correction_3_echoes() {
2842 let (nx, ny, nz) = (8, 8, 8);
2843 let tes = [0.005, 0.010, 0.015];
2844 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2845
2846 let mut phases_mut = phases;
2847 let sigma = [1.0, 1.0, 1.0];
2848 bipolar_correction(
2849 &mut phases_mut, &mags, &tes, &mask,
2850 sigma, &grid(nx, ny, nz),
2851 );
2852
2853 // All values should remain finite
2854 for echo in &phases_mut {
2855 for &v in echo {
2856 assert!(v.is_finite(), "bipolar-corrected phase should be finite");
2857 }
2858 }
2859 }
2860
2861 #[test]
2862 fn test_bipolar_correction_2_echoes_noop() {
2863 // With only 2 echoes, bipolar correction should be a no-op
2864 let (nx, ny, nz) = (8, 8, 8);
2865 let tes = [0.005, 0.010];
2866 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2867
2868 let original: Vec<Vec<f64>> = phases.clone();
2869 let mut phases_mut = phases;
2870 bipolar_correction(
2871 &mut phases_mut, &mags, &tes, &mask,
2872 [1.0, 1.0, 1.0], &grid(nx, ny, nz),
2873 );
2874
2875 // Should be unchanged (2 echoes = noop)
2876 for (e, echo) in phases_mut.iter().enumerate() {
2877 for (i, &v) in echo.iter().enumerate() {
2878 assert_eq!(v, original[e][i],
2879 "2-echo bipolar correction should be no-op");
2880 }
2881 }
2882 }
2883
2884 #[test]
2885 fn test_field_mapping_with_bipolar() {
2886 use crate::unwrap::romeo::{unwrap_romeo_multi_echo, RomeoParams};
2887 let (nx, ny, nz) = (8, 8, 8);
2888 let n = nx * ny * nz;
2889 let tes = [0.005, 0.010, 0.015];
2890 let (phases, mags, mask) = make_synthetic_multi_echo(nx, ny, nz, &tes);
2891 let sigma = [1.0, 1.0, 1.0];
2892
2893 let crate::utils::PhaseOffsetRemoval { phases: mut corrected, .. } = phase_offset_removal(
2894 &phases, &mags, &tes, sigma, [0, 1],
2895 UnwrapMethod::Romeo, &grid(nx, ny, nz),
2896 );
2897 let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
2898 bipolar_correction(&mut corrected, &mag_refs, &tes, &mask, sigma, &grid(nx, ny, nz));
2899 let unwrapped = unwrap_romeo_multi_echo(
2900 &corrected, &mag_refs, &tes, &mask,
2901 &RomeoParams::default(), &grid(nx, ny, nz),
2902 );
2903 let b0 = calculate_b0_weighted(&unwrapped, &mags, &tes, &mask, B0WeightType::PhaseSNR, &grid(n, 1, 1));
2904
2905 assert_eq!(b0.len(), n);
2906 for v in &b0 {
2907 assert!(v.is_finite(), "B0 with bipolar correction should be finite");
2908 }
2909 }
2910
2911 // --- mcpc3ds_combine ---
2912 /// `(phases, magnitudes, echo_times)`, each coil a `Vec` of per-echo volumes.
2913 type SyntheticCoils = (Vec<Vec<Vec<f64>>>, Vec<Vec<Vec<f64>>>, Vec<f64>);
2914
2915 /// A per-coil phase offset as a function of voxel coordinates.
2916 type OffsetFn = Box<dyn Fn(usize, usize, usize) -> f64>;
2917
2918
2919 /// Two coils with distinct smooth phase offsets over a linear field: the combined phase
2920 /// must be the pure field evolution 2π·f·TE_e, offsets gone.
2921 fn synthetic_coils(nx: usize, ny: usize, nz: usize, tes: &[f64]) -> SyntheticCoils {
2922 let n = nx * ny * nz;
2923 let offsets: Vec<OffsetFn> = vec![
2924 Box::new(|x, _, _| 0.3 + 0.01 * x as f64),
2925 Box::new(|_, y, _| -1.0 + 0.02 * y as f64),
2926 ];
2927 let mut field = vec![0.0; n]; // Hz
2928 let mut phases = Vec::new();
2929 let mut mags = Vec::new();
2930 for (c, po) in offsets.iter().enumerate() {
2931 let mut cp = Vec::new();
2932 let mut cm = Vec::new();
2933 for &te in tes {
2934 let mut p = vec![0.0; n];
2935 let mut m = vec![0.0; n];
2936 for z in 0..nz { for y in 0..ny { for x in 0..nx {
2937 let i = x + y * nx + z * nx * ny;
2938 let f = 2.0 + 1.5 * (x as f64 / nx as f64) - 1.0 * (z as f64 / nz as f64);
2939 field[i] = f;
2940 p[i] = wrap_to_pi(2.0 * PI * f * te + po(x, y, z));
2941 m[i] = 100.0 * (1.0 + 0.3 * (if c == 0 { x as f64 / nx as f64 } else { y as f64 / ny as f64 }));
2942 }}}
2943 cp.push(p);
2944 cm.push(m);
2945 }
2946 phases.push(cp);
2947 mags.push(cm);
2948 }
2949 (phases, mags, field)
2950 }
2951
2952 /// A single-echo uncombined acquisition, and other unusable inputs, are errors rather
2953 /// than panics (QSMxT #273).
2954 #[test]
2955 fn mcpc3ds_combine_refuses_input_it_cannot_combine() {
2956 let (nx, ny, nz) = (6, 6, 4);
2957 let g = grid(nx, ny, nz);
2958 let tes = [0.005, 0.010];
2959 let (phases, mags, _) = synthetic_coils(nx, ny, nz, &tes);
2960 let run = |p: &[Vec<Vec<f64>>], m: &[Vec<Vec<f64>>], tes: &[f64], echoes: [usize; 2]| {
2961 mcpc3ds_combine(p, m, tes, [2.0, 2.0, 2.0], echoes, UnwrapMethod::Romeo, &g)
2962 .map(|_| ())
2963 .map_err(|e| e.to_string())
2964 };
2965 assert!(run(&phases, &mags, &tes, [0, 1]).is_ok());
2966 let one_echo: Vec<Vec<Vec<f64>>> = phases.iter().map(|c| c[..1].to_vec()).collect();
2967 let one_echo_m: Vec<Vec<Vec<f64>>> = mags.iter().map(|c| c[..1].to_vec()).collect();
2968 let err = run(&one_echo, &one_echo_m, &tes[..1], [0, 1]).unwrap_err();
2969 assert!(err.contains("at least two echoes"), "{err}");
2970 assert!(run(&[], &[], &tes, [0, 1]).unwrap_err().contains("no coils"));
2971 assert!(run(&phases, &mags[..1], &tes, [0, 1]).is_err());
2972 assert!(run(&phases, &mags, &tes[..1], [0, 1]).is_err());
2973 assert!(run(&phases, &mags, &tes, [0, 0]).is_err());
2974 assert!(run(&phases, &mags, &tes, [0, 2]).is_err());
2975 assert!(run(&phases, &mags, &[0.005, 0.005], [0, 1]).is_err());
2976 let mut short = phases.clone();
2977 short[1][0].pop();
2978 assert!(run(&short, &mags, &tes, [0, 1]).unwrap_err().contains("coil 1 echo 0 phase"));
2979 }
2980
2981 #[test]
2982 fn test_mcpc3ds_combine_removes_coil_offsets() {
2983 let (nx, ny, nz) = (20, 20, 10);
2984 let tes = [0.005, 0.010];
2985 let g = grid(nx, ny, nz);
2986 let (phases, mags, field) = synthetic_coils(nx, ny, nz, &tes);
2987 let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Romeo, &g).unwrap();
2988 assert_eq!(res.phases.len(), 2);
2989 assert_eq!(res.magnitudes.len(), 2);
2990 let n_mask = res.mask.iter().filter(|&&m| m > 0).count();
2991 assert!(n_mask > n_mask_min(nx, ny, nz), "robust mask should cover the object: {}", n_mask);
2992 // Interior voxels (away from the smoothing boundary): combined phase == field evolution.
2993 let mut checked = 0;
2994 for z in 3..nz - 3 { for y in 4..ny - 4 { for x in 4..nx - 4 {
2995 let i = x + y * nx + z * nx * ny;
2996 if res.mask[i] == 0 { continue; }
2997 for (e, &te) in tes.iter().enumerate() {
2998 let expected = wrap_to_pi(2.0 * PI * field[i] * te);
2999 let err = wrap_to_pi(res.phases[e][i] - expected).abs();
3000 assert!(err < 0.05, "echo {} voxel ({},{},{}): got {:.4}, expected {:.4}", e, x, y, z, res.phases[e][i], expected);
3001 }
3002 checked += 1;
3003 }}}
3004 assert!(checked > 100, "too few interior voxels checked: {}", checked);
3005 // Magnitude: sqrt(|Σ m_c² e^{iθ}|) with aligned phases = sqrt(m0² + m1²) ≥ each coil.
3006 for i in 0..nx * ny * nz {
3007 let m0 = mags[0][0][i];
3008 let m1 = mags[1][0][i];
3009 let expected = (m0 * m0 + m1 * m1).sqrt();
3010 assert!((res.magnitudes[0][i] - expected).abs() / expected < 0.02, "magnitude at {}: {} vs {}", i, res.magnitudes[0][i], expected);
3011 }
3012 }
3013
3014 fn n_mask_min(nx: usize, ny: usize, nz: usize) -> usize { nx * ny * nz / 2 }
3015
3016 #[test]
3017 fn test_mcpc3ds_combine_single_coil_removes_offset_keeps_magnitude() {
3018 let (nx, ny, nz) = (24, 24, 12);
3019 let tes = [0.005, 0.010];
3020 let g = grid(nx, ny, nz);
3021 let (mut phases, mut mags, field) = synthetic_coils(nx, ny, nz, &tes);
3022 phases.truncate(1);
3023 mags.truncate(1);
3024 let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Romeo, &g).unwrap();
3025 let n = nx * ny * nz;
3026 for e in 0..2 {
3027 for i in 0..n {
3028 // one coil: |S| is untouched, and the inter-echo phase difference is preserved
3029 // exactly (a TE-independent offset cancels in it)
3030 assert!((res.magnitudes[e][i] - mags[0][e][i]).abs() < 1e-9);
3031 }
3032 }
3033 for i in 0..n {
3034 let d_in = wrap_to_pi(phases[0][1][i] - phases[0][0][i]);
3035 let d_out = wrap_to_pi(res.phases[1][i] - res.phases[0][i]);
3036 assert!(wrap_to_pi(d_out - d_in).abs() < 1e-9, "voxel {}: {} vs {}", i, d_out, d_in);
3037 }
3038 // and the offset itself is gone: interior phase == field evolution
3039 let mut checked = 0;
3040 for z in 3..nz - 3 { for y in 5..ny - 5 { for x in 5..nx - 5 {
3041 let i = x + y * nx + z * nx * ny;
3042 if res.mask[i] == 0 { continue; }
3043 let err = wrap_to_pi(res.phases[0][i] - 2.0 * PI * field[i] * tes[0]).abs();
3044 assert!(err < 0.05, "voxel ({},{},{}): {}", x, y, z, err);
3045 checked += 1;
3046 }}}
3047 assert!(checked > 100);
3048 }
3049
3050 #[test]
3051 fn test_mcpc3ds_combine_identical_coils_scale_magnitude() {
3052 let (nx, ny, nz) = (12, 12, 6);
3053 let tes = [0.004, 0.009];
3054 let g = grid(nx, ny, nz);
3055 let (mut phases, mut mags, _) = synthetic_coils(nx, ny, nz, &tes);
3056 phases.truncate(1);
3057 mags.truncate(1);
3058 let (p, m) = (phases[0].clone(), mags[0].clone());
3059 phases.push(p);
3060 mags.push(m);
3061 let res = mcpc3ds_combine(&phases, &mags, &tes, [2.0, 2.0, 2.0], [0, 1], UnwrapMethod::Laplacian, &g).unwrap();
3062 for i in 0..nx * ny * nz {
3063 let expected = (2.0f64).sqrt() * mags[0][1][i];
3064 assert!((res.magnitudes[1][i] - expected).abs() < 1e-6);
3065 }
3066 }
3067
3068 // --- nan-box smoothing (MriResearchTools port) ---
3069
3070 #[test]
3071 fn test_gaussian_box_sizes_match_reference() {
3072 // values computed from getboxsizes(sigma, 4) in MriResearchTools.jl
3073 assert_eq!(gaussian_box_sizes(10.0, 4), vec![17, 17, 17, 19]);
3074 assert_eq!(gaussian_box_sizes(5.0, 4), vec![7, 9, 9, 9]);
3075 assert_eq!(gaussian_box_sizes(4.0, 4), vec![7, 7, 7, 7]);
3076 assert_eq!(gaussian_box_sizes(0.0, 4), vec![1, 1, 1, 1]);
3077 assert_eq!(gaussian_box_sizes(5.0, 3), vec![9, 9, 11]); // Julia round() ties to even: m = round(1.5) = 2
3078 assert_eq!(gaussian_box_sizes(2.0, 4), vec![3, 3, 3, 5]);
3079 }
3080
3081 #[test]
3082 fn test_nan_box_filter_line_constant_with_gap() {
3083 // constant segments stay constant, and the NaN gap is filled by extrapolation
3084 let mut line = vec![3.0; 30];
3085 for v in line.iter_mut().take(20).skip(12) { *v = f64::NAN; }
3086 let mut scratch = Vec::new();
3087 nan_box_filter_line(&mut line, 5, &mut scratch);
3088 for (i, v) in line.iter().enumerate() {
3089 if v.is_finite() {
3090 assert!((v - 3.0).abs() < 1e-12, "position {} = {}", i, v);
3091 }
3092 }
3093 // filled up to r=2 samples into the gap from the left
3094 assert!(line[12].is_finite() && line[13].is_finite(), "{:?}", &line[10..16]);
3095 assert!(line[16].is_nan());
3096 }
3097
3098 #[test]
3099 fn test_nan_box_filter_line_linear_ramp_interior() {
3100 let n = 40;
3101 let mut line: Vec<f64> = (0..n).map(|i| 0.5 * i as f64).collect();
3102 let mut scratch = Vec::new();
3103 nan_box_filter_line(&mut line, 7, &mut scratch);
3104 // box mean of a linear ramp is the ramp itself where the box is fully inside
3105 for i in 7..n - 3 {
3106 assert!((line[i] - 0.5 * i as f64).abs() < 1e-9, "position {} = {}", i, line[i]);
3107 }
3108 }
3109
3110 #[test]
3111 fn test_nan_box_smooth_3d_phase_constant_inside_mask() {
3112 let (nx, ny, nz) = (48, 48, 24);
3113 let g = grid(nx, ny, nz);
3114 let n = nx * ny * nz;
3115 let mut mask = vec![0u8; n];
3116 for z in 8..nz - 8 { for y in 16..ny - 16 { for x in 16..nx - 16 {
3117 mask[x + y * nx + z * nx * ny] = 1;
3118 }}}
3119 let phase = vec![1.2f64; n];
3120 let out = nan_box_smooth_3d_phase(&phase, [3.0, 3.0, 2.0], &mask, &g);
3121 let mut checked = 0;
3122 for i in 0..n {
3123 if mask[i] > 0 && out[i].is_finite() {
3124 assert!((out[i] - 1.2).abs() < 1e-9, "voxel {}: {}", i, out[i]);
3125 checked += 1;
3126 }
3127 }
3128 let n_mask = mask.iter().filter(|&&m| m > 0).count();
3129 assert_eq!(checked, n_mask, "every mask voxel must be defined and unchanged");
3130 // fills extend at most a few box radii past the mask: the corner stays undefined
3131 assert!(out[0].is_nan());
3132 let outside_defined = (0..n).filter(|&i| mask[i] == 0 && out[i].is_finite()).count();
3133 let outside = (0..n).filter(|&i| mask[i] == 0).count();
3134 assert!(outside_defined < outside / 2, "{} of {}", outside_defined, outside);
3135 }
3136 // ---------------------------------------------------------------- robust echo combination
3137 //
3138 // These were developed against a mutation sweep — perturb the implementation, check a test
3139 // fails — which lives in a scratchpad and not in the repository, so the two things it has to
3140 // guarantee are written down here for whoever rebuilds it. Both apply to any *scripted* edit
3141 // of this file, not only to a mutation harness: the first of them bit a one-off insertion
3142 // script two commits after it was documented here.
3143 //
3144 // 1. **Every needle must match exactly once.** Two of the call sites below differ only in
3145 // indentation, so one pattern is a substring of the other and a `replace(.., 1)` patches
3146 // the same site twice while reporting two. It cost me a sweep that said "caught" and then
3147 // "not caught" for the same mutation after an unrelated test got *stronger*, which is the
3148 // only reason it was noticed.
3149 // 2. **Every replacement must differ from its needle.** A no-op patches cleanly, passes the
3150 // uniqueness check, and is reported as a surviving mutation — a false alarm rather than
3151 // false reassurance, so the safe direction, but the same class of defect.
3152 //
3153 // Both audited over the 27 perturbations these tests were built against: 26 needles unique,
3154 // one since deleted along with the code it targeted, no no-ops. The point generalises past
3155 // this module — the instrument you check your instruments with has no instrument of its own,
3156 // and a pass is what you were hoping for, so nobody looks at it.
3157
3158 /// Deterministic multi-echo phantom: `phase = intercept + slope·TE` exactly, with
3159 /// magnitudes decaying over TE the way a real T2* does and varying over the volume.
3160 ///
3161 /// Phase noise is scaled as `1/magnitude`, because that is what it physically is — phase SNR
3162 /// is amplitude SNR — and because it is the assumption [`multi_echo_robust_fit`] standardises
3163 /// on. A phantom with constant-amplitude phase noise over a magnitude profile spanning 10x
3164 /// is not a harder test, it is a different model: it makes `residual × magnitude`
3165 /// heteroscedastic by construction, so a *clean* series looks like it has outlier echoes and
3166 /// the detector flags two of four.
3167 ///
3168 /// Where this phantom is *easier* than real data, and why that is left alone: the phase here
3169 /// is exactly linear in TE, which is precisely the model being fitted, so a clean series'
3170 /// residuals are pure noise. Real phase is not — there is genuine curvature from
3171 /// intra-voxel dephasing, flow and chemical shift — and the robust loss sees that mismatch
3172 /// as outliers, which is why the clean *phantom* downweights 6-24% of voxels where this
3173 /// downweights far fewer. The margin that buys is not large: injecting 0.1 rad of TE
3174 /// curvature here breaks three of these tests. The realistic case is covered by
3175 /// `test_robust_echo_combination` on the real phantom, and the clean-series numbers quoted
3176 /// throughout these docs are that phantom's, not this one's.
3177 ///
3178 /// Returns `(phases, mags, tes, true_slope, mask)`.
3179 #[allow(clippy::type_complexity)]
3180 fn echo_series(
3181 n: usize,
3182 noise: f64,
3183 ) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<f64>, Vec<f64>, Vec<u8>) {
3184 let tes: Vec<f64> = vec![0.004, 0.012, 0.020, 0.028];
3185 let mut seed = 0x1234_5678u32;
3186 let mut rnd = move || {
3187 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3188 (seed >> 8) as f64 / ((1u32 << 24) as f64) - 0.5
3189 };
3190 let slope: Vec<f64> = (0..n).map(|v| 60.0 + 40.0 * ((v as f64) * 0.013).sin()).collect();
3191 let intercept: Vec<f64> = (0..n).map(|v| 0.3 * ((v as f64) * 0.07).cos()).collect();
3192 // Magnitude varies over the volume as well as over TE: real data spans an order of
3193 // magnitude across a slice, and that spread is what the global robust scale is measured
3194 // against.
3195 const REF_MAG: f64 = 100.0;
3196 let profile: Vec<f64> =
3197 (0..n).map(|v| 20.0 + 180.0 * (0.5 + 0.5 * ((v as f64) * 0.0031).sin())).collect();
3198
3199 let mut phases = Vec::new();
3200 let mut mags = Vec::new();
3201 for &te in &tes {
3202 // T2* = 30 ms, so the last echo is a good deal dimmer than the first — which is what
3203 // makes the magnitude weighting do any work.
3204 let decay = (-te / 0.030_f64).exp();
3205 let m: Vec<f64> = (0..n).map(|v| decay * profile[v]).collect();
3206 phases.push(
3207 (0..n)
3208 .map(|v| {
3209 intercept[v] + slope[v] * te + noise * rnd() * REF_MAG / m[v].max(1e-6)
3210 })
3211 .collect::<Vec<f64>>(),
3212 );
3213 mags.push(m);
3214 }
3215 (phases, mags, tes, slope, vec![1u8; n])
3216 }
3217
3218 /// Mean absolute slope error over the mask, in rad/s.
3219 fn slope_err(got: &[f64], want: &[f64], mask: &[u8]) -> f64 {
3220 let (mut s, mut c) = (0.0, 0usize);
3221 for v in 0..got.len() {
3222 if mask[v] != 0 {
3223 s += (got[v] - want[v]).abs();
3224 c += 1;
3225 }
3226 }
3227 s / c.max(1) as f64
3228 }
3229
3230 /// The two losses, against values worked out by hand.
3231 #[test]
3232 fn robust_losses_have_the_shapes_they_claim() {
3233 // Huber: flat inside the radius, c/|u| outside.
3234 assert_eq!(RobustLoss::Huber.weight(0.0), 1.0);
3235 assert_eq!(RobustLoss::Huber.weight(1.0), 1.0);
3236 assert!((RobustLoss::Huber.weight(2.0) - 0.5).abs() < 1e-12);
3237 assert!((RobustLoss::Huber.weight(-4.0) - 0.25).abs() < 1e-12);
3238 assert!(RobustLoss::Huber.weight(1e9) > 0.0, "Huber must never reject outright");
3239
3240 // Tukey: (1 - u²)² inside, exactly zero outside. At u = 0.5 that is (1-0.25)² = 0.5625.
3241 assert_eq!(RobustLoss::Tukey.weight(0.0), 1.0);
3242 assert!((RobustLoss::Tukey.weight(0.5) - 0.5625).abs() < 1e-12);
3243 assert_eq!(RobustLoss::Tukey.weight(1.0), 0.0);
3244 assert_eq!(RobustLoss::Tukey.weight(1.0001), 0.0);
3245 assert_eq!(RobustLoss::Tukey.weight(-3.0), 0.0);
3246
3247 assert_eq!(RobustLoss::default(), RobustLoss::Tukey);
3248 assert_eq!(RobustLoss::Tukey.conventional_tuning(), 4.685);
3249 assert_eq!(RobustLoss::Huber.conventional_tuning(), 1.345);
3250 assert!(RobustFitParams::default().tuning.is_none(), "the default defers to the loss");
3251 }
3252
3253 /// A clean series must come back clean: nothing flagged, and the field essentially unmoved
3254 /// from the ordinary weighted fit.
3255 #[test]
3256 fn a_clean_series_is_not_flagged_and_is_barely_touched() {
3257 let (phases, mags, tes, truth, mask) = echo_series(4000, 0.02);
3258 let p = RobustFitParams::default();
3259 let lin = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true);
3260 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &p);
3261
3262 println!(
3263 "clean: downweighted {:?} flagged {:?}",
3264 rob.quality
3265 .downweighted_fraction
3266 .iter()
3267 .map(|x| format!("{x:.3}"))
3268 .collect::<Vec<_>>(),
3269 rob.quality.flagged
3270 );
3271 assert!(
3272 rob.quality.flagged.is_empty(),
3273 "a clean series must not flag anything, got {:?}",
3274 rob.quality.flagged
3275 );
3276 let (e_lin, e_rob) = (
3277 slope_err(&lin.field, &truth, &mask),
3278 slope_err(&rob.fit.field, &truth, &mask),
3279 );
3280 println!("clean slope error: linear {e_lin:.4} robust {e_rob:.4} rad/s");
3281 assert!(
3282 e_rob < 2.0 * e_lin.max(1e-6),
3283 "robust fit should not materially damage a clean series: {e_rob} vs {e_lin}"
3284 );
3285 assert_eq!(rob.quality.len(), 4);
3286 assert!(!rob.quality.is_empty());
3287 }
3288
3289 /// Corrupt one echo over part of the volume, the way motion or a spin-history dropout
3290 /// does: a contiguous block of voxels gets a large phase error and a loss of signal.
3291 fn corrupt_block(
3292 phases: &mut [Vec<f64>],
3293 mags: &mut [Vec<f64>],
3294 echo: usize,
3295 frac: f64,
3296 mag_scale: f64,
3297 amplitude: f64,
3298 ) {
3299 let n = phases[echo].len();
3300 let hi = ((n as f64) * frac) as usize;
3301 let mut seed = 0x9E37_79B9u32;
3302 for v in 0..hi {
3303 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3304 let r = (seed >> 8) as f64 / ((1u32 << 24) as f64) - 0.5;
3305 phases[echo][v] += amplitude * r;
3306 mags[echo][v] *= mag_scale;
3307 }
3308 }
3309
3310 /// One echo corrupted over part of the volume: the detector names it, and the fit stops it
3311 /// dragging the slope.
3312 ///
3313 /// The corruption is deliberately *spatially structured* rather than a uniform offset,
3314 /// because that is what motion produces and what this is for — see
3315 /// `a_spatially_uniform_offset_is_not_what_this_detects` for the other case and why it is
3316 /// out of scope.
3317 #[test]
3318 fn a_corrupted_echo_is_flagged_and_its_influence_removed() {
3319 let (mut phases, mut mags, tes, truth, mask) = echo_series(4000, 0.02);
3320 const BAD: usize = 2;
3321 const AMP: f64 = 0.8;
3322 corrupt_block(&mut phases, &mut mags, BAD, 0.35, 0.8, AMP);
3323
3324 let lin = multi_echo_linear_fit(&phases, &mags, &tes, &mask, true);
3325 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3326
3327 println!(
3328 "corrupted: outlier score {:?} downweighted {:?} flagged {:?}",
3329 rob.quality.outlier_score.iter().map(|x| format!("{x:.3}")).collect::<Vec<_>>(),
3330 rob.quality
3331 .downweighted_fraction
3332 .iter()
3333 .map(|x| format!("{x:.3}"))
3334 .collect::<Vec<_>>(),
3335 rob.quality.flagged
3336 );
3337 assert_eq!(rob.quality.flagged, vec![BAD], "exactly the corrupted echo");
3338 assert_eq!(rob.quality.worst().map(|(e, _)| e), Some(BAD));
3339
3340 // The score separates it from the best-fitting echo by a wide margin, which is the
3341 // quantity flagging is decided on.
3342 let best = rob.quality.outlier_score.iter().copied().fold(f64::INFINITY, f64::min);
3343 assert!(
3344 rob.quality.outlier_score[BAD] > 5.0 * best,
3345 "the corrupted echo's score {} should tower over the best echo's {best}",
3346 rob.quality.outlier_score[BAD]
3347 );
3348
3349 let (e_lin, e_rob) = (
3350 slope_err(&lin.field, &truth, &mask),
3351 slope_err(&rob.fit.field, &truth, &mask),
3352 );
3353 println!("corrupted slope error: linear {e_lin:.4} robust {e_rob:.4} rad/s");
3354 // A real improvement, but a modest one, and deliberately asserted as such. How much the
3355 // weighting can recover depends on the regime: here the corrupted echo keeps most of its
3356 // magnitude, so it still pulls the per-voxel fit and the degeneracy guard often restores
3357 // it. Swept over corruption amplitude on this phantom the gain runs 10% / 9% / 6% / 0.7%
3358 // at 0.5 / 0.8 / 1.5 / 4.0 rad — *detection* holds at every one of those, correction
3359 // fades as the corruption gets extreme enough to pull the fit at the same voxel. On real
3360 // data, where a dropout costs magnitude as well as phase and the bad echo therefore
3361 // carries little weight to begin with, the same code takes the field error from 0.042 to
3362 // 0.027. The integration test measures that; this one pins the direction and the
3363 // detection.
3364 assert!(
3365 e_rob < 0.95 * e_lin,
3366 "the robust fit has to improve the slope: {e_rob} vs linear {e_lin}"
3367 );
3368 }
3369
3370 /// Too few echoes to have an opinion: pass the ordinary fit straight through rather than
3371 /// inventing weights from a fit with no spare degrees of freedom.
3372 #[test]
3373 fn a_short_series_falls_back_instead_of_guessing() {
3374 let (phases, mags, tes, _, mask) = echo_series(500, 0.02);
3375 // Three echoes with an intercept is two parameters and one spare degree of freedom —
3376 // below the four this needs.
3377 let (p3, m3, t3) = (&phases[..3], &mags[..3], &tes[..3]);
3378 let lin = multi_echo_linear_fit(p3, m3, t3, &mask, true);
3379 let rob = multi_echo_robust_fit(p3, m3, t3, &mask, &RobustFitParams::default());
3380
3381 assert_eq!(rob.fit.field, lin.field, "field must be the ordinary fit, untouched");
3382 assert!(rob.quality.flagged.is_empty());
3383 assert!(rob.quality.downweighted_fraction.iter().all(|&f| f == 0.0));
3384 assert!(rob.quality.median_weight.iter().all(|&w| w == 1.0));
3385 assert!(rob.robust_weights.iter().all(|w| w.iter().all(|&x| x == 1.0)));
3386 assert_eq!(rob.quality.len(), 3);
3387
3388 // Without an intercept the model is one parameter, so three echoes *is* enough and the
3389 // same data now gets the full treatment.
3390 let rob_no_offset = multi_echo_robust_fit(
3391 p3,
3392 m3,
3393 t3,
3394 &mask,
3395 &RobustFitParams { estimate_offset: false, ..Default::default() },
3396 );
3397 assert!(
3398 rob_no_offset.robust_weights.iter().any(|w| w.iter().any(|&x| x != 1.0)),
3399 "three echoes and one parameter is enough to reweight"
3400 );
3401 }
3402
3403 /// The degeneracy guard: a rejecting loss must never leave a voxel with fewer surviving
3404 /// echoes than the model has parameters plus one.
3405 ///
3406 /// Two echoes of four are corrupted in opposite directions, which is the case that tempts
3407 /// Tukey to throw both away and fit a slope through the remaining two points — an exact fit
3408 /// with an unconstrained slope. The guard restores plain magnitude weighting there instead.
3409 #[test]
3410 fn the_degeneracy_guard_keeps_the_fit_determined() {
3411 let (mut phases, mags, tes, truth, mask) = echo_series(4000, 0.02);
3412 for v in 0..phases[0].len() {
3413 phases[1][v] += 6.0;
3414 phases[2][v] -= 6.0;
3415 }
3416 let rob = multi_echo_robust_fit(
3417 &phases,
3418 &mags,
3419 &tes,
3420 &mask,
3421 &RobustFitParams { loss: RobustLoss::Tukey, ..Default::default() },
3422 );
3423
3424 // At every voxel, at least three echoes must still carry weight (two parameters + 1).
3425 let mut worst_surviving = usize::MAX;
3426 for v in 0..mask.len() {
3427 let surviving = (0..4).filter(|&e| rob.robust_weights[e][v] > 1e-3).count();
3428 worst_surviving = worst_surviving.min(surviving);
3429 }
3430 println!("fewest surviving echoes at any voxel: {worst_surviving}");
3431 assert!(
3432 worst_surviving >= 3,
3433 "the guard must keep at least n_params+1 echoes everywhere, found {worst_surviving}"
3434 );
3435
3436 // And the slope stays finite and sane rather than exploding through two points.
3437 let e_rob = slope_err(&rob.fit.field, &truth, &mask);
3438 println!("slope error with two echoes corrupted: {e_rob:.4} rad/s");
3439 assert!(
3440 rob.fit.field.iter().all(|f| f.is_finite()),
3441 "an under-determined fit would produce non-finite slopes"
3442 );
3443 assert!(e_rob < 200.0, "slope error {e_rob} rad/s suggests the fit went unconstrained");
3444 }
3445
3446 /// A perfect fit has no scale to measure against; the weights must stay at 1 rather than
3447 /// dividing by zero and rejecting everything.
3448 #[test]
3449 fn a_perfect_fit_does_not_divide_by_zero() {
3450 let (phases, mags, tes, truth, mask) = echo_series(500, 0.0);
3451 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3452 assert!(
3453 rob.robust_weights.iter().all(|w| w.iter().all(|&x| x == 1.0)),
3454 "noiseless data has no outliers, so nothing should be downweighted"
3455 );
3456 assert!(rob.quality.flagged.is_empty());
3457 assert!(
3458 slope_err(&rob.fit.field, &truth, &mask) < 1e-9,
3459 "a noiseless linear series must be recovered exactly"
3460 );
3461 }
3462
3463 /// Flagging is relative to the series' own median, so it does not depend on an absolute
3464 /// downweighting rate that moves with SNR.
3465 #[test]
3466 fn flagging_is_relative_to_the_series() {
3467 let (mut phases, mut mags, tes, _, mask) = echo_series(4000, 0.02);
3468 corrupt_block(&mut phases, &mut mags, 2, 0.35, 0.8, 4.0);
3469 // A ratio high enough that nothing can reach it proves the ratio is what decides.
3470 let strict = multi_echo_robust_fit(
3471 &phases,
3472 &mags,
3473 &tes,
3474 &mask,
3475 &RobustFitParams { flag_ratio: 1e6, ..Default::default() },
3476 );
3477 assert!(strict.quality.flagged.is_empty(), "an unreachable ratio must flag nothing");
3478
3479 let normal =
3480 multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3481 assert_eq!(normal.quality.flagged, vec![2], "the defaults do flag it");
3482 }
3483
3484 /// A phase offset applied to one echo at *every* voxel is detected too — the final fit
3485 /// carries so little of the rejected echo that its residual is essentially the whole offset.
3486 #[test]
3487 fn a_whole_volume_offset_is_detected() {
3488 let (mut phases, mags, tes, _, mask) = echo_series(4000, 0.02);
3489 for v in 0..phases[2].len() {
3490 phases[2][v] += 3.0;
3491 }
3492 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3493 println!(
3494 "whole-volume offset: residual (rad) {:?} flagged {:?}",
3495 rob.quality
3496 .median_abs_residual_rad
3497 .iter()
3498 .map(|x| format!("{x:.2}"))
3499 .collect::<Vec<_>>(),
3500 rob.quality.flagged
3501 );
3502 assert_eq!(rob.quality.flagged, vec![2]);
3503 // The residual on the rejected echo should be close to the offset itself, which is what
3504 // "the fit no longer uses it" means in practice.
3505 assert!(
3506 (rob.quality.median_abs_residual_rad[2] - 3.0).abs() < 0.3,
3507 "expected the rejected echo's residual to be the ~3 rad offset, got {}",
3508 rob.quality.median_abs_residual_rad[2]
3509 );
3510 }
3511
3512 /// A known false positive, pinned so it is recorded rather than rediscovered: a series in
3513 /// which one echo carries almost no signal gets another echo flagged, on data with nothing
3514 /// wrong with it.
3515 ///
3516 /// The cause is structural rather than a threshold being wrong. With one of four echoes
3517 /// effectively absent the fit has three points and two parameters, and in a three-point
3518 /// linear fit the *middle* point carries the leverage and therefore the residual — so the
3519 /// middle echo scores an order of magnitude above its neighbours with no corruption
3520 /// anywhere. Measured: magnitudes scaled by 1e-4 on the first echo gives scores
3521 /// 0.0002 / 0.056 / 1.171 / 0.056.
3522 ///
3523 /// Two fixes were tried and neither works. Flooring the flagging reference at the robust
3524 /// scale does not help, because the scale is well below the spread. Taking the reference
3525 /// from the median rather than the minimum does not either: it suppresses this case and the
3526 /// genuine 12% dropout along with it. The honest position is that this function wants
3527 /// `n_params + 2` echoes *carrying signal*, not merely present, and that it cannot tell the
3528 /// difference — so a series with a dead echo needs that echo dropped by the caller before
3529 /// this is believed.
3530 #[test]
3531 fn a_dead_echo_causes_a_known_false_positive() {
3532 let (phases, mut mags, tes, _, mask) = echo_series(4000, 0.02);
3533 for m in mags[0].iter_mut() {
3534 *m *= 1e-4;
3535 }
3536 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3537 println!(
3538 "dead echo on a clean series: scores {:?} flagged {:?}",
3539 rob.quality.outlier_score.iter().map(|x| format!("{x:.5}")).collect::<Vec<_>>(),
3540 rob.quality.flagged
3541 );
3542 assert_eq!(
3543 rob.quality.flagged,
3544 vec![2],
3545 "documented false positive. If this changes, the behaviour has moved — update the \
3546 limitation in `multi_echo_robust_fit`'s docs rather than the assertion"
3547 );
3548 // And the series really is clean: drop the dead echo and nothing is flagged.
3549 let kept: Vec<Vec<f64>> = phases[1..].to_vec();
3550 let kept_mags: Vec<Vec<f64>> = mags[1..].to_vec();
3551 let three = multi_echo_robust_fit(
3552 &kept, &kept_mags, &tes[1..], &mask, &RobustFitParams::default(),
3553 );
3554 assert!(
3555 three.quality.flagged.is_empty(),
3556 "with the dead echo removed the same data must be clean, got {:?}",
3557 three.quality.flagged
3558 );
3559 }
3560
3561 /// A mask that selects nothing must not read as a clean series.
3562 ///
3563 /// Every statistic here is taken over the mask, so an empty one produces a flawless report:
3564 /// nothing flagged, nothing downweighted, every score zero. That is exactly what a failed
3565 /// brain extraction upstream yields, and the report alone cannot tell it from good data —
3566 /// a mechanism that goes quiet precisely when it can no longer speak.
3567 /// [`EchoQuality::voxels_examined`] is what makes the two distinguishable, so it is checked
3568 /// here rather than left to a caller who reads the docs.
3569 #[test]
3570 fn an_empty_mask_does_not_read_as_a_clean_series() {
3571 let (phases, mags, tes, _, _) = echo_series(500, 0.02);
3572 let empty = vec![0u8; 500];
3573 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &empty, &RobustFitParams::default());
3574
3575 // The report really does look perfect, which is the point.
3576 assert!(rob.quality.flagged.is_empty());
3577 assert!(rob.quality.outlier_score.iter().all(|&x| x == 0.0));
3578 assert!(rob.quality.downweighted_fraction.iter().all(|&x| x == 0.0));
3579 // And this is the only thing that says so.
3580 assert_eq!(
3581 rob.quality.voxels_examined, 0,
3582 "an empty mask must report that nothing was examined"
3583 );
3584
3585 // A real mask reports a real count, so the field is not vacuously zero.
3586 let full = vec![1u8; 500];
3587 let ok = multi_echo_robust_fit(&phases, &mags, &tes, &full, &RobustFitParams::default());
3588 assert_eq!(ok.quality.voxels_examined, 500);
3589
3590 // And a *partial* mask reports its population, not the volume it sits in. Without this
3591 // the only thing standing between `count()` and `len()` is the empty case above, which
3592 // is one assertion deep: an all-ones mask cannot tell the two apart, because for it they
3593 // are the same number.
3594 let mut partial = vec![0u8; 500];
3595 partial[..300].fill(1);
3596 let some = multi_echo_robust_fit(
3597 &phases, &mags, &tes, &partial, &RobustFitParams::default(),
3598 );
3599 assert_eq!(some.quality.voxels_examined, 300);
3600
3601 // The short-series fallback reports it the same way — and on the partial mask, so this
3602 // pins its population too rather than a number that happens to match the volume.
3603 let short = multi_echo_robust_fit(
3604 &phases[..3], &mags[..3], &tes[..3], &partial, &RobustFitParams::default(),
3605 );
3606 assert_eq!(short.quality.voxels_examined, 300);
3607 }
3608
3609 /// More echoes make this work better, and every other test here runs at the minimum.
3610 ///
3611 /// Four echoes is the fewest `multi_echo_robust_fit` accepts with an intercept, so every
3612 /// other test in this module sits exactly on its boundary — and a reader could reasonably
3613 /// take the limitations they measure for properties of the method rather than of that
3614 /// boundary. They are substantially the latter. Same corruption, same TE spacing, more
3615 /// echoes:
3616 ///
3617 /// | echoes | clean-series spread | corrupted spread | slope error vs the plain fit |
3618 /// |---|---|---|---|
3619 /// | 4 | 1.88 | 10.4 | −9% |
3620 /// | 6 | 1.34 | 17.5 | −28% |
3621 /// | 8 | 1.25 | 13.5 | −23% |
3622 ///
3623 /// Both halves improve: the clean spread tightens, so there is more headroom under the
3624 /// flagging threshold, and the correction roughly triples once there is a spare degree of
3625 /// freedom to lose.
3626 #[test]
3627 fn more_echoes_work_better_than_the_four_every_other_test_uses() {
3628 let fit_at = |n_echo: usize| -> (f64, f64, f64, Vec<usize>) {
3629 let tes: Vec<f64> = (0..n_echo).map(|e| 0.004 + 0.008 * e as f64).collect();
3630 let n = 4000;
3631 let mut seed = 0x1234_5678u32;
3632 let mut rnd = move || {
3633 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3634 (seed >> 8) as f64 / ((1u32 << 24) as f64) - 0.5
3635 };
3636 let slope: Vec<f64> =
3637 (0..n).map(|v| 60.0 + 40.0 * ((v as f64) * 0.013).sin()).collect();
3638 let icpt: Vec<f64> = (0..n).map(|v| 0.3 * ((v as f64) * 0.07).cos()).collect();
3639 let prof: Vec<f64> = (0..n)
3640 .map(|v| 20.0 + 180.0 * (0.5 + 0.5 * ((v as f64) * 0.0031).sin()))
3641 .collect();
3642 let (mut ph, mut mg) = (Vec::new(), Vec::new());
3643 for &te in &tes {
3644 let d = (-te / 0.030_f64).exp();
3645 let m: Vec<f64> = (0..n).map(|v| d * prof[v]).collect();
3646 ph.push(
3647 (0..n)
3648 .map(|v| icpt[v] + slope[v] * te + 0.02 * rnd() * 100.0 / m[v])
3649 .collect::<Vec<f64>>(),
3650 );
3651 mg.push(m);
3652 }
3653 let mask = vec![1u8; n];
3654 let p = RobustFitParams::default();
3655 let clean = multi_echo_robust_fit(&ph, &mg, &tes, &mask, &p);
3656 let spread = |q: &EchoQuality| {
3657 let lo = q.outlier_score.iter().copied().fold(f64::INFINITY, f64::min);
3658 q.outlier_score.iter().copied().fold(0.0f64, f64::max) / lo.max(1e-30)
3659 };
3660 let clean_spread = spread(&clean.quality);
3661 assert!(
3662 clean.quality.flagged.is_empty(),
3663 "{n_echo} echoes: a clean series must not be flagged"
3664 );
3665
3666 let bad = n_echo / 2;
3667 let mut s2 = 0x9E37_79B9u32;
3668 for v in 0..(n * 35 / 100) {
3669 s2 = s2.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
3670 let r = (s2 >> 8) as f64 / ((1u32 << 24) as f64) - 0.5;
3671 ph[bad][v] += 0.8 * r;
3672 mg[bad][v] *= 0.8;
3673 }
3674 let lin = multi_echo_linear_fit(&ph, &mg, &tes, &mask, true);
3675 let rob = multi_echo_robust_fit(&ph, &mg, &tes, &mask, &p);
3676 let err = |f: &[f64]| -> f64 {
3677 (0..n).map(|v| (f[v] - slope[v]).abs()).sum::<f64>() / n as f64
3678 };
3679 let gain = (err(&lin.field) - err(&rob.fit.field)) / err(&lin.field);
3680 (clean_spread, spread(&rob.quality), gain, rob.quality.flagged.clone())
3681 };
3682
3683 let (c4, d4, g4, f4) = fit_at(4);
3684 let (c6, d6, g6, f6) = fit_at(6);
3685 println!(
3686 "4 echoes: clean spread {c4:.2}, corrupted {d4:.1}, gain {:+.0}%, flagged {f4:?}\n\
3687 6 echoes: clean spread {c6:.2}, corrupted {d6:.1}, gain {:+.0}%, flagged {f6:?}",
3688 100.0 * g4,
3689 100.0 * g6
3690 );
3691 assert_eq!(f4, vec![2], "4 echoes: the corrupted one");
3692 assert_eq!(f6, vec![3], "6 echoes: the corrupted one");
3693 assert!(c6 < c4, "more echoes should tighten the clean spread: {c6} vs {c4}");
3694 assert!(d6 > d4, "and widen the corrupted one: {d6} vs {d4}");
3695 assert!(
3696 g6 > 2.0 * g4,
3697 "the correction should improve substantially past the four-echo boundary: \
3698 {:.1}% against {:.1}%",
3699 100.0 * g6,
3700 100.0 * g4
3701 );
3702 }
3703
3704 /// The documented detection floor: corruption confined to a small share of the volume /// The documented detection floor: corruption confined to a small share of the volume is not
3705 /// flagged, because the score is a high quantile over the mask.
3706 ///
3707 /// Pinned so the limitation is recorded rather than rediscovered. If this starts failing the
3708 /// detector has genuinely improved — update the "What it does not detect" docs rather than
3709 /// the assertion.
3710 #[test]
3711 fn corruption_over_a_small_share_of_the_volume_is_below_the_floor() {
3712 let (mut phases, mut mags, tes, _, mask) = echo_series(4000, 0.02);
3713 corrupt_block(&mut phases, &mut mags, 2, 0.04, 0.8, 4.0);
3714 let rob = multi_echo_robust_fit(&phases, &mags, &tes, &mask, &RobustFitParams::default());
3715 println!(
3716 "4% of the volume corrupted: downweighted {:?} flagged {:?}",
3717 rob.quality
3718 .downweighted_fraction
3719 .iter()
3720 .map(|x| format!("{x:.3}"))
3721 .collect::<Vec<_>>(),
3722 rob.quality.flagged
3723 );
3724 assert!(
3725 rob.quality.flagged.is_empty(),
3726 "documented floor: 4% extent is below the reporting threshold, got {:?}",
3727 rob.quality.flagged
3728 );
3729 // It is still the worst-scoring echo — the information is there, it just does not clear
3730 // the bar for calling the whole echo corrupted.
3731 assert_eq!(rob.quality.worst().map(|(e, _)| e), Some(2));
3732 }
3733
3734}