Skip to main content

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, &params),
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, &params),
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}