Skip to main content

qsm_core/pipeline/
field_mapping.rs

1//! Field mapping stage
2//!
3//! Multi-echo phase → B0 field map in ppm.
4//! Implements the canonical field mapping pipeline:
5//! phase offset removal → bipolar correction → unwrapping → B0 estimation,
6//! or MEDI's: complex fit of the wrapped echoes → unwrapping.
7//!
8//! # Reference pipeline
9//!
10//! The default ([`FieldMappingConfig::default`]: MCPC-3D-S offset removal, ROMEO template
11//! unwrapping, `:phase_snr` weighted average) is the ROMEO authors' B0 pipeline: the `romeo` CLI
12//! with `--compute-B0` turns MCPC-3D-S on for multi-echo data (ROMEO.jl 08776d4,
13//! `ext/RomeoApp/caller.jl:80-91`), unwraps temporally from echo 1 (`argparse.jl:101-105`) and
14//! combines with `phase_snr` (`argparse.jl:67-70`; MriResearchTools 771eadf
15//! `src/romeofunctions.jl:13-24`), whose docstring requires the phase offsets to have been
16//! removed first (`romeofunctions.jl:3-7`). Where QSM.rs differs:
17//!
18//! - **Smoothing sigma.** `phase_offset_sigma_mm` defaults to the ROMEO command line's 7 mm per
19//!   axis, divided by the voxel size (`argparse.jl:79-91`, `caller.jl:137-141`); MriResearchTools'
20//!   `mcpc3ds` function itself defaults to `[10, 10, 5]` voxels (`src/mcpc3ds.jl:17,39`). The
21//!   command line says to reduce it on a small field of view and to use 0 along an axis with a
22//!   single slice; that is left to the caller.
23//! - **Masks.** MCPC-3D-S makes its own `robustmask` of `sqrt(|HIP|)`, as the reference
24//!   `mcpc3ds` does (`mcpc3ds.jl:44-46`; [`crate::utils::phase_offset_removal`]), and bipolar
25//!   correction runs inside that same mask ([`crate::utils::mcpc3ds_mask`]), because `mcpc3ds`
26//!   calls `bipolar_correction!` with it (`mcpc3ds.jl:55`). This used to be the brain mask: on
27//!   `tests/mritools_parity.rs`'s phantom, whose mask differs from that robust mask at about a
28//!   tenth of their voxels, it left the corrected phase up to 0.22 rad from `mcpc3ds(...;
29//!   bipolar_correction = true)` inside the brain; with `mcpc3ds`' mask it agrees to 1.8e-15 rad
30//!   (QSM.rs#330). ROMEO uses the brain mask passed in,
31//!   as the CLI does when given one (`-k`); without one the CLI unwraps inside `robustmask` of
32//!   the template magnitude (`caller.jl:201-204`).
33//! - **ROMEO settings.** The default is ROMEO.jl's API defaults
34//!   ([`RomeoParams::default`](crate::unwrap::RomeoParams::default): `:romeo` weights,
35//!   `temporal_uncertain_unwrapping` 0.5). The CLI turns `-w romeo` into `romeo3` when a
36//!   magnitude is given and does no temporal re-unwrapping;
37//!   [`RomeoParams::romeo_cli`](crate::unwrap::RomeoParams::romeo_cli) sets those, and with it
38//!   the result matches the CLI up to the CLI's single-precision arithmetic (see
39//!   [`crate::unwrap::romeo`], *Agreement with the reference*).
40//! - **Where MCPC-3D-S's offset is undefined.** As in the reference, the corrected phase is NaN
41//!   there, ROMEO does not grow through it, and the field is undefined: the result's
42//!   [`FieldMappingResult::valid_mask`] is the brain mask less those voxels, the field is 0 on the
43//!   rest of the brain mask, and every later stage must work in `valid_mask`
44//!   ([`crate::utils::phase_offset_removal`] has the measurements; until 2026-11 those voxels kept
45//!   their raw phase, which caused ring artefacts on GE and Philips data). On every other path
46//!   `valid_mask` is the brain mask.
47//! - **Offset removal off, or Laplacian unwrapping.** Phase-offset removal and bipolar correction
48//!   run only with ROMEO (`do_offset` below); with Laplacian they are skipped whatever the
49//!   config says. Without them ROMEO unwraps the raw echoes exactly as on the offset-removal path
50//!   ([`crate::unwrap::unwrap_romeo_multi_echo`] with `romeo_params`: template or individual
51//!   mode, each echo's own magnitude, ROMEO.jl's `phase2`; until 2026-11 this path unwrapped each
52//!   echo with echo 1's magnitude and the next echo as `phase2` whatever the mode), and the
53//!   per-echo whole turns are aligned before combining (see `field_mapping_direct`; a no-op
54//!   whenever ROMEO's own global correction ran or the echoes were unwrapped temporally).
55//! - **Bipolar correction** is opt-in in the reference too (`argparse.jl:71-76`, "requires ≥ 3
56//!   echoes"); with fewer than 3 echoes it is skipped here silently.
57//!
58//! Field strength enters only in the conversion to ppm; nothing assumes a resolution or B0
59//! direction.
60//!
61//! # Pipeline rules
62//!
63//! The facts above that a configuration can depart from are published as rules in
64//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `field-mapping.phase-offsets`, `field-mapping.bipolar-needs-three-echoes`, `field-mapping.bipolar-not-with-laplacian`, `field-mapping.phase-offset-sigma`.
65
66use super::config::*;
67use super::phase_utils::{hz_to_ppm, rads_to_ppm};
68use crate::utils::B0WeightType;
69
70/// Run field mapping: convert per-echo phase data to a B0 field map in ppm.
71///
72/// # Arguments
73/// * `phases` - Per-echo wrapped phase arrays (in [-pi, pi])
74/// * `magnitudes` - Per-echo magnitude arrays (optional; uniform weights if None)
75/// * `mask` - Binary brain mask
76/// * `metadata` - Scan metadata (dims, voxel size, echo times in seconds, field strength)
77/// * `config` - Field mapping configuration
78/// * `progress` - Progress callback (current_step, total_steps)
79///
80/// # Returns
81/// `FieldMappingResult` with B0 field in ppm and optional phase offset
82///
83/// # UK Biobank's field map
84///
85/// On offset-free echoes (e.g. MCPC-3D-S coil-combined), Laplacian unwrapping with
86/// `laplacian_solver: LaplacianSolver::Fft { pad: [64; 3] }` and
87/// `b0_estimation: B0EstimationMethod::WeightedAvg { weighting: B0WeightType::AssumedDecay { t2star_s: 0.040 } }`
88/// reproduces the UK Biobank QSM pipeline's field map: STI Suite's `MRPhaseUnwrap(mask .* φ)`
89/// per echo, then `Σ Wφ / Σ W·TE` with `W = TE·exp(−TE/T2*)`. It matches up to a global
90/// constant (each echo's masked mean is removed before combining, see the direct path below),
91/// to ≈1e-14 rad in the combined phase on in-vivo two-echo data.
92///
93/// ```
94/// use qsm_core::pipeline::{B0EstimationMethod, FieldMappingConfig, UnwrappingAlgorithm};
95/// use qsm_core::unwrap::LaplacianSolver;
96/// use qsm_core::utils::B0WeightType;
97/// let ukb = FieldMappingConfig {
98///     unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
99///     laplacian_solver: LaplacianSolver::Fft { pad: [64; 3] },
100///     b0_estimation: B0EstimationMethod::WeightedAvg {
101///         weighting: B0WeightType::assumed_decay_default(),
102///     },
103///     ..Default::default()
104/// };
105/// # let _ = ukb;
106/// ```
107///
108/// # MEDI's field map
109///
110/// [`B0EstimationMethod::ComplexFit`] runs MEDI's `Fit_ppm_complex` on the wrapped complex echoes,
111/// unwraps the fitted phase per echo spacing spatially with `unwrapping_algorithm`, and divides
112/// by the echo spacing. It needs `magnitudes` and evenly spaced echoes, and also returns the
113/// field's noise map in [`FieldMappingResult::field_noise_ppm`] (MEDI's `N_std`), and the fit
114/// before unwrapping in [`FieldMappingResult::complex_fit`] (MEDI's `iFreq_raw` and `N_std`, in
115/// radians per echo spacing):
116///
117/// ```
118/// use qsm_core::fieldmap::FitPpmComplexParams;
119/// use qsm_core::pipeline::{B0EstimationMethod, FieldMappingConfig};
120/// let medi = FieldMappingConfig {
121///     b0_estimation: B0EstimationMethod::ComplexFit(FitPpmComplexParams::default()),
122///     ..Default::default()
123/// };
124/// # let _ = medi;
125/// ```
126///
127/// # Motion-robust echo combination
128///
129/// [`B0EstimationMethod::RobustFit`] combines the echoes with
130/// [`multi_echo_robust_fit`](crate::utils::multi_echo_robust_fit) on whichever path runs, so it
131/// composes with ROMEO and Laplacian unwrapping and with phase-offset removal on or off. It
132/// needs [`RobustFitParams::min_echoes`](crate::utils::RobustFitParams::min_echoes) echoes or
133/// more (fewer is [`PipelineError::InvalidConfig`]) and returns its per-echo report in
134/// [`FieldMappingResult::robust_fit`]:
135///
136/// ```
137/// use qsm_core::pipeline::{B0EstimationMethod, FieldMappingConfig};
138/// use qsm_core::utils::RobustFitParams;
139/// let robust = FieldMappingConfig {
140///     b0_estimation: B0EstimationMethod::RobustFit(RobustFitParams::default()),
141///     ..Default::default()
142/// };
143/// # let _ = robust;
144/// ```
145pub fn run_field_mapping(
146    phases: &[&[f64]],
147    magnitudes: Option<&[&[f64]]>,
148    mask: &[u8],
149    metadata: &ScanMetadata,
150    config: &FieldMappingConfig,
151    progress: &mut dyn FnMut(usize, usize),
152) -> Result<FieldMappingResult, PipelineError> {
153    let (nx, ny, nz) = metadata.dims;
154    let (vsx, vsy, vsz) = metadata.voxel_size;
155    let n_voxels = nx * ny * nz;
156    let n_echoes = phases.len();
157
158    if n_echoes == 0 {
159        return Err(PipelineError::InvalidInput("no phase echoes provided".into()));
160    }
161    if metadata.echo_times.len() != n_echoes {
162        return Err(PipelineError::DimensionMismatch {
163            expected: n_echoes,
164            got: metadata.echo_times.len(),
165        });
166    }
167    for (i, p) in phases.iter().enumerate() {
168        if p.len() != n_voxels {
169            return Err(PipelineError::DimensionMismatch {
170                expected: n_voxels,
171                got: p.len(),
172            });
173        }
174        if let Some(mags) = magnitudes {
175            if i < mags.len() && mags[i].len() != n_voxels {
176                return Err(PipelineError::DimensionMismatch {
177                    expected: n_voxels,
178                    got: mags[i].len(),
179                });
180            }
181        }
182    }
183
184    require_magnitude_for_medi(config, magnitudes)?;
185
186    match &config.b0_estimation {
187        B0EstimationMethod::WeightedAvg { weighting: B0WeightType::AssumedDecay { t2star_s } } => {
188            if !(t2star_s.is_finite() && *t2star_s > 0.0) {
189                return Err(PipelineError::InvalidConfig(format!(
190                    "assumed T2* for the assumed-decay B0 weighting must be positive, got {} s", t2star_s
191                )));
192            }
193        }
194        B0EstimationMethod::RobustFit(params) => {
195            params.validate().map_err(PipelineError::InvalidConfig)?;
196            // The library falls back to the plain fit, silently, below this. Asking for the robust
197            // fit and getting a plain one with an empty report is worse than being told.
198            if n_echoes < params.min_echoes() {
199                return Err(PipelineError::InvalidConfig(format!(
200                    "the robust B0 fit needs at least {} echoes ({}), got {}; use the weighted \
201                     average or the linear fit for this series",
202                    params.min_echoes(),
203                    if params.estimate_offset {
204                        "4 with estimate_offset, which costs a degree of freedom"
205                    } else {
206                        "3 without estimate_offset"
207                    },
208                    n_echoes,
209                )));
210            }
211        }
212        B0EstimationMethod::QsmartEchofit { fit_threshold } => {
213            fit_threshold.validate().map_err(PipelineError::InvalidConfig)?;
214        }
215        B0EstimationMethod::WeightedAvg { .. } | B0EstimationMethod::LinearFit(_) => {}
216        // Not a combination of unwrapped echoes: the fit runs on the wrapped echoes and is
217        // unwrapped after, so it has a path of its own.
218        B0EstimationMethod::ComplexFit(params) => {
219            return field_mapping_complex_fit(phases, magnitudes, mask, metadata, config, params, progress);
220        }
221    }
222
223    let is_laplacian = config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian;
224    let do_offset = n_echoes > 1 && config.phase_offset_removal && !is_laplacian;
225
226    // Create uniform magnitude fallback
227    let uniform_mag = vec![1.0f64; n_voxels];
228    let uniform_mags: Vec<&[f64]> = (0..n_echoes).map(|_| uniform_mag.as_slice()).collect();
229    let mag_slices: &[&[f64]] = magnitudes.unwrap_or(&uniform_mags);
230
231    progress(0, 4);
232
233    if n_echoes > 1 && do_offset {
234        // ---- Path A: Phase offset removal + unwrap + B0 estimation ----
235        field_mapping_with_offset(
236            phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
237        )
238    } else if n_echoes > 1 {
239        // ---- Path B: Direct per-echo unwrapping + configured B0 estimation ----
240        field_mapping_direct(
241            phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
242        )
243    } else {
244        // ---- Path C: Single echo ----
245        field_mapping_single_echo(
246            phases[0], mag_slices[0], mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
247        )
248    }
249}
250
251/// Path A: Multi-echo with phase offset removal.
252///
253/// MCPC-3D-S ([`crate::utils::phase_offset_removal`]) leaves the phase NaN where its offset is
254/// undefined, as MriResearchTools does. ROMEO is given that NaN phase and the brain mask, exactly
255/// as ROMEO.jl is in the authors' pipeline, so it never grows through those voxels. The other
256/// unwrappers (QSM.rs's options here) take no NaN: they get the phase zeroed there and the mask
257/// `brain ∩ valid`. The echoes are combined inside `brain ∩ valid`, the field is 0 outside it, and
258/// that mask is returned as [`FieldMappingResult::valid_mask`].
259#[allow(clippy::too_many_arguments)]
260fn field_mapping_with_offset(
261    phases: &[&[f64]],
262    mag_slices: &[&[f64]],
263    mask: &[u8],
264    metadata: &ScanMetadata,
265    config: &FieldMappingConfig,
266    _n_voxels: usize,
267    nx: usize, ny: usize, nz: usize,
268    vsx: f64, vsy: f64, vsz: f64,
269    progress: &mut dyn FnMut(usize, usize),
270) -> Result<FieldMappingResult, PipelineError> {
271    let tes = &metadata.echo_times;
272    let n_echoes = phases.len();
273
274    // Step 1: Phase offset removal
275    progress(1, 4);
276    let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
277    let crate::utils::PhaseOffsetRemoval { phases: mut corrected, offset: mut phase_offset, valid: offset_valid } =
278        crate::utils::phase_offset_removal(
279            phases, mag_slices, tes,
280            config.phase_offset_sigma_voxels((vsx, vsy, vsz)), [0, 1],
281            crate::unwrap::UnwrapMethod::Romeo,
282            &grid,
283        );
284    let valid_mask: Vec<u8> = mask.iter().zip(&offset_valid).map(|(&m, &v)| u8::from(m != 0 && v != 0)).collect();
285
286    // Step 2: Bipolar correction (optional, >= 3 echoes), inside MCPC-3D-S's own mask
287    // `robustmask(sqrt(|hip|))`, as `mcpc3ds` passes it to `bipolar_correction!`
288    // (`mcpc3ds.jl:55`), not the brain mask (QSM.rs#330). NaN phase stays NaN, as there.
289    if config.bipolar_correction && n_echoes >= 3 {
290        let mcpc_mask = crate::utils::mcpc3ds_mask(phases, mag_slices, [0, 1], &grid);
291        crate::utils::bipolar_correction(
292            &mut corrected, mag_slices, tes, &mcpc_mask,
293            config.phase_offset_sigma_voxels((vsx, vsy, vsz)), &grid,
294        );
295    }
296
297    // Step 3: Multi-echo unwrapping
298    progress(2, 4);
299    if config.unwrapping_algorithm != UnwrappingAlgorithm::Romeo {
300        // Only ROMEO takes NaN as "no data"; the others get 0 there and the valid mask.
301        for p in corrected.iter_mut() {
302            p.iter_mut().for_each(|v| if !v.is_finite() { *v = 0.0 });
303        }
304    }
305    let mut unwrapped: Vec<Vec<f64>> = match config.unwrapping_algorithm {
306        UnwrappingAlgorithm::Laplacian => {
307            // Neumann, not the ROI-masked variant: this stage wants unwrapping only.
308            // Background removal is a later stage, and the masked variant would remove it
309            // here first — measurably worse than doing it once, properly. The phase is zeroed
310            // outside the mask first (MCPC-3D-S corrects every voxel, and the Laplacian unwrap
311            // takes the whole array), as on the direct path.
312            corrected.iter()
313                .map(|p| crate::unwrap::laplacian_unwrap(&mask_phase(p, &valid_mask), &valid_mask, &grid, config.laplacian_solver))
314                .collect()
315        }
316        UnwrappingAlgorithm::Romeo => {
317            crate::unwrap::unwrap_romeo_multi_echo(
318                &corrected, mag_slices, tes, mask,
319                &config.romeo_params, &grid,
320            )
321        }
322        UnwrappingAlgorithm::Prelude => {
323            let mut u: Vec<Vec<f64>> = corrected.iter()
324                .map(|p| crate::unwrap::unwrap_prelude(p, &valid_mask, &grid, &config.prelude_params))
325                .collect();
326            align_per_echo_constants(&mut u, tes, &valid_mask, UnwrappingAlgorithm::Prelude);
327            u
328        }
329        UnwrappingAlgorithm::MediRegionGrowing => {
330            // Each echo on its own over the whole array (no mask, as unwrapPhase), ordered by
331            // MEDI's iMag; then whole turns aligned, as for PRELUDE.
332            let imag = rss_magnitude(mag_slices);
333            let mut u: Vec<Vec<f64>> = corrected.iter()
334                .map(|p| crate::unwrap::unwrap_medi_region_growing(p, &imag, &grid))
335                .collect();
336            align_per_echo_constants(&mut u, tes, &valid_mask, UnwrappingAlgorithm::MediRegionGrowing);
337            u
338        }
339    };
340    drop(corrected);
341    // Nothing is combined outside `brain ∩ valid`, and no NaN reaches the combinations.
342    for u in unwrapped.iter_mut() {
343        u.iter_mut().for_each(|v| if !v.is_finite() { *v = 0.0 });
344    }
345    phase_offset.iter_mut().for_each(|v| if !v.is_finite() { *v = 0.0 });
346
347    // Step 4: B0 estimation
348    progress(3, 4);
349    let mut robust_fit = None;
350    if let B0EstimationMethod::QsmartEchofit { fit_threshold } = config.b0_estimation {
351        progress(4, 4);
352        let mut r = qsmart_fit(&unwrapped, mag_slices, &valid_mask, metadata, fit_threshold);
353        // `echofit.m` fits every voxel; the ones with no defined phase have no field.
354        for (i, &ok) in offset_valid.iter().enumerate() {
355            if ok == 0 {
356                r.b0_field_ppm[i] = 0.0;
357                if let Some(rel) = r.reliability.as_mut() {
358                    rel[i] = 0;
359                }
360            }
361        }
362        return Ok(FieldMappingResult { phase_offset: Some(phase_offset), ..r });
363    }
364    let b0_hz = match &config.b0_estimation {
365        B0EstimationMethod::WeightedAvg { weighting } => {
366            crate::utils::calculate_b0_weighted(
367                &unwrapped, mag_slices, tes, &valid_mask,
368                *weighting, &grid,
369            )
370        }
371        B0EstimationMethod::LinearFit(params) => {
372            let uw_refs: Vec<&[f64]> = unwrapped.iter().map(|u| u.as_slice()).collect();
373            let fit = crate::utils::multi_echo_linear_fit(
374                &uw_refs, mag_slices, tes, &valid_mask,
375                params.estimate_offset,
376            );
377            crate::utils::field_to_hz(&fit.field)
378        }
379        B0EstimationMethod::RobustFit(params) => {
380            let (field_rads, report) = robust_fit_echoes(&unwrapped, mag_slices, tes, &valid_mask, params);
381            robust_fit = Some(report);
382            crate::utils::field_to_hz(&field_rads)
383        }
384        B0EstimationMethod::QsmartEchofit { .. } => unreachable!("returned above"),
385        B0EstimationMethod::ComplexFit(_) => unreachable!("run_field_mapping takes the complex fit's own path"),
386    };
387
388    progress(4, 4);
389    Ok(FieldMappingResult {
390        b0_field_ppm: hz_to_ppm(&b0_hz, metadata.field_strength),
391        valid_mask,
392        phase_offset: Some(phase_offset),
393        robust_fit,
394        reliability: None,
395        qsmart_fit_threshold: None,
396        field_noise_ppm: None,
397        complex_fit: None,
398    })
399}
400
401/// Path B: Multi-echo without phase offset removal (per-echo unwrap + B0 estimation).
402///
403/// Taken whenever offset removal is off: always with Laplacian unwrapping, and with ROMEO when
404/// `phase_offset_removal` is false. Each echo is unwrapped on its own (for Laplacian, with the
405/// phase zeroed outside `mask` first unless `config.laplacian_unmasked`, and `config.laplacian_solver` as the Poisson solver), and the echoes are then combined with
406/// `config.b0_estimation`, as on the offset-removal path:
407///
408/// * [`B0EstimationMethod::WeightedAvg`] (the default): each echo is unwrapped only up to a
409///   constant of its own, which a weighted mean of `φ/TE` would turn into a field offset (one
410///   that varies over the brain for magnitude-dependent weights). So that constant is aligned
411///   across echoes first, by the least that the unwrapper leaves undetermined:
412///   - Laplacian fixes each echo only up to an arbitrary constant, so the masked mean is
413///     subtracted from every echo. Echo `e` is `TEₑ·ω + cₑ` inside the mask; demeaned it is
414///     `TEₑ·(ω − ω̄)` for every echo, which loses only a global constant field `ω̄` (removed by
415///     background removal and referencing anyway).
416///   - ROMEO ([`unwrap_romeo_multi_echo`](crate::unwrap::unwrap_romeo_multi_echo) with
417///     `romeo_params`, as on the offset-removal path) fixes each echo up to a whole number of
418///     turns, so each echo's median wrap count relative to the TE-scaled previous echo is removed
419///     ([`correct_multi_echo_wraps`](crate::unwrap::correct_multi_echo_wraps)); this is a no-op
420///     when the echoes are already consistent (temporal unwrapping, or individual unwrapping with
421///     `correct_global`, which already ends with it). Kept for individual mode without global
422///     correction, where the authors' `calculateB0_unwrapped` would average echoes a turn apart.
423///
424///   Then `B0 = Σ w (φ/TE) / Σ w` with the variant's `weighting`.
425///
426///   This alignment is QSM.rs's own (kept, Ashley 2026-10-28): each echo's Laplacian unwrap is
427///   determined only up to a constant of its own, so the constants are removed before the
428///   echoes are combined, which loses only a global constant field. No reference combines
429///   Laplacian-unwrapped echoes this way. The MATLAB pipelines QSM.rs reproduces with other
430///   settings: UK Biobank (STI Suite `MRPhaseUnwrap` on the masked phase, then the T2*-weighted
431///   average; [`LaplacianSolver::Fft`](crate::unwrap::LaplacianSolver::Fft) `{ pad: [64; 3] }` with
432///   [`B0WeightType::AssumedDecay`], matched to ≈1e-14 rad up to a global constant, see
433///   [`run_field_mapping`]), QSMART (Hongfu Sun's `lapunwrap` per echo on the raw phase, then
434///   `echofit`; [`LaplacianSolver::Mirror`](crate::unwrap::LaplacianSolver::Mirror) with
435///   `laplacian_unmasked` and [`B0EstimationMethod::QsmartEchofit`], which aligns nothing) and
436///   STI Suite's GUI (`MRPhaseUnwrap` on the raw phase, echoes summed: `laplacian_unmasked` with
437///   [`B0WeightType::TEs`]). QSM.jl's README unwraps the raw phase and inverts each echo's field
438///   separately, so it has no echo combination to compare with.
439///   Measured on the QSM-CI in-vivo travelling-head scan (Prisma 2.89 T, 1 mm, 5 echoes)
440///   against QSM.jl's `unwrap_laplacian(…; solver = :dct)` with each echo scaled by its TE and
441///   combined by QSM.jl's `multi_echo_average` (`W` = magnitude), in the QSM-CI `julia-qsm`
442///   image (`sha256:b3c048ac…`): with the phase masked and each echo's masked mean removed on the
443///   QSM.jl side too, the field maps agree to NRMSE 1.4e-9 % (float32 output rounding); with
444///   QSM.jl's raw phase and no mean removal, NRMSE 46 % against this default and 28 % against
445///   `laplacian_unmasked: true`, the rest being the per-echo constants this alignment removes.
446/// * [`B0EstimationMethod::LinearFit`]: the magnitude-weighted fit of phase against TE with
447///   the variant's [`LinearFitParams`](crate::utils::LinearFitParams). With ROMEO it runs on the
448///   per-echo unwrapped phases as they come out of the unwrapper, which is what this path
449///   computed unconditionally before it honoured `b0_estimation` (bit-identical). With Laplacian
450///   the per-echo constants are aligned first, as for the weighted mean: a constant `cₑ` that
451///   differs between echoes is not linear in TE, so the fit turns it into a slope error, and
452///   because the fit is magnitude-weighted that error varies over the brain rather than being a
453///   constant that background removal would take out.
454///   With an intercept and two echoes the fit is the echo difference `(φ₂ − φ₁)/(TE₂ − TE₁)`,
455///   which discards the absolute phase, so it is only worth choosing when the echoes carry a
456///   phase offset that has not been removed.
457/// * [`B0EstimationMethod::RobustFit`]: the per-echo constants are aligned exactly as for the
458///   weighted mean (an unaligned constant is a departure from linearity in TE that the robust
459///   loss would blame on the echo), then the robust fit runs on the aligned echoes. Keep
460///   `estimate_offset` on unless the echoes are known to be offset-free (e.g. MCPC-3D-S
461///   combined): the offset removal that would otherwise take the intercept out does not run here.
462/// * [`B0EstimationMethod::QsmartEchofit`]: QSMART's `echofit.m` on the echoes as unwrapped. With
463///   Laplacian nothing is aligned, as in QSMART (whose `unwrap_phase` → `echofit` removes no
464///   per-echo constant), and with `laplacian_unmasked` every echo is kept over the whole volume,
465///   because `echofit.m` fits every voxel and takes its `R_0` threshold from all of them. With
466///   ROMEO, whole turns are aligned as for the weighted mean: a fit through the origin turns a
467///   turn between echoes into a slope error.
468#[allow(clippy::too_many_arguments)]
469fn field_mapping_direct(
470    phases: &[&[f64]],
471    mag_slices: &[&[f64]],
472    mask: &[u8],
473    metadata: &ScanMetadata,
474    config: &FieldMappingConfig,
475    _n_voxels: usize,
476    nx: usize, ny: usize, nz: usize,
477    vsx: f64, vsy: f64, vsz: f64,
478    progress: &mut dyn FnMut(usize, usize),
479) -> Result<FieldMappingResult, PipelineError> {
480    let tes = &metadata.echo_times;
481    let n_echoes = phases.len();
482
483    // Per-echo unwrapping. The Laplacian unwrap is a global Poisson solve, so phase noise
484    // outside the brain leaks into it; zero the phase outside the mask first (as STI Suite's
485    // `MRPhaseUnwrap(mask .* phase)` is called in the UK Biobank pipeline), unless
486    // `laplacian_unmasked` asks for the raw phase (STI Suite's GUI, QSMART).
487    progress(1, 4);
488    let is_laplacian = config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian;
489    let region = LaplacianRegion::new(config, mask);
490    // The magnitude the per-echo unwrappers are given: MEDI's iMag (root-sum-of-squares of the
491    // echoes) for MEDI's region growing, as on the other paths; unused by Laplacian and PRELUDE.
492    // ROMEO takes the multi-echo route below, with each echo's own.
493    let imag;
494    let quality: &[f64] = if config.unwrapping_algorithm == UnwrappingAlgorithm::MediRegionGrowing {
495        imag = rss_magnitude(mag_slices);
496        &imag
497    } else {
498        mag_slices.first().copied().unwrap_or(&[])
499    };
500    let mut unwrapped: Vec<Vec<f64>> = if config.unwrapping_algorithm == UnwrappingAlgorithm::Romeo {
501        // ROMEO.jl's multi-echo `unwrap` with the configured mode, as on the offset-removal path:
502        // each echo's own magnitude and, in individual mode, the previous echo as `phase2`
503        // (`src/unwrapping.jl`). Until 2026-11 this path unwrapped every echo with echo 1's
504        // magnitude and the *next* echo as `phase2`, whatever the mode (47 % of voxels > 0.01 ppm
505        // from ROMEO.jl on a GE scan, 7 % on a Siemens one; Q0748 audit, workstream C).
506        let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
507        crate::unwrap::unwrap_romeo_multi_echo(phases, mag_slices, tes, mask, &config.romeo_params, &grid)
508    } else {
509        (0..n_echoes)
510            .map(|e| {
511                let masked;
512                let phase: &[f64] = if region.zero_outside {
513                    masked = mask_phase(phases[e], mask);
514                    &masked
515                } else {
516                    phases[e]
517                };
518                unwrap_single(phase, quality, region.keep(mask), config, nx, ny, nz, vsx, vsy, vsz)
519            })
520            .collect()
521    };
522
523    progress(3, 4);
524    if let B0EstimationMethod::QsmartEchofit { fit_threshold } = config.b0_estimation {
525        if !is_laplacian {
526            align_per_echo_constants(&mut unwrapped, tes, mask, config.unwrapping_algorithm);
527        }
528        progress(4, 4);
529        return Ok(qsmart_fit(&unwrapped, mag_slices, mask, metadata, fit_threshold));
530    }
531    let mut robust_fit = None;
532    let b0_field_ppm = match &config.b0_estimation {
533        B0EstimationMethod::WeightedAvg { weighting } => {
534            align_per_echo_constants(&mut unwrapped, tes, mask, config.unwrapping_algorithm);
535            let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
536            let b0_hz = crate::utils::calculate_b0_weighted(
537                &unwrapped, mag_slices, tes, mask,
538                *weighting, &grid,
539            );
540            hz_to_ppm(&b0_hz, metadata.field_strength)
541        }
542        B0EstimationMethod::LinearFit(params) => {
543            // Not ROMEO's: its whole-turn alignment would change what this path has always
544            // computed for it. PRELUDE's and MEDI's region growing's echoes are unwrapped
545            // independently, so their turns are aligned as for the weighted mean.
546            if config.unwrapping_algorithm != UnwrappingAlgorithm::Romeo {
547                align_per_echo_constants(&mut unwrapped, tes, mask, config.unwrapping_algorithm);
548            }
549            let uw_refs: Vec<&[f64]> = unwrapped.iter().map(|u| u.as_slice()).collect();
550            let fit = crate::utils::multi_echo_linear_fit(
551                &uw_refs, mag_slices, tes, mask,
552                params.estimate_offset,
553            );
554            rads_to_ppm(&fit.field, metadata.field_strength)
555        }
556        B0EstimationMethod::RobustFit(params) => {
557            // Aligned first, as for the weighted mean. A per-echo constant left by the unwrapper is
558            // exactly what the robust loss is built to see as a bad echo — a departure from a
559            // straight line in TE at every voxel — so unaligned echoes would be down-weighted for
560            // the unwrapper's convention, not for anything in the data. Both alignments keep a
561            // series that is linear in TE linear in TE (Laplacian: the demeaned echo
562            // `φ₀ − φ̄₀ + TEₑ(ω − ω̄)` still has one intercept and one slope; ROMEO: whole turns
563            // only, and a no-op on consistent echoes), so the fit itself loses nothing but a
564            // global constant field.
565            align_per_echo_constants(&mut unwrapped, tes, mask, config.unwrapping_algorithm);
566            let (field_rads, report) = robust_fit_echoes(&unwrapped, mag_slices, tes, mask, params);
567            robust_fit = Some(report);
568            rads_to_ppm(&field_rads, metadata.field_strength)
569        }
570        B0EstimationMethod::QsmartEchofit { .. } => unreachable!("returned above"),
571        B0EstimationMethod::ComplexFit(_) => unreachable!("run_field_mapping takes the complex fit's own path"),
572    };
573
574    progress(4, 4);
575    Ok(FieldMappingResult {
576        b0_field_ppm,
577        valid_mask: mask.to_vec(),
578        phase_offset: None,
579        robust_fit,
580        field_noise_ppm: None,
581        complex_fit: None,
582        reliability: None,
583        qsmart_fit_threshold: None,
584    })
585}
586
587/// [`B0EstimationMethod::QsmartEchofit`] on unwrapped echoes: QSMART's `echofit.m`
588/// ([`qsmart_echofit`](crate::utils::qsmart_echofit)) with QSMART's γ, giving the total field
589/// over every voxel and the reliability map `R_0`.
590fn qsmart_fit(
591    unwrapped: &[Vec<f64>],
592    mag_slices: &[&[f64]],
593    valid_mask: &[u8],
594    metadata: &ScanMetadata,
595    fit_threshold: crate::utils::QsmartFitThreshold,
596) -> FieldMappingResult {
597    let uw: Vec<&[f64]> = unwrapped.iter().map(|u| u.as_slice()).collect();
598    let ppm = crate::utils::QSMART_GYRO * metadata.field_strength / 1e6;
599    let fit = crate::utils::qsmart_echofit(&uw, mag_slices, &metadata.echo_times, &metadata.grid(), ppm, fit_threshold);
600    FieldMappingResult {
601        b0_field_ppm: fit.field_ppm,
602        valid_mask: valid_mask.to_vec(),
603        phase_offset: None,
604        robust_fit: None,
605        reliability: Some(fit.reliability),
606        qsmart_fit_threshold: Some(fit.fit_threshold),
607        field_noise_ppm: None,
608        complex_fit: None,
609    }
610}
611
612/// Where Laplacian unwrapping works ([`FieldMappingConfig::laplacian_unmasked`]): on the phase
613/// zeroed outside the mask, keeping the result inside it (the default); or on the raw phase.
614/// The raw phase's result is kept over the whole volume only for QSMART's echo fit, which fits
615/// every voxel (`echofit.m`); the other combinations use only the voxels inside the mask.
616struct LaplacianRegion {
617    zero_outside: bool,
618    everywhere: Option<Vec<u8>>,
619}
620
621impl LaplacianRegion {
622    fn new(config: &FieldMappingConfig, mask: &[u8]) -> Self {
623        let unmasked = config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian && config.laplacian_unmasked;
624        let echofit = matches!(config.b0_estimation, B0EstimationMethod::QsmartEchofit { .. });
625        LaplacianRegion {
626            zero_outside: config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian && !unmasked,
627            everywhere: (unmasked && echofit).then(|| vec![1; mask.len()]),
628        }
629    }
630
631    /// The mask the unwrapped result is kept in.
632    fn keep<'a>(&'a self, mask: &'a [u8]) -> &'a [u8] {
633        self.everywhere.as_deref().unwrap_or(mask)
634    }
635}
636
637/// [`multi_echo_robust_fit`](crate::utils::multi_echo_robust_fit) on unwrapped echoes: the field
638/// in rad/s and the report. Echo count and params are checked by [`run_field_mapping`].
639fn robust_fit_echoes(
640    unwrapped: &[Vec<f64>],
641    mag_slices: &[&[f64]],
642    tes: &[f64],
643    mask: &[u8],
644    params: &crate::utils::RobustFitParams,
645) -> (Vec<f64>, RobustFitReport) {
646    let r = crate::utils::multi_echo_robust_fit(unwrapped, mag_slices, tes, mask, params);
647    (r.fit.field, RobustFitReport { quality: r.quality, robust_weights: r.robust_weights })
648}
649
650/// `phase` with every voxel outside `mask` set to zero.
651fn mask_phase(phase: &[f64], mask: &[u8]) -> Vec<f64> {
652    phase.iter().zip(mask).map(|(&p, &m)| if m != 0 { p } else { 0.0 }).collect()
653}
654
655/// Remove, from each separately unwrapped echo, the constant its unwrapper leaves undetermined,
656/// so that the echoes agree before they are averaged. See [`field_mapping_direct`].
657fn align_per_echo_constants(
658    unwrapped: &mut [Vec<f64>],
659    tes: &[f64],
660    mask: &[u8],
661    algorithm: UnwrappingAlgorithm,
662) {
663    match algorithm {
664        UnwrappingAlgorithm::Laplacian => {
665            for u in unwrapped.iter_mut() {
666                remove_masked_mean(u, mask);
667            }
668        }
669        // Each echo's spatial unwrap leaves a whole number of turns undetermined.
670        UnwrappingAlgorithm::Romeo | UnwrappingAlgorithm::Prelude | UnwrappingAlgorithm::MediRegionGrowing => {
671            crate::unwrap::correct_multi_echo_wraps(unwrapped, tes, mask);
672        }
673    }
674}
675
676/// Subtract the mean over `mask` from `values` inside `mask` (outside is left untouched).
677fn remove_masked_mean(values: &mut [f64], mask: &[u8]) {
678    let (sum, n) = values.iter().zip(mask)
679        .filter(|(_, &m)| m != 0)
680        .fold((0.0, 0usize), |(s, n), (&v, _)| (s + v, n + 1));
681    if n == 0 {
682        return;
683    }
684    let mean = sum / n as f64;
685    for (v, &m) in values.iter_mut().zip(mask) {
686        if m != 0 {
687            *v -= mean;
688        }
689    }
690}
691
692/// Path C: Single echo unwrap
693#[allow(clippy::too_many_arguments)]
694fn field_mapping_single_echo(
695    phase: &[f64],
696    mag: &[f64],
697    mask: &[u8],
698    metadata: &ScanMetadata,
699    config: &FieldMappingConfig,
700    _n_voxels: usize,
701    nx: usize, ny: usize, nz: usize,
702    vsx: f64, vsy: f64, vsz: f64,
703    progress: &mut dyn FnMut(usize, usize),
704) -> Result<FieldMappingResult, PipelineError> {
705    progress(1, 4);
706    let unwrapped = unwrap_one_echo(phase, mag, mask, config, nx, ny, nz, vsx, vsy, vsz);
707    if let B0EstimationMethod::QsmartEchofit { fit_threshold } = config.b0_estimation {
708        progress(4, 4);
709        return Ok(qsmart_fit(&[unwrapped], &[mag], mask, metadata, fit_threshold));
710    }
711    progress(4, 4);
712    Ok(single_echo_result(&unwrapped, mask, metadata.echo_times[0], metadata.field_strength, config))
713}
714
715/// One echo's phase, unwrapped. Laplacian: as on the multi-echo path, the phase is zeroed outside
716/// the mask first (a global Poisson solve otherwise carries the noise there into the brain; not
717/// with `laplacian_unmasked`).
718#[allow(clippy::too_many_arguments)]
719fn unwrap_one_echo(
720    phase: &[f64],
721    mag: &[f64],
722    mask: &[u8],
723    config: &FieldMappingConfig,
724    nx: usize, ny: usize, nz: usize,
725    vsx: f64, vsy: f64, vsz: f64,
726) -> Vec<f64> {
727    let region = LaplacianRegion::new(config, mask);
728    let masked;
729    let phase: &[f64] = if region.zero_outside {
730        masked = mask_phase(phase, mask);
731        &masked
732    } else {
733        phase
734    };
735    unwrap_single(phase, mag, region.keep(mask), config, nx, ny, nz, vsx, vsy, vsz)
736}
737
738/// One echo's field from its unwrapped phase: the masked mean a Laplacian unwrap leaves
739/// undetermined removed (so one echo gives the same field as each echo of a series does), then
740/// divided by the echo time.
741fn single_echo_result(
742    unwrapped: &[f64],
743    mask: &[u8],
744    te: f64,
745    field_strength: f64,
746    config: &FieldMappingConfig,
747) -> FieldMappingResult {
748    let mut unwrapped = unwrapped.to_vec();
749    if config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian {
750        remove_masked_mean(&mut unwrapped, mask);
751    }
752    // field = unwrapped / TE → rad/s
753    let field_rads: Vec<f64> = unwrapped.iter().map(|&v| v / te).collect();
754    FieldMappingResult {
755        b0_field_ppm: rads_to_ppm(&field_rads, field_strength),
756        valid_mask: mask.to_vec(),
757        phase_offset: None,
758        robust_fit: None,
759        reliability: None,
760        qsmart_fit_threshold: None,
761        field_noise_ppm: None,
762        complex_fit: None,
763    }
764}
765
766/// The field of **each echo on its own**, for a per-echo reconstruction
767/// ([`QsmPipelineConfig::qsm_per_echo`](super::config::QsmPipelineConfig::qsm_per_echo); see
768/// [`super::per_echo`]): echo `e`'s phase is unwrapped with
769/// [`FieldMappingConfig::unwrapping_algorithm`] (ROMEO with that echo's magnitude) and divided by
770/// its own echo time, exactly as a single-echo series is ([`run_field_mapping`] with one echo).
771/// The echo combination ([`FieldMappingConfig::b0_estimation`]), phase-offset removal and bipolar
772/// correction combine or compare echoes, so they do not apply and are not run. This is the
773/// DECOMPOSE authors' per-echo field: `MRPhaseUnwrap` of each echo's raw phase, then that echo's
774/// background removal and STAR-QSM at its TE (Chen et al. 2021, NeuroImage 242:118477, §3.6).
775///
776/// Returns one [`FieldMappingResult`] per echo, in echo order.
777pub fn run_field_mapping_per_echo(
778    phases: &[&[f64]],
779    magnitudes: Option<&[&[f64]]>,
780    mask: &[u8],
781    metadata: &ScanMetadata,
782    config: &FieldMappingConfig,
783    progress: &mut dyn FnMut(usize, usize),
784) -> Result<Vec<FieldMappingResult>, PipelineError> {
785    let (nx, ny, nz) = metadata.dims;
786    let (vsx, vsy, vsz) = metadata.voxel_size;
787    let n_voxels = nx * ny * nz;
788    let n_echoes = phases.len();
789    if n_echoes == 0 {
790        return Err(PipelineError::InvalidInput("no phase echoes provided".into()));
791    }
792    if metadata.echo_times.len() != n_echoes {
793        return Err(PipelineError::DimensionMismatch { expected: n_echoes, got: metadata.echo_times.len() });
794    }
795    if mask.len() != n_voxels {
796        return Err(PipelineError::DimensionMismatch { expected: n_voxels, got: mask.len() });
797    }
798    require_magnitude_for_medi(config, magnitudes)?;
799    let uniform = vec![1.0f64; n_voxels];
800    let mut out = Vec::with_capacity(n_echoes);
801    for (e, p) in phases.iter().enumerate() {
802        let mag: &[f64] = magnitudes.and_then(|m| m.get(e).copied()).unwrap_or(&uniform);
803        if p.len() != n_voxels || mag.len() != n_voxels {
804            return Err(PipelineError::DimensionMismatch { expected: n_voxels, got: p.len().min(mag.len()) });
805        }
806        progress(e, n_echoes);
807        let te = metadata.echo_times[e];
808        let unwrapped = unwrap_one_echo(p, mag, mask, config, nx, ny, nz, vsx, vsy, vsz);
809        out.push(single_echo_result(&unwrapped, mask, te, metadata.field_strength, config));
810    }
811    progress(n_echoes, n_echoes);
812    Ok(out)
813}
814
815/// [`B0EstimationMethod::ComplexFit`]: MEDI's field map. `Fit_ppm_complex` on the wrapped
816/// complex echoes gives the phase per echo spacing, wrapped; it is unwrapped spatially, as MEDI's
817/// `iFreq = unwrapPhase(iMag, iFreq_raw, matrix_size)` does, and divided by the echo spacing.
818/// The noise map (`dp1`, MEDI's `N_std`) is converted the same way, to ppm. The fit itself is
819/// returned too ([`FieldMappingResult::complex_fit`]: `iFreq_raw` and `N_std` in MEDI's units).
820///
821/// The configured `unwrapping_algorithm` unwraps it. [`UnwrappingAlgorithm::MediRegionGrowing`]
822/// is MEDI's own `unwrapPhase(iMag, iFreq_raw, matrix_size)`, with `iMag` the root-sum-of-squares
823/// of the echo magnitudes and no mask, so with it this is MEDI's field map (QSM-CI's
824/// `fit-ppm-complex-cornell`; `tests/medi_unwrap_cornell_parity.rs`). ROMEO gets the same `iMag`
825/// as its magnitude. Laplacian unwrapping is done as on the other paths: phase zeroed outside the
826/// mask first, masked mean removed after (it leaves a constant undetermined).
827fn field_mapping_complex_fit(
828    phases: &[&[f64]],
829    magnitudes: Option<&[&[f64]]>,
830    mask: &[u8],
831    metadata: &ScanMetadata,
832    config: &FieldMappingConfig,
833    params: &crate::utils::FitPpmComplexParams,
834    progress: &mut dyn FnMut(usize, usize),
835) -> Result<FieldMappingResult, PipelineError> {
836    let mags = magnitudes.ok_or_else(|| PipelineError::InvalidInput(
837        "the complex fit (Fit_ppm_complex) needs the magnitude of every echo".into(),
838    ))?;
839    if mags.len() != phases.len() {
840        return Err(PipelineError::DimensionMismatch { expected: phases.len(), got: mags.len() });
841    }
842    if config.bipolar_correction {
843        return Err(PipelineError::InvalidConfig(
844            "bipolar correction is not available with the complex fit (MEDI's \
845             Fit_ppm_complex_bipolar is not ported)".into(),
846        ));
847    }
848    progress(1, 4);
849    let fit = crate::utils::fit_ppm_complex(mags, phases, &metadata.echo_times, params)?;
850
851    progress(2, 4);
852    let (nx, ny, nz) = metadata.dims;
853    let (vsx, vsy, vsz) = metadata.voxel_size;
854    let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
855    let unwrapped = match config.unwrapping_algorithm {
856        UnwrappingAlgorithm::Laplacian => {
857            let mut u = crate::unwrap::laplacian_unwrap(
858                &mask_phase(&fit.field, mask), mask, &grid, config.laplacian_solver,
859            );
860            remove_masked_mean(&mut u, mask);
861            u
862        }
863        UnwrappingAlgorithm::Romeo => {
864            crate::unwrap::unwrap_romeo(
865                &fit.field, &rss_magnitude(mags), None, fit.echo_spacing, 0.0,
866                mask, &config.romeo_params, &grid,
867            )
868        }
869        UnwrappingAlgorithm::Prelude => {
870            crate::unwrap::unwrap_prelude(&fit.field, mask, &grid, &config.prelude_params)
871        }
872        // iFreq = unwrapPhase(iMag, iFreq_raw, matrix_size): no mask.
873        UnwrappingAlgorithm::MediRegionGrowing => {
874            crate::unwrap::unwrap_medi_region_growing(&fit.field, &rss_magnitude(mags), &grid)
875        }
876    };
877
878    progress(3, 4);
879    let per_second = |v: &[f64]| -> Vec<f64> { v.iter().map(|&x| x / fit.echo_spacing).collect() };
880    let b0_field_ppm = rads_to_ppm(&per_second(&unwrapped), metadata.field_strength);
881    let field_noise_ppm = rads_to_ppm(&per_second(&fit.noise), metadata.field_strength);
882    progress(4, 4);
883    Ok(FieldMappingResult {
884        b0_field_ppm,
885        valid_mask: mask.to_vec(),
886        phase_offset: None,
887        robust_fit: None,
888        reliability: None,
889        qsmart_fit_threshold: None,
890        field_noise_ppm: Some(field_noise_ppm),
891        complex_fit: Some(fit),
892    })
893}
894
895/// Unwrap a single echo on its own using the configured algorithm (ROMEO without `phase2`).
896#[allow(clippy::too_many_arguments)]
897fn unwrap_single(
898    phase: &[f64],
899    mag: &[f64],
900    mask: &[u8],
901    config: &FieldMappingConfig,
902    nx: usize, ny: usize, nz: usize,
903    vsx: f64, vsy: f64, vsz: f64,
904) -> Vec<f64> {
905    let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
906    match config.unwrapping_algorithm {
907        UnwrappingAlgorithm::Laplacian => {
908            crate::unwrap::laplacian_unwrap(phase, mask, &grid, config.laplacian_solver)
909        }
910        UnwrappingAlgorithm::Romeo => {
911            crate::unwrap::unwrap_romeo(
912                phase, mag, None, 0.0, 0.0,
913                mask, &config.romeo_params, &grid,
914            )
915        }
916        UnwrappingAlgorithm::Prelude => {
917            crate::unwrap::unwrap_prelude(phase, mask, &grid, &config.prelude_params)
918        }
919        // No mask, as MEDI's unwrapPhase; `mag` is the quality map the caller chose.
920        UnwrappingAlgorithm::MediRegionGrowing => {
921            crate::unwrap::unwrap_medi_region_growing(phase, mag, &grid)
922        }
923    }
924}
925
926/// MEDI's `iMag = sqrt(sum(abs(iField).^2, 4))`: the root-sum-of-squares of the echo magnitudes.
927fn rss_magnitude(mags: &[&[f64]]) -> Vec<f64> {
928    let n = mags.first().map_or(0, |m| m.len());
929    (0..n).map(|i| mags.iter().map(|m| m[i] * m[i]).sum::<f64>().sqrt()).collect()
930}
931
932/// MEDI's region growing orders its growth by the magnitude and has no fallback without one
933/// (`unwrapPhase(iMag, ...)`), so a run without magnitudes is refused rather than grown over a
934/// uniform magnitude, where every voxel would tie.
935fn require_magnitude_for_medi(
936    config: &FieldMappingConfig,
937    magnitudes: Option<&[&[f64]]>,
938) -> Result<(), PipelineError> {
939    if config.unwrapping_algorithm == UnwrappingAlgorithm::MediRegionGrowing && magnitudes.is_none() {
940        return Err(PipelineError::InvalidInput(
941            "MEDI's region-growing unwrapping needs the magnitude".into(),
942        ));
943    }
944    Ok(())
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950    use crate::utils::{LinearFitParams, RobustFitParams};
951    use crate::unwrap::LaplacianSolver;
952    use std::f64::consts::PI;
953
954    fn make_test_metadata(n_echoes: usize) -> ScanMetadata {
955        let tes: Vec<f64> = (0..n_echoes).map(|i| 0.005 + 0.005 * i as f64).collect();
956        ScanMetadata {
957            dims: (8, 8, 8),
958            voxel_size: (1.0, 1.0, 1.0),
959            echo_times: tes,
960            field_strength: 3.0,
961            b0_direction: (0.0, 0.0, 1.0),
962            slice_geometry: None,
963            affine: None,
964        }
965    }
966
967    /// PRELUDE in the field-mapping stage: each echo unwrapped spatially, whole turns aligned
968    /// between echoes, then every echo combination. A wrapped linear field whose median wrap
969    /// count is 0 in every echo comes back exactly, on every path.
970    #[test]
971    fn prelude_recovers_a_wrapped_field_on_every_path() {
972        let (nx, ny, nz) = (16, 12, 6);
973        let n = nx * ny * nz;
974        let tes = vec![0.005, 0.010, 0.015, 0.020];
975        let meta = ScanMetadata {
976            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0), echo_times: tes.clone(),
977            field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0), slice_geometry: None, affine: None,
978        };
979        // rad/s, symmetric about 0, wrapping several times by the last echo.
980        let freq: Vec<f64> = (0..n).map(|i| 90.0 * ((i % nx) as f64 - 7.5) + 30.0 * ((i / nx % ny) as f64 - 5.5)).collect();
981        let phases: Vec<Vec<f64>> = tes.iter()
982            .map(|&te| freq.iter().map(|f| { let t = f * te; t.sin().atan2(t.cos()) }).collect())
983            .collect();
984        assert!(phases[3].iter().zip(&freq).any(|(p, f)| (p - f * 0.020).abs() > 1.0), "the field does not wrap");
985        let p: Vec<&[f64]> = phases.iter().map(|v| v.as_slice()).collect();
986        let ones = vec![1.0; n];
987        let mags: Vec<&[f64]> = (0..4).map(|_| ones.as_slice()).collect();
988        let mask = vec![1u8; n];
989        let truth = rads_to_ppm(&freq, 3.0);
990        let base = FieldMappingConfig { unwrapping_algorithm: UnwrappingAlgorithm::Prelude, phase_offset_removal: false, ..Default::default() };
991        let close = |got: &[f64], tol: f64, what: &str| {
992            let worst = got.iter().zip(&truth).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
993            assert!(worst < tol, "{what}: off by {worst} ppm");
994        };
995        for b0 in [
996            B0EstimationMethod::default(),
997            B0EstimationMethod::LinearFit(LinearFitParams { estimate_offset: false }),
998            B0EstimationMethod::RobustFit(RobustFitParams::default()),
999        ] {
1000            let config = FieldMappingConfig { b0_estimation: b0.clone(), ..base.clone() };
1001            let got = run_field_mapping(&p, Some(&mags), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1002            close(&got.b0_field_ppm, 1e-9, b0.id());
1003        }
1004        // MCPC-3D-S first: its smoothed offset estimate is not exactly zero at the edges.
1005        let offset = FieldMappingConfig { phase_offset_removal: true, ..base.clone() };
1006        close(&run_field_mapping(&p, Some(&mags), &mask, &meta, &offset, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-3, "mcpc-3d-s");
1007        // One echo, and the complex fit (unwrapped once, per echo spacing).
1008        close(&run_field_mapping(&p[..1], Some(&mags[..1]), &mask, &meta.echo(0), &base, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-9, "single echo");
1009        let complex = FieldMappingConfig { b0_estimation: B0EstimationMethod::ComplexFit(Default::default()), ..base.clone() };
1010        close(&run_field_mapping(&p, Some(&mags), &mask, &meta, &complex, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-3, "complex fit");
1011        // The settings reach the unwrapper: a single partition labelled over the volume makes one
1012        // region, which unwraps nothing.
1013        let one = FieldMappingConfig {
1014            prelude_params: crate::unwrap::PreludeParams {
1015                num_phase_partitions: 1,
1016                labelling: crate::unwrap::PreludeLabelling::Volume,
1017                correct_global: false,
1018            },
1019            ..base.clone()
1020        };
1021        let got = run_field_mapping(&p[..1], None, &mask, &meta.echo(0), &one, &mut |_, _| {}).unwrap();
1022        assert_eq!(got.b0_field_ppm, rads_to_ppm(&p[0].iter().map(|v| v / tes[0]).collect::<Vec<_>>(), 3.0));
1023    }
1024
1025    /// MEDI's region growing in the field-mapping stage, on every path: each echo unwrapped over
1026    /// the whole array (no mask) and whole turns aligned between echoes, a single echo, and the
1027    /// complex fit unwrapped once. The seed's phase is inside (-π, π] in every echo, so the field
1028    /// comes back to single-precision rounding. Without the magnitude it is refused.
1029    #[test]
1030    fn medi_region_growing_recovers_a_wrapped_field_on_every_path() {
1031        let (nx, ny, nz) = (16, 12, 6);
1032        let n = nx * ny * nz;
1033        let tes = vec![0.005, 0.010, 0.015, 0.020];
1034        let meta = ScanMetadata {
1035            dims: (nx, ny, nz), voxel_size: (1.0, 1.0, 1.0), echo_times: tes.clone(),
1036            field_strength: 3.0, b0_direction: (0.0, 0.0, 1.0), slice_geometry: None, affine: None,
1037        };
1038        let freq: Vec<f64> = (0..n).map(|i| 90.0 * ((i % nx) as f64 - 7.5) + 30.0 * ((i / nx % ny) as f64 - 5.5)).collect();
1039        let phases: Vec<Vec<f64>> = tes.iter()
1040            .map(|&te| freq.iter().map(|f| { let t = f * te; t.sin().atan2(t.cos()) }).collect())
1041            .collect();
1042        let p: Vec<&[f64]> = phases.iter().map(|v| v.as_slice()).collect();
1043        let mag: Vec<f64> = (0..n).map(|i| 1.0 + (i % 5) as f64).collect();
1044        let mags: Vec<&[f64]> = (0..4).map(|_| mag.as_slice()).collect();
1045        // The mask plays no part in the unwrapping; it only limits the echo combination.
1046        let mut mask = vec![0u8; n];
1047        mask[n / 4..3 * n / 4].fill(1);
1048        let truth = rads_to_ppm(&freq, 3.0);
1049        let base = FieldMappingConfig {
1050            unwrapping_algorithm: UnwrappingAlgorithm::MediRegionGrowing, phase_offset_removal: false, ..Default::default()
1051        };
1052        let close = |got: &[f64], tol: f64, what: &str| {
1053            let worst = (0..n).filter(|&i| mask[i] != 0).map(|i| (got[i] - truth[i]).abs()).fold(0.0, f64::max);
1054            assert!(worst < tol, "{what}: off by {worst} ppm");
1055        };
1056        for b0 in [
1057            B0EstimationMethod::default(),
1058            B0EstimationMethod::LinearFit(LinearFitParams { estimate_offset: false }),
1059            B0EstimationMethod::RobustFit(RobustFitParams::default()),
1060        ] {
1061            let config = FieldMappingConfig { b0_estimation: b0.clone(), ..base.clone() };
1062            let got = run_field_mapping(&p, Some(&mags), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1063            close(&got.b0_field_ppm, 1e-6, b0.id());
1064        }
1065        let offset = FieldMappingConfig { phase_offset_removal: true, ..base.clone() };
1066        close(&run_field_mapping(&p, Some(&mags), &mask, &meta, &offset, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-3, "mcpc-3d-s");
1067        close(&run_field_mapping(&p[..1], Some(&mags[..1]), &mask, &meta.echo(0), &base, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-6, "single echo");
1068        let complex = FieldMappingConfig { b0_estimation: B0EstimationMethod::ComplexFit(Default::default()), ..base.clone() };
1069        close(&run_field_mapping(&p, Some(&mags), &mask, &meta, &complex, &mut |_, _| {}).unwrap().b0_field_ppm, 1e-3, "complex fit");
1070        // The complex fit is unwrapped as MEDI's unwrapPhase(iMag, iFreq_raw, matrix_size).
1071        let got = run_field_mapping(&p, Some(&mags), &mask, &meta, &complex, &mut |_, _| {}).unwrap();
1072        let fit = got.complex_fit.as_ref().unwrap();
1073        let imag: Vec<f64> = mag.iter().map(|m| (4.0 * m * m).sqrt()).collect();
1074        let ifreq = crate::unwrap::unwrap_medi_region_growing(&fit.field, &imag, &meta.grid());
1075        assert_eq!(got.b0_field_ppm, rads_to_ppm(&ifreq.iter().map(|v| v / fit.echo_spacing).collect::<Vec<_>>(), 3.0));
1076        for e in [
1077            run_field_mapping(&p, None, &mask, &meta, &base, &mut |_, _| {}).err().unwrap(),
1078            run_field_mapping_per_echo(&p, None, &mask, &meta, &base, &mut |_, _| {}).err().unwrap(),
1079        ] {
1080            assert!(matches!(e, PipelineError::InvalidInput(_)), "{e:?}");
1081        }
1082    }
1083
1084    #[test]
1085    fn qsmart_echofit_is_unmasked_laplacian_then_echofit() {
1086        // QSMART's route, from the regular settings: every echo's raw phase unwrapped over the
1087        // whole volume with the mirror solver (the mask plays no part), then qsmart_echofit,
1088        // which also returns R_0. Bit for bit what QSMART's own route computed.
1089        let meta = make_test_metadata(3);
1090        let n = 8 * 8 * 8;
1091        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1092            .map(|te| (0..n).map(|i| { let t = 400.0 * te * (1.0 + (i as f64 * 0.37).sin()) + 0.2 * (i as f64).cos(); t.sin().atan2(t.cos()) }).collect())
1093            .collect();
1094        let mags: Vec<Vec<f64>> = (0..3).map(|e| (0..n).map(|i| 1.0 + ((i + e) % 5) as f64).collect()).collect();
1095        let p: Vec<&[f64]> = phases.iter().map(|v| v.as_slice()).collect();
1096        let m: Vec<&[f64]> = mags.iter().map(|v| v.as_slice()).collect();
1097        let mut mask = vec![0u8; n];
1098        mask[100..300].fill(1);
1099        let grid = meta.grid();
1100        let uw: Vec<Vec<f64>> = p.iter().map(|ph| crate::unwrap::laplacian_unwrap(ph, &vec![1; n], &grid, LaplacianSolver::Mirror)).collect();
1101        let uw: Vec<&[f64]> = uw.iter().map(|v| v.as_slice()).collect();
1102        let ppm = crate::utils::QSMART_GYRO * 3.0 / 1e6;
1103        // A threshold at the median residual, so R_0 has both values.
1104        let mut blur = crate::utils::qsmart_echofit(&uw, &m, &meta.echo_times, &grid, ppm, crate::utils::QsmartFitThreshold::Absolute(1.0)).fit_residual_blur;
1105        blur.sort_by(f64::total_cmp);
1106        let thr = blur[n / 2];
1107        let want = crate::utils::qsmart_echofit(&uw, &m, &meta.echo_times, &grid, ppm, crate::utils::QsmartFitThreshold::Absolute(thr));
1108        let config = FieldMappingConfig {
1109            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1110            laplacian_solver: LaplacianSolver::Mirror,
1111            laplacian_unmasked: true,
1112            b0_estimation: B0EstimationMethod::QsmartEchofit { fit_threshold: crate::utils::QsmartFitThreshold::Absolute(thr) },
1113            ..Default::default()
1114        };
1115        let got = run_field_mapping(&p, Some(&m), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1116        assert_eq!(got.b0_field_ppm, want.field_ppm);
1117        assert_eq!(got.reliability, Some(want.reliability));
1118        assert!(got.reliability.as_ref().unwrap().contains(&0) && got.reliability.as_ref().unwrap().contains(&1));
1119        assert_eq!(got.qsmart_fit_threshold, Some(thr));
1120        // An adaptive threshold is reported as the residual the percentile resolved to.
1121        let pct = crate::utils::QsmartFitThreshold::Percentile(99.0);
1122        let want_pct = crate::utils::qsmart_echofit(&uw, &m, &meta.echo_times, &grid, ppm, pct);
1123        let config_pct = FieldMappingConfig { b0_estimation: B0EstimationMethod::QsmartEchofit { fit_threshold: pct }, ..config.clone() };
1124        let got_pct = run_field_mapping(&p, Some(&m), &mask, &meta, &config_pct, &mut |_, _| {}).unwrap();
1125        assert_eq!(got_pct.qsmart_fit_threshold, Some(want_pct.fit_threshold));
1126        assert!(want_pct.fit_threshold > 0.0 && want_pct.fit_threshold != thr);
1127
1128        let bad = FieldMappingConfig { b0_estimation: B0EstimationMethod::QsmartEchofit { fit_threshold: crate::utils::QsmartFitThreshold::Percentile(0.0) }, ..config.clone() };
1129        assert!(run_field_mapping(&p, Some(&m), &mask, &meta, &bad, &mut |_, _| {}).is_err());
1130
1131        // The phase masked first (the default) is a different unwrap, kept inside the mask.
1132        let masked = FieldMappingConfig { laplacian_unmasked: false, ..config.clone() };
1133        let got_masked = run_field_mapping(&p, Some(&m), &mask, &meta, &masked, &mut |_, _| {}).unwrap();
1134        assert_ne!(got_masked.b0_field_ppm, want.field_ppm);
1135        assert!(got_masked.b0_field_ppm.iter().zip(&mask).all(|(&f, &k)| k != 0 || f == 0.0));
1136
1137        // ROMEO, with or without phase-offset removal or bipolar correction, feeds the same fit:
1138        // a departure from QSMART, not a refusal.
1139        for other in [
1140            FieldMappingConfig { unwrapping_algorithm: UnwrappingAlgorithm::Romeo, ..config.clone() },
1141            FieldMappingConfig { unwrapping_algorithm: UnwrappingAlgorithm::Romeo, phase_offset_removal: false, ..config.clone() },
1142            FieldMappingConfig { unwrapping_algorithm: UnwrappingAlgorithm::Romeo, bipolar_correction: true, ..config.clone() },
1143        ] {
1144            let got = run_field_mapping(&p, Some(&m), &mask, &meta, &other, &mut |_, _| {}).unwrap();
1145            assert!(got.reliability.is_some() && got.qsmart_fit_threshold.is_some(), "{other:?}");
1146            assert!(got.b0_field_ppm.iter().all(|f| f.is_finite()));
1147        }
1148        // Every other method leaves R_0 out.
1149        let linear = FieldMappingConfig { b0_estimation: B0EstimationMethod::LinearFit(LinearFitParams::default()), ..config };
1150        let got_linear = run_field_mapping(&p, Some(&m), &mask, &meta, &linear, &mut |_, _| {}).unwrap();
1151        assert!(got_linear.reliability.is_none() && got_linear.qsmart_fit_threshold.is_none());
1152    }
1153
1154    #[test]
1155    fn qsmart_echofit_on_one_echo_is_unmasked_laplacian_then_echofit() {
1156        let meta = make_test_metadata(1);
1157        let n = 8 * 8 * 8;
1158        let phase: Vec<f64> = (0..n).map(|i| { let t = 2.0 * (1.0 + (i as f64 * 0.37).sin()); t.sin().atan2(t.cos()) }).collect();
1159        let mag: Vec<f64> = (0..n).map(|i| 1.0 + (i % 5) as f64).collect();
1160        let mut mask = vec![0u8; n];
1161        mask[100..300].fill(1);
1162        let grid = meta.grid();
1163        let uw = crate::unwrap::laplacian_unwrap(&phase, &vec![1; n], &grid, LaplacianSolver::Mirror);
1164        let fit_threshold = crate::utils::QsmartFitThreshold::default();
1165        let want = crate::utils::qsmart_echofit(&[&uw], &[&mag], &meta.echo_times, &grid, crate::utils::QSMART_GYRO * 3.0 / 1e6, fit_threshold);
1166        let config = FieldMappingConfig {
1167            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1168            laplacian_solver: LaplacianSolver::Mirror,
1169            laplacian_unmasked: true,
1170            b0_estimation: B0EstimationMethod::QsmartEchofit { fit_threshold },
1171            ..Default::default()
1172        };
1173        let got = run_field_mapping(&[&phase], Some(&[&mag]), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1174        assert_eq!(got.b0_field_ppm, want.field_ppm);
1175        assert_eq!(got.reliability, Some(want.reliability));
1176    }
1177
1178    /// Unmasked Laplacian unwrapping solves on the raw phase; the other combinations still keep
1179    /// the field inside the mask.
1180    #[test]
1181    fn laplacian_unmasked_unwraps_the_raw_phase() {
1182        let meta = make_test_metadata(3);
1183        let n = 8 * 8 * 8;
1184        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1185            .map(|te| (0..n).map(|i| { let t = 400.0 * te * (1.0 + (i as f64 * 0.37).sin()); t.sin().atan2(t.cos()) }).collect())
1186            .collect();
1187        let p: Vec<&[f64]> = phases.iter().map(|v| v.as_slice()).collect();
1188        let mut mask = vec![0u8; n];
1189        mask[100..300].fill(1);
1190        let grid = meta.grid();
1191        let base = FieldMappingConfig { unwrapping_algorithm: UnwrappingAlgorithm::Laplacian, ..Default::default() };
1192        let unmasked = FieldMappingConfig { laplacian_unmasked: true, ..base.clone() };
1193        let got = run_field_mapping(&p, None, &mask, &meta, &unmasked, &mut |_, _| {}).unwrap().b0_field_ppm;
1194        // As the multi-echo path computes it, from echoes unwrapped on the raw phase.
1195        let mut uw: Vec<Vec<f64>> = p.iter().map(|ph| crate::unwrap::laplacian_unwrap(ph, &mask, &grid, LaplacianSolver::Dct)).collect();
1196        align_per_echo_constants(&mut uw, &meta.echo_times, &mask, UnwrappingAlgorithm::Laplacian);
1197        let ones = vec![1.0; n];
1198        let mags: Vec<&[f64]> = (0..3).map(|_| ones.as_slice()).collect();
1199        let want = hz_to_ppm(&crate::utils::calculate_b0_weighted(&uw, &mags, &meta.echo_times, &mask, B0WeightType::PhaseSNR, &grid), 3.0);
1200        assert_eq!(got, want);
1201        let masked = run_field_mapping(&p, None, &mask, &meta, &base, &mut |_, _| {}).unwrap().b0_field_ppm;
1202        assert_ne!(got, masked);
1203        assert!(got.iter().zip(&mask).all(|(&f, &k)| k != 0 || f == 0.0));
1204    }
1205
1206    #[test]
1207    fn test_single_echo_recovers_frequency() {
1208        // Use ROMEO (not Laplacian, which removes constant component)
1209        // Uniform phase across all voxels → uniform B0
1210        // phase(rad) = 2π * f * TE  →  f = phase / (2π * TE)
1211        let meta = make_test_metadata(1);
1212        let te = meta.echo_times[0]; // 0.005 s
1213        let n = 8 * 8 * 8;
1214        let mask = vec![1u8; n];
1215
1216        let freq_hz = 50.0;
1217        let phase_val = 2.0 * PI * freq_hz * te; // ~1.57 rad (no wrapping)
1218        let phase = vec![phase_val; n];
1219
1220        let config = FieldMappingConfig {
1221            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1222            ..Default::default()
1223        };
1224
1225        let phase_s: &[f64] = &phase;
1226        let result = run_field_mapping(
1227            &[phase_s], None, &mask, &meta, &config, &mut |_, _| {},
1228        ).unwrap();
1229
1230        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1231        let expected_ppm = freq_hz * 1e6 / (gamma * 3.0);
1232
1233        // ROMEO preserves the constant phase, so B0 should be recovered
1234        for &v in &result.b0_field_ppm {
1235            assert!((v - expected_ppm).abs() < 0.01,
1236                "expected ~{:.4} ppm, got {:.4}", expected_ppm, v);
1237        }
1238    }
1239
1240    #[test]
1241    fn test_multi_echo_direct_recovers_slope() {
1242        // 3 echoes with phase = slope * TE (no wrapping, small slope)
1243        // Use ROMEO so constant field is preserved
1244        let meta = make_test_metadata(3); // TEs: 0.005, 0.010, 0.015
1245        let n = 8 * 8 * 8;
1246        let mask = vec![1u8; n];
1247        let slope = 100.0; // rad/s → f ≈ 15.9 Hz
1248
1249        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1250            .map(|&te| vec![slope * te; n])
1251            .collect();
1252        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
1253        let mag = vec![1.0; n];
1254        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
1255
1256        let config = FieldMappingConfig {
1257            phase_offset_removal: false,
1258            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1259            ..Default::default()
1260        };
1261
1262        let result = run_field_mapping(
1263            &phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {},
1264        ).unwrap();
1265
1266        // Expected ppm: slope(rad/s) → ppm via rads_to_ppm
1267        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1268        let expected_ppm = slope * 1e6 / (2.0 * PI * gamma * 3.0);
1269
1270        let masked_values: Vec<f64> = result.b0_field_ppm.iter()
1271            .zip(mask.iter())
1272            .filter(|(_, &m)| m > 0)
1273            .map(|(&v, _)| v)
1274            .collect();
1275
1276        let mean: f64 = masked_values.iter().sum::<f64>() / masked_values.len() as f64;
1277        assert!((mean - expected_ppm).abs() < 0.001,
1278            "expected ~{:.6} ppm, got {:.6}", expected_ppm, mean);
1279    }
1280
1281    #[test]
1282    fn test_multi_echo_with_offset_removal() {
1283        // Verify the offset-removal path produces output and has phase_offset
1284        let meta = make_test_metadata(3);
1285        let n = 8 * 8 * 8;
1286        let mask = vec![1u8; n];
1287
1288        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1289            .map(|&te| vec![50.0 * te; n]) // small linear phase
1290            .collect();
1291        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
1292        let mag = vec![1.0; n];
1293        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
1294
1295        let config = FieldMappingConfig {
1296            phase_offset_removal: true,
1297            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1298            b0_estimation: B0EstimationMethod::default(),
1299            ..Default::default()
1300        };
1301
1302        let result = run_field_mapping(
1303            &phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {},
1304        ).unwrap();
1305
1306        assert_eq!(result.b0_field_ppm.len(), n);
1307        assert!(result.phase_offset.is_some(), "offset removal path should return phase offset");
1308        let offset = result.phase_offset.unwrap();
1309        assert_eq!(offset.len(), n);
1310
1311        // All values should be finite
1312        for &v in &result.b0_field_ppm {
1313            assert!(v.is_finite(), "B0 field should be finite");
1314        }
1315    }
1316
1317    #[test]
1318    fn complex_fit_gives_the_field_and_its_noise_in_ppm() {
1319        // 4 echoes 5 ms apart, ω = 50·(1 + x) rad/s and an offset of 1 rad: the echo phases wrap
1320        // (up to 9 rad), the phase per echo spacing (up to 2 rad) does not.
1321        let meta = make_test_metadata(4);
1322        let n = 8 * 8 * 8;
1323        let mask = vec![1u8; n];
1324        let omega: Vec<f64> = (0..n).map(|i| 50.0 * (1.0 + (i % 8) as f64)).collect();
1325        let wrap = |x: f64| (x + PI).rem_euclid(2.0 * PI) - PI;
1326        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1327            .map(|&te| omega.iter().map(|&w| wrap(1.0 + w * te)).collect())
1328            .collect();
1329        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
1330        let mag = vec![1.0; n];
1331        let mag_refs: Vec<&[f64]> = (0..4).map(|_| mag.as_slice()).collect();
1332        let fit = B0EstimationMethod::ComplexFit(crate::utils::FitPpmComplexParams::default());
1333        for unwrapping_algorithm in [UnwrappingAlgorithm::Romeo, UnwrappingAlgorithm::Laplacian] {
1334            let config = FieldMappingConfig { unwrapping_algorithm, b0_estimation: fit.clone(), ..Default::default() };
1335            let r = run_field_mapping(&phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1336            let want = rads_to_ppm(&omega, 3.0);
1337            // ROMEO keeps the absolute field; Laplacian only up to the masked mean
1338            let shift = if unwrapping_algorithm == UnwrappingAlgorithm::Laplacian {
1339                (want.iter().sum::<f64>() - r.b0_field_ppm.iter().sum::<f64>()) / n as f64
1340            } else {
1341                0.0
1342            };
1343            for (got, w) in r.b0_field_ppm.iter().zip(&want) {
1344                assert!((got + shift - w).abs() < 1e-6, "{unwrapping_algorithm:?}: {got} vs {w}");
1345            }
1346            // dp1 for four unit echoes: sqrt(4 / (4·14 − 36)) rad per spacing
1347            let noise = r.field_noise_ppm.expect("complex fit returns its noise map");
1348            let want_noise = rads_to_ppm(&[(0.2f64).sqrt() / 0.005], 3.0)[0];
1349            assert!(noise.iter().all(|v| (v - want_noise).abs() < 1e-12));
1350        }
1351    }
1352
1353    /// The complex fit is returned as it was before unwrapping (MEDI's `iFreq_raw` and `N_std`):
1354    /// identical to calling `fit_ppm_complex` directly, wrapped, in radians per echo spacing.
1355    /// Other methods return none.
1356    #[test]
1357    fn complex_fit_returns_the_raw_fit_and_its_noise() {
1358        // 3 echoes 5 ms apart, phase per echo spacing 0.5..4.0 rad: above π it comes back wrapped.
1359        let meta = make_test_metadata(3);
1360        let n = 8 * 8 * 8;
1361        let mask = vec![1u8; n];
1362        let per_spacing: Vec<f64> = (0..n).map(|i| 0.5 * (1.0 + (i % 8) as f64)).collect();
1363        let wrap = |x: f64| (x + PI).rem_euclid(2.0 * PI) - PI;
1364        let phases: Vec<Vec<f64>> = (1..=3)
1365            .map(|e| per_spacing.iter().map(|&d| wrap(0.3 + d * e as f64)).collect())
1366            .collect();
1367        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
1368        let mags: Vec<Vec<f64>> = [1.0, 0.8, 0.6].iter().map(|&m| vec![m; n]).collect();
1369        let mag_refs: Vec<&[f64]> = mags.iter().map(|m| m.as_slice()).collect();
1370        let params = crate::utils::FitPpmComplexParams::default();
1371        let config = FieldMappingConfig {
1372            b0_estimation: B0EstimationMethod::ComplexFit(params), ..Default::default()
1373        };
1374        let r = run_field_mapping(&phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1375        let raw = r.complex_fit.expect("the complex fit returns the fit itself");
1376        let direct = crate::utils::fit_ppm_complex(&mag_refs, &phase_refs, &meta.echo_times, &params).unwrap();
1377        assert_eq!(raw.field, direct.field);
1378        assert_eq!(raw.noise, direct.noise);
1379        assert_eq!(raw.echo_spacing, direct.echo_spacing);
1380        for (got, &d) in raw.field.iter().zip(&per_spacing) {
1381            assert!((got - wrap(d)).abs() < 1e-8, "{got} vs {}", wrap(d));
1382            assert!(got.abs() <= PI);
1383        }
1384        // N_std is field_noise_ppm before the conversion to ppm
1385        let noise_ppm = r.field_noise_ppm.unwrap();
1386        let from_raw = rads_to_ppm(&raw.noise.iter().map(|v| v / raw.echo_spacing).collect::<Vec<_>>(), 3.0);
1387        assert_eq!(noise_ppm, from_raw);
1388
1389        let config = FieldMappingConfig::default();
1390        let r = run_field_mapping(&phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {}).unwrap();
1391        assert!(r.complex_fit.is_none());
1392    }
1393
1394    #[test]
1395    fn complex_fit_needs_magnitudes_and_refuses_bipolar_correction() {
1396        let meta = make_test_metadata(3);
1397        let n = 8 * 8 * 8;
1398        let mask = vec![1u8; n];
1399        let p = vec![0.1; n];
1400        let phases: Vec<&[f64]> = vec![&p, &p, &p];
1401        let fit = B0EstimationMethod::ComplexFit(crate::utils::FitPpmComplexParams::default());
1402        let config = FieldMappingConfig { b0_estimation: fit.clone(), ..Default::default() };
1403        assert!(matches!(run_field_mapping(&phases, None, &mask, &meta, &config, &mut |_, _| {}),
1404            Err(PipelineError::InvalidInput(_))));
1405        let config = FieldMappingConfig { b0_estimation: fit, bipolar_correction: true, ..Default::default() };
1406        assert!(matches!(run_field_mapping(&phases, Some(&phases), &mask, &meta, &config, &mut |_, _| {}),
1407            Err(PipelineError::InvalidConfig(_))));
1408    }
1409
1410    #[test]
1411    fn test_validates_echo_time_mismatch() {
1412        let meta = make_test_metadata(2);
1413        let n = 8 * 8 * 8;
1414        let mask = vec![1u8; n];
1415        let phase = vec![0.0; n];
1416
1417        let result = run_field_mapping(
1418            &[&phase[..]], None, &mask, &meta,
1419            &FieldMappingConfig::default(), &mut |_, _| {},
1420        );
1421        assert!(result.is_err());
1422    }
1423
1424    #[test]
1425    fn test_validates_empty_phases() {
1426        let meta = ScanMetadata {
1427            dims: (4, 4, 4),
1428            voxel_size: (1.0, 1.0, 1.0),
1429            echo_times: vec![],
1430            field_strength: 3.0,
1431            b0_direction: (0.0, 0.0, 1.0),
1432            slice_geometry: None,
1433            affine: None,
1434        };
1435        let mask = vec![1u8; 64];
1436        let result = run_field_mapping(
1437            &[], None, &mask, &meta,
1438            &FieldMappingConfig::default(), &mut |_, _| {},
1439        );
1440        assert!(result.is_err());
1441    }
1442
1443    #[test]
1444    fn test_b0_estimation_methods_agree_on_linear_data() {
1445        // For perfectly linear phase data (no offset), WeightedAvg and LinearFit
1446        // should produce the same B0 estimate. Use ROMEO to preserve constant fields.
1447        let meta = make_test_metadata(3);
1448        let n = 8 * 8 * 8;
1449        let mask = vec![1u8; n];
1450        let slope = 80.0; // rad/s
1451
1452        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
1453            .map(|&te| vec![slope * te; n])
1454            .collect();
1455        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
1456        let mag = vec![1.0; n];
1457        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
1458
1459        // Path A: offset removal + weighted avg (uses ROMEO)
1460        let config_a = FieldMappingConfig {
1461            phase_offset_removal: true,
1462            b0_estimation: B0EstimationMethod::default(),
1463            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1464            ..Default::default()
1465        };
1466        let result_a = run_field_mapping(
1467            &phase_refs, Some(&mag_refs), &mask, &meta, &config_a, &mut |_, _| {},
1468        ).unwrap();
1469
1470        // Path B: no offset removal, explicit linear fit (uses ROMEO)
1471        let config_b = FieldMappingConfig {
1472            phase_offset_removal: false,
1473            b0_estimation: B0EstimationMethod::LinearFit(LinearFitParams::default()),
1474            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1475            ..Default::default()
1476        };
1477        let result_b = run_field_mapping(
1478            &phase_refs, Some(&mag_refs), &mask, &meta, &config_b, &mut |_, _| {},
1479        ).unwrap();
1480
1481        // Both should recover ~same ppm for perfectly linear data
1482        let count_a = result_a.b0_field_ppm.iter().filter(|v| v.is_finite() && **v != 0.0).count();
1483        let count_b = result_b.b0_field_ppm.iter().filter(|v| v.is_finite() && **v != 0.0).count();
1484        assert!(count_a > 0, "Path A should have non-zero voxels");
1485        assert!(count_b > 0, "Path B should have non-zero voxels");
1486
1487        let mean_a: f64 = result_a.b0_field_ppm.iter()
1488            .filter(|v| v.is_finite() && **v != 0.0).sum::<f64>() / count_a as f64;
1489        let mean_b: f64 = result_b.b0_field_ppm.iter()
1490            .filter(|v| v.is_finite() && **v != 0.0).sum::<f64>() / count_b as f64;
1491
1492        assert!((mean_a - mean_b).abs() < 0.05,
1493            "WeightedAvg ({:.4}) and LinearFit ({:.4}) should agree on linear data", mean_a, mean_b);
1494    }
1495
1496    // ---- Path B honours b0_estimation and its weighting ---------------------------------------
1497
1498    const N: usize = 16;
1499
1500    fn meta_n(tes: &[f64]) -> ScanMetadata {
1501        ScanMetadata {
1502            dims: (N, N, N),
1503            voxel_size: (1.0, 1.0, 1.0),
1504            echo_times: tes.to_vec(),
1505            field_strength: 3.0,
1506            b0_direction: (0.0, 0.0, 1.0),
1507            slice_geometry: None,
1508            affine: None,
1509        }
1510    }
1511
1512    fn wrap(x: f64) -> f64 { (x + PI).rem_euclid(2.0 * PI) - PI }
1513
1514    /// Deterministic noise in [-0.5, 0.5).
1515    fn noise(state: &mut u64) -> f64 {
1516        *state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1517        (*state >> 11) as f64 / (1u64 << 53) as f64 - 0.5
1518    }
1519
1520    /// Per-echo wrapped phase `wrap(TE·ω + noise)` and a magnitude that varies over the volume
1521    /// and decays with TE, so magnitude-dependent weights differ from voxel to voxel.
1522    fn echoes(tes: &[f64], omega: impl Fn(usize, usize, usize) -> f64, noise_amp: f64)
1523        -> (Vec<Vec<f64>>, Vec<Vec<f64>>)
1524    {
1525        let n = N * N * N;
1526        let mut ph = vec![vec![0.0; n]; tes.len()];
1527        let mut mg = vec![vec![0.0; n]; tes.len()];
1528        let mut st = 42u64;
1529        for k in 0..N { for j in 0..N { for i in 0..N {
1530            let idx = i + j * N + k * N * N;
1531            for (e, &te) in tes.iter().enumerate() {
1532                ph[e][idx] = wrap(te * omega(i, j, k) + noise_amp * noise(&mut st));
1533                mg[e][idx] = (1.0 + 0.8 * (i as f64 / N as f64)) * (-te / 0.030).exp();
1534            }
1535        }}}
1536        (ph, mg)
1537    }
1538
1539    /// Smooth field (rad/s) made of cosine modes, which the Neumann/DCT Laplacian unwrap
1540    /// reproduces exactly; it wraps several times at the later echoes.
1541    fn omega_cos(i: usize, j: usize, k: usize) -> f64 {
1542        let c = |x: usize, m: f64| (PI * m * (x as f64 + 0.5) / N as f64).cos();
1543        900.0 * c(i, 1.0) + 300.0 * c(j, 1.0) * c(k, 2.0)
1544    }
1545
1546    /// Small field (rad/s) that never wraps at the echo times used with it.
1547    fn omega_small(i: usize, j: usize, _k: usize) -> f64 {
1548        60.0 + 40.0 * (i as f64 / N as f64) - 30.0 * (j as f64 / N as f64)
1549    }
1550
1551    fn refs(v: &[Vec<f64>]) -> Vec<&[f64]> { v.iter().map(|x| x.as_slice()).collect() }
1552
1553    fn max_abs_diff(a: &[f64], b: &[f64], mask: &[u8]) -> f64 {
1554        a.iter().zip(b).zip(mask).filter(|(_, &m)| m != 0)
1555            .map(|((x, y), _)| (x - y).abs()).fold(0.0, f64::max)
1556    }
1557
1558    /// Hand-written weighted combination `Σ w (φ/TE) / Σ w` in ppm, with the weights spelled
1559    /// out here rather than taken from `B0WeightType::weight`.
1560    fn hand_combination(uw: &[Vec<f64>], mags: &[Vec<f64>], tes: &[f64], mask: &[u8], wt: B0WeightType, b0_t: f64) -> Vec<f64> {
1561        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1562        (0..mask.len()).map(|i| {
1563            if mask[i] == 0 { return 0.0; }
1564            let (mut num, mut den) = (0.0, 0.0);
1565            for e in 0..tes.len() {
1566                let (te, m) = (tes[e], mags[e][i]);
1567                let w = match wt {
1568                    B0WeightType::PhaseSNR => m * te,
1569                    B0WeightType::PhaseVar => m * m * te * te,
1570                    B0WeightType::Average => 1.0,
1571                    B0WeightType::TEs => te,
1572                    B0WeightType::Mag => m,
1573                    B0WeightType::AssumedDecay { t2star_s } => te * te * (-te / t2star_s).exp(),
1574                };
1575                num += w * uw[e][i] / te;
1576                den += w;
1577            }
1578            num / den / (2.0 * PI) * 1e6 / (gamma * b0_t)
1579        }).collect()
1580    }
1581
1582    /// UK Biobank's form of the assumed-decay combination: `Σ Wφ / Σ W·TE`, `W = TE·exp(−TE/T2*)`.
1583    fn ukb_assumed_decay(uw: &[Vec<f64>], tes: &[f64], mask: &[u8], t2s: f64, b0_t: f64) -> Vec<f64> {
1584        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1585        let w: Vec<f64> = tes.iter().map(|&te| te * (-te / t2s).exp()).collect();
1586        let den: f64 = w.iter().zip(tes).map(|(w, te)| w * te).sum();
1587        (0..mask.len()).map(|i| {
1588            if mask[i] == 0 { return 0.0; }
1589            let num: f64 = (0..tes.len()).map(|e| w[e] * uw[e][i]).sum();
1590            num / den / (2.0 * PI) * 1e6 / (gamma * b0_t)
1591        }).collect()
1592    }
1593
1594    fn all_weight_types() -> [B0WeightType; 6] {
1595        [
1596            B0WeightType::PhaseSNR, B0WeightType::PhaseVar, B0WeightType::Average,
1597            B0WeightType::TEs, B0WeightType::Mag, B0WeightType::assumed_decay_default(),
1598        ]
1599    }
1600
1601    fn masked_mean(v: &[f64], mask: &[u8]) -> f64 {
1602        let (s, n) = v.iter().zip(mask).filter(|(_, &m)| m != 0)
1603            .fold((0.0, 0usize), |(s, n), (&x, _)| (s + x, n + 1));
1604        s / n as f64
1605    }
1606
1607    #[test]
1608    fn laplacian_direct_honours_weight_type_and_matches_hand_combination() {
1609        let tes = [0.004, 0.009, 0.014];
1610        let meta = meta_n(&tes);
1611        let (ph, mg) = echoes(&tes, omega_cos, 0.4);
1612        let mask = vec![1u8; N * N * N];
1613        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
1614
1615        // Reference: unwrap each echo, remove its masked mean, combine by hand.
1616        let uw: Vec<Vec<f64>> = ph.iter().map(|p| {
1617            let mut u = crate::unwrap::laplacian_unwrap(p, &mask, &grid, LaplacianSolver::Dct);
1618            let m = masked_mean(&u, &mask);
1619            u.iter_mut().for_each(|v| *v -= m);
1620            u
1621        }).collect();
1622
1623        let mut outputs = Vec::new();
1624        for wt in all_weight_types() {
1625            let cfg = FieldMappingConfig {
1626                unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1627                b0_estimation: B0EstimationMethod::WeightedAvg { weighting: wt },
1628                ..Default::default()
1629            };
1630            let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1631                .unwrap().b0_field_ppm;
1632            let want = hand_combination(&uw, &mg, &tes, &mask, wt, 3.0);
1633            let d = max_abs_diff(&got, &want, &mask);
1634            assert!(d < 1e-12, "{wt:?}: differs from the hand combination by {d}");
1635            outputs.push((wt, got));
1636        }
1637        // Every weight type gives a different field (they all used to be identical).
1638        for a in 0..outputs.len() {
1639            for b in a + 1..outputs.len() {
1640                let d = max_abs_diff(&outputs[a].1, &outputs[b].1, &mask);
1641                assert!(d > 1e-4, "{:?} and {:?} gave the same field (max diff {d})", outputs[a].0, outputs[b].0);
1642            }
1643        }
1644        // Assumed-decay weighting is UK Biobank's Σ Wφ / Σ W·TE.
1645        let ad = &outputs[5].1;
1646        let ukb = ukb_assumed_decay(&uw, &tes, &mask, 0.040, 3.0);
1647        assert!(max_abs_diff(ad, &ukb, &mask) < 1e-12);
1648    }
1649
1650    /// Laplacian + linear fit aligns the per-echo constants (as the weighted mean does), and a
1651    /// single Laplacian echo is masked and demeaned like each echo of a series.
1652    #[test]
1653    fn laplacian_linear_fit_and_single_echo_align_like_the_weighted_mean() {
1654        let tes = [0.004, 0.009, 0.014];
1655        let meta = meta_n(&tes);
1656        let (mut ph, mg) = echoes(&tes, omega_cos, 0.4);
1657        // A sphere mask with noise outside it, and the FFT solver, whose unwraps are not
1658        // zero-mean over the mask: the cases where alignment and masking matter.
1659        let c = N as f64 / 2.0;
1660        let mask: Vec<u8> = (0..N * N * N).map(|idx| {
1661            let d = |x: usize| x as f64 + 0.5 - c;
1662            (d(idx % N).powi(2) + d((idx / N) % N).powi(2) + d(idx / (N * N)).powi(2) < (c - 2.0).powi(2)) as u8
1663        }).collect();
1664        let mut st = 5u64;
1665        for p in ph.iter_mut() {
1666            for (v, &m) in p.iter_mut().zip(&mask) {
1667                if m == 0 { *v = noise(&mut st) * PI; }
1668            }
1669        }
1670        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
1671        let solver = LaplacianSolver::Fft { pad: [8, 8, 6] };
1672        let uw: Vec<Vec<f64>> = ph.iter().map(|p| {
1673            let masked = mask_phase(p, &mask);
1674            let mut u = crate::unwrap::laplacian_unwrap(&masked, &mask, &grid, solver);
1675            remove_masked_mean(&mut u, &mask);
1676            u
1677        }).collect();
1678
1679        for estimate_offset in [true, false] {
1680            let p = LinearFitParams { estimate_offset };
1681            let cfg = FieldMappingConfig {
1682                unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1683                b0_estimation: B0EstimationMethod::LinearFit(p.clone()),
1684                laplacian_solver: solver,
1685                ..Default::default()
1686            };
1687            let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1688                .unwrap().b0_field_ppm;
1689            let fit = crate::utils::multi_echo_linear_fit(
1690                &refs(&uw), &refs(&mg), &tes, &mask, p.estimate_offset);
1691            let want = rads_to_ppm(&fit.field, 3.0);
1692            let d = max_abs_diff(&got, &want, &mask);
1693            assert!(d < 1e-12, "estimate_offset {estimate_offset}: differs from the aligned fit by {d}");
1694        }
1695
1696        let one = meta_n(&tes[..1]);
1697        let cfg = FieldMappingConfig {
1698            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1699            laplacian_solver: solver,
1700            ..Default::default()
1701        };
1702        let got = run_field_mapping(&[&ph[0]], None, &mask, &one, &cfg, &mut |_, _| {}).unwrap().b0_field_ppm;
1703        let want: Vec<f64> = uw[0].iter().map(|&u| u / tes[0]).collect();
1704        let d = max_abs_diff(&got, &rads_to_ppm(&want, 3.0), &mask);
1705        assert!(d < 1e-12, "single echo differs from the masked, demeaned unwrap by {d}");
1706    }
1707
1708    #[test]
1709    fn laplacian_direct_recovers_wrapped_field() {
1710        // Noise-free: every weighting recovers the true field up to its masked mean.
1711        let tes = [0.004, 0.009, 0.014];
1712        let meta = meta_n(&tes);
1713        let (ph, mg) = echoes(&tes, omega_cos, 0.0);
1714        let mask = vec![1u8; N * N * N];
1715        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1716        let mut truth: Vec<f64> = (0..N * N * N)
1717            .map(|idx| omega_cos(idx % N, (idx / N) % N, idx / (N * N)) / (2.0 * PI) * 1e6 / (gamma * 3.0))
1718            .collect();
1719        let m = masked_mean(&truth, &mask);
1720        truth.iter_mut().for_each(|v| *v -= m);
1721        for wt in all_weight_types() {
1722            let cfg = FieldMappingConfig {
1723                unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1724                b0_estimation: B0EstimationMethod::WeightedAvg { weighting: wt },
1725                ..Default::default()
1726            };
1727            let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1728                .unwrap().b0_field_ppm;
1729            let d = max_abs_diff(&got, &truth, &mask);
1730            assert!(d < 1e-6, "{wt:?}: max error {d} ppm");
1731        }
1732    }
1733
1734    #[test]
1735    fn romeo_without_offset_removal_honours_weight_type() {
1736        // A field that does not wrap, so ROMEO returns each echo's phase unchanged and the
1737        // expected result is the hand combination of the input phases themselves.
1738        let tes = [0.004, 0.009, 0.014];
1739        let meta = meta_n(&tes);
1740        let (ph, mg) = echoes(&tes, omega_small, 0.3);
1741        let mask = vec![1u8; N * N * N];
1742
1743        let mut outputs = Vec::new();
1744        for wt in all_weight_types() {
1745            let cfg = FieldMappingConfig {
1746                unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1747                phase_offset_removal: false,
1748                b0_estimation: B0EstimationMethod::WeightedAvg { weighting: wt },
1749                ..Default::default()
1750            };
1751            let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1752                .unwrap().b0_field_ppm;
1753            let want = hand_combination(&ph, &mg, &tes, &mask, wt, 3.0);
1754            let d = max_abs_diff(&got, &want, &mask);
1755            assert!(d < 1e-12, "{wt:?}: differs from the hand combination by {d}");
1756            outputs.push((wt, got));
1757        }
1758        for a in 0..outputs.len() {
1759            for b in a + 1..outputs.len() {
1760                let d = max_abs_diff(&outputs[a].1, &outputs[b].1, &mask);
1761                assert!(d > 1e-4, "{:?} and {:?} gave the same field (max diff {d})", outputs[a].0, outputs[b].0);
1762            }
1763        }
1764    }
1765
1766    #[test]
1767    fn two_echo_weighted_mean_keeps_absolute_phase_linear_fit_is_echo_difference() {
1768        // The UK Biobank case: two offset-free echoes. The default (weighted mean) uses both
1769        // echoes' absolute phase; an explicit linear fit with an intercept is the echo difference.
1770        let tes = [0.00942, 0.0197];
1771        let meta = meta_n(&tes);
1772        let (ph, mg) = echoes(&tes, omega_small, 0.3);
1773        let mask = vec![1u8; N * N * N];
1774        let gamma = crate::constants::GAMMA_BAR_HZ_PER_T;
1775        let base = FieldMappingConfig {
1776            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
1777            phase_offset_removal: false,
1778            ..Default::default()
1779        };
1780
1781        let wavg = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &base, &mut |_, _| {})
1782            .unwrap().b0_field_ppm;
1783        let want = hand_combination(&ph, &mg, &tes, &mask, B0WeightType::PhaseSNR, 3.0);
1784        assert!(max_abs_diff(&wavg, &want, &mask) < 1e-12);
1785
1786        let lf_cfg = FieldMappingConfig { b0_estimation: B0EstimationMethod::LinearFit(LinearFitParams::default()), ..base.clone() };
1787        let lf = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &lf_cfg, &mut |_, _| {})
1788            .unwrap().b0_field_ppm;
1789        let diff: Vec<f64> = (0..mask.len())
1790            .map(|i| (ph[1][i] - ph[0][i]) / (tes[1] - tes[0]) / (2.0 * PI) * 1e6 / (gamma * 3.0))
1791            .collect();
1792        assert!(max_abs_diff(&lf, &diff, &mask) < 1e-9, "linear fit is not the echo difference");
1793        assert!(max_abs_diff(&lf, &wavg, &mask) > 1e-3, "weighted mean should differ from the echo difference");
1794
1795        // And the echo difference is the noisier of the two: compare residuals to the truth.
1796        let truth: Vec<f64> = (0..N * N * N)
1797            .map(|idx| omega_small(idx % N, (idx / N) % N, idx / (N * N)) / (2.0 * PI) * 1e6 / (gamma * 3.0))
1798            .collect();
1799        let rms = |v: &[f64]| (v.iter().zip(&truth).map(|(a, b)| (a - b).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
1800        assert!(rms(&lf) > 1.5 * rms(&wavg), "rms lf {} vs wavg {}", rms(&lf), rms(&wavg));
1801    }
1802
1803    /// Sphere mask filling most of the N³ grid.
1804    fn sphere_mask() -> Vec<u8> {
1805        let c = (N as f64 - 1.0) / 2.0;
1806        (0..N * N * N).map(|idx| {
1807            let (i, j, k) = (idx % N, (idx / N) % N, idx / (N * N));
1808            let r2 = (i as f64 - c).powi(2) + (j as f64 - c).powi(2) + (k as f64 - c).powi(2);
1809            (r2 <= (0.45 * N as f64).powi(2)) as u8
1810        }).collect()
1811    }
1812
1813    #[test]
1814    fn direct_linear_fit_is_the_fit_on_the_unwrapped_echoes() {
1815        // `b0_estimation = LinearFit` on the Laplacian direct path: per-echo unwrap of the
1816        // masked phase, each echo's masked mean removed (the per-echo alignment), then
1817        // multi_echo_linear_fit with an intercept (the variant carries no weighting).
1818        let tes = [0.004, 0.009, 0.014];
1819        let meta = meta_n(&tes);
1820        let (ph, mg) = echoes(&tes, omega_cos, 0.4);
1821        let mask = sphere_mask();
1822        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
1823        let cfg = FieldMappingConfig {
1824            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1825            b0_estimation: B0EstimationMethod::LinearFit(LinearFitParams::default()),
1826            ..Default::default()
1827        };
1828        let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1829            .unwrap().b0_field_ppm;
1830        let uw: Vec<Vec<f64>> = ph.iter()
1831            .map(|p| {
1832                let mut u = crate::unwrap::laplacian_unwrap(&mask_phase(p, &mask), &mask, &grid, LaplacianSolver::Dct);
1833                remove_masked_mean(&mut u, &mask);
1834                u
1835            })
1836            .collect();
1837        let fit = crate::utils::multi_echo_linear_fit(
1838            &refs(&uw), &refs(&mg), &tes, &mask,
1839            LinearFitParams::default().estimate_offset,
1840        );
1841        assert_eq!(got, rads_to_ppm(&fit.field, 3.0));
1842    }
1843
1844    #[test]
1845    fn laplacian_direct_ignores_phase_outside_the_mask() {
1846        // The phase is zeroed outside the mask before the (global) Laplacian solve, so what is
1847        // there cannot leak into the brain, and the result is the hand combination of the
1848        // masked, unwrapped, demeaned echoes.
1849        let tes = [0.004, 0.009, 0.014];
1850        let meta = meta_n(&tes);
1851        let (ph, mg) = echoes(&tes, omega_cos, 0.4);
1852        let mask = sphere_mask();
1853        assert!(mask.contains(&0) && mask.contains(&1));
1854        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
1855        let cfg = FieldMappingConfig {
1856            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1857            ..Default::default()
1858        };
1859        let run = |p: &[Vec<f64>]| run_field_mapping(&refs(p), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1860            .unwrap().b0_field_ppm;
1861        let got = run(&ph);
1862
1863        let mut st = 7u64;
1864        let scrambled: Vec<Vec<f64>> = ph.iter().map(|p| p.iter().zip(&mask)
1865            .map(|(&v, &m)| if m != 0 { v } else { wrap(10.0 * noise(&mut st)) }).collect()).collect();
1866        assert_eq!(got, run(&scrambled));
1867
1868        let uw: Vec<Vec<f64>> = ph.iter().map(|p| {
1869            let mut u = crate::unwrap::laplacian_unwrap(&mask_phase(p, &mask), &mask, &grid, LaplacianSolver::Dct);
1870            remove_masked_mean(&mut u, &mask);
1871            u
1872        }).collect();
1873        let want = hand_combination(&uw, &mg, &tes, &mask, B0WeightType::PhaseSNR, 3.0);
1874        assert!(max_abs_diff(&got, &want, &mask) < 1e-12);
1875    }
1876
1877    #[test]
1878    fn offset_removal_path_accepts_assumed_decay_weighting() {
1879        // Path A: assumed-decay weighting changes the output, and with T2* → ∞ it is phase-variance
1880        // weighting at unit magnitude (TE²·exp(−TE/∞) = TE² = mag²·TE²).
1881        let tes = [0.004, 0.009, 0.014];
1882        let meta = meta_n(&tes);
1883        let (ph, _) = echoes(&tes, omega_small, 0.3);
1884        let mask = vec![1u8; N * N * N];
1885        let run = |wt| {
1886            let cfg = FieldMappingConfig { b0_estimation: B0EstimationMethod::WeightedAvg { weighting: wt }, ..Default::default() };
1887            run_field_mapping(&refs(&ph), None, &mask, &meta, &cfg, &mut |_, _| {}).unwrap()
1888        };
1889        let snr = run(B0WeightType::PhaseSNR);
1890        let ad = run(B0WeightType::assumed_decay_default());
1891        let inf = run(B0WeightType::AssumedDecay { t2star_s: 1e30 });
1892        let pv = run(B0WeightType::PhaseVar);
1893        assert!(ad.phase_offset.is_some());
1894        assert!(max_abs_diff(&snr.b0_field_ppm, &ad.b0_field_ppm, &mask) > 1e-4);
1895        assert!(max_abs_diff(&inf.b0_field_ppm, &pv.b0_field_ppm, &mask) < 1e-12);
1896    }
1897
1898    #[test]
1899    fn rejects_non_positive_assumed_t2star() {
1900        let tes = [0.004, 0.009];
1901        let meta = meta_n(&tes);
1902        let (ph, _) = echoes(&tes, omega_small, 0.0);
1903        let mask = vec![1u8; N * N * N];
1904        for t2 in [0.0, -0.01, f64::NAN] {
1905            let cfg = FieldMappingConfig {
1906                unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1907                b0_estimation: B0EstimationMethod::WeightedAvg { weighting: B0WeightType::AssumedDecay { t2star_s: t2 } },
1908                ..Default::default()
1909            };
1910            assert!(run_field_mapping(&refs(&ph), None, &mask, &meta, &cfg, &mut |_, _| {}).is_err());
1911        }
1912    }
1913
1914    // ---- Laplacian solver choice on the direct path --------------------------------------
1915
1916    /// Masked phase, unwrapped with `solver`, as the direct path does before aligning echoes.
1917    fn masked_unwrap(ph: &[Vec<f64>], mask: &[u8], solver: LaplacianSolver) -> Vec<Vec<f64>> {
1918        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
1919        ph.iter().map(|p| crate::unwrap::laplacian_unwrap(&mask_phase(p, mask), mask, &grid, solver)).collect()
1920    }
1921
1922    #[test]
1923    fn laplacian_direct_uses_the_configured_solver() {
1924        let tes = [0.004, 0.009, 0.014];
1925        let meta = meta_n(&tes);
1926        let (ph, mg) = echoes(&tes, omega_cos, 0.4);
1927        let mask = sphere_mask();
1928        let fft = LaplacianSolver::Fft { pad: [4; 3] };
1929        let run = |solver| {
1930            let cfg = FieldMappingConfig {
1931                unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1932                laplacian_solver: solver,
1933                ..Default::default()
1934            };
1935            run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1936                .unwrap().b0_field_ppm
1937        };
1938        assert_eq!(FieldMappingConfig::default().laplacian_solver, LaplacianSolver::Dct);
1939        let (dct, got) = (run(LaplacianSolver::Dct), run(fft));
1940        let mut uw = masked_unwrap(&ph, &mask, fft);
1941        uw.iter_mut().for_each(|u| remove_masked_mean(u, &mask));
1942        let want = hand_combination(&uw, &mg, &tes, &mask, B0WeightType::PhaseSNR, 3.0);
1943        assert!(max_abs_diff(&got, &want, &mask) < 1e-12);
1944        assert!(max_abs_diff(&got, &dct, &mask) > 1e-6, "solver setting had no effect");
1945    }
1946
1947    #[test]
1948    fn assumed_decay_with_fft_is_ukb_up_to_a_constant() {
1949        // UK Biobank: MRPhaseUnwrap(mask .* φ) per echo (Fft), then Σ Wφ / Σ W·TE with
1950        // W = TE·exp(−TE/40 ms). The direct path removes each echo's masked mean first, which
1951        // with voxel-independent weights shifts the field by one constant and nothing else.
1952        let tes = [0.00942, 0.0197];
1953        let meta = meta_n(&tes);
1954        let (ph, mg) = echoes(&tes, omega_cos, 0.4);
1955        let mask = sphere_mask();
1956        let fft = LaplacianSolver::Fft { pad: [8; 3] };
1957        let cfg = FieldMappingConfig {
1958            unwrapping_algorithm: UnwrappingAlgorithm::Laplacian,
1959            laplacian_solver: fft,
1960            b0_estimation: B0EstimationMethod::WeightedAvg { weighting: B0WeightType::assumed_decay_default() },
1961            ..Default::default()
1962        };
1963        let got = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {})
1964            .unwrap().b0_field_ppm;
1965        let ukb = ukb_assumed_decay(&masked_unwrap(&ph, &mask, fft), &tes, &mask, 0.040, 3.0);
1966        let d: Vec<f64> = got.iter().zip(&ukb).map(|(a, b)| a - b).collect();
1967        let c = masked_mean(&d, &mask);
1968        let dev = d.iter().zip(&mask).filter(|(_, &m)| m != 0).map(|(v, _)| (v - c).abs()).fold(0.0, f64::max);
1969        assert!(dev < 1e-12, "not a constant offset: {dev}");
1970    }
1971
1972    #[test]
1973    fn assumed_decay_effective_te_matches_ukb() {
1974        // UK Biobank's effective TE Σ W·TE / Σ W for TE 9.42 / 19.7 ms and T2* = 40 ms is
1975        // 15.772350 ms (from the MATLAB run). The phase weight is W = w / TE.
1976        let tes = [9.42e-3, 19.7e-3];
1977        let wt = B0WeightType::assumed_decay_default();
1978        let w: Vec<f64> = tes.iter().map(|&te| wt.weight(te, 1.0) / te).collect();
1979        assert!((w[0] - 9.42e-3 * (-9.42f64 / 40.0).exp()).abs() < 1e-15);
1980        let te_eff = (w[0] * tes[0] + w[1] * tes[1]) / (w[0] + w[1]);
1981        assert!((te_eff - 15.772350e-3).abs() < 1e-9, "{te_eff}");
1982    }
1983
1984    // ---- B0EstimationMethod::RobustFit ----------------------------------------------------------
1985
1986    const TES4: [f64; 4] = [0.004, 0.009, 0.014, 0.019];
1987    /// The echo the corruption tests damage. Not the first two, which the offset removal reads,
1988    /// and not the last, which a linear fit follows (see `multi_echo_robust_fit`'s docs).
1989    const BAD: usize = 2;
1990
1991    fn robust_cfg(unwrapping_algorithm: UnwrappingAlgorithm, phase_offset_removal: bool) -> FieldMappingConfig {
1992        FieldMappingConfig {
1993            unwrapping_algorithm,
1994            phase_offset_removal,
1995            b0_estimation: B0EstimationMethod::RobustFit(RobustFitParams::default()),
1996            ..Default::default()
1997        }
1998    }
1999
2000    /// A four-echo series `wrap(φ₀ + TE·ω + noise/mag)` over a sphere, with phase noise scaled
2001    /// as `1/magnitude` (phase SNR is magnitude SNR, the model the robust scale assumes). With
2002    /// `corrupt`, echo [`BAD`] loses 70% of its magnitude and gains a smooth phase error of up to
2003    /// ~0.8 rad over a slab of slices — what a shot taken mid-movement does to one echo.
2004    ///
2005    /// Returns `(phases, mags, mask, true field in ppm, slab mask)`.
2006    #[allow(clippy::type_complexity)]
2007    fn robust_series(corrupt: bool) -> (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<u8>, Vec<f64>, Vec<u8>) {
2008        let n = N * N * N;
2009        let mask = sphere_mask();
2010        let mut ph = vec![vec![0.0; n]; TES4.len()];
2011        let mut mg = vec![vec![0.0; n]; TES4.len()];
2012        let mut truth = vec![0.0; n];
2013        let mut slab = vec![0u8; n];
2014        let mut st = 7u64;
2015        let ppm = 1e6 / (2.0 * PI * crate::constants::GAMMA_BAR_HZ_PER_T * 3.0);
2016        for k in 0..N { for j in 0..N { for i in 0..N {
2017            let idx = i + j * N + k * N * N;
2018            let w = omega_small(i, j, k);
2019            let phi0 = 0.2 * (i as f64 * 0.3).cos() * (k as f64 * 0.2).sin();
2020            truth[idx] = w * ppm;
2021            let in_slab = (5..11).contains(&k);
2022            slab[idx] = (in_slab && mask[idx] != 0) as u8;
2023            for (e, &te) in TES4.iter().enumerate() {
2024                let mut m = (1.0 + 0.8 * (i as f64 / N as f64)) * (-te / 0.030).exp();
2025                let mut p = phi0 + te * w + 0.02 * noise(&mut st) / m;
2026                if corrupt && e == BAD && in_slab {
2027                    m *= 0.3;
2028                    p += 0.8 * ((2.5 * i as f64 / N as f64).sin() + (1.7 * j as f64 / N as f64).cos()) * 0.5;
2029                }
2030                ph[e][idx] = wrap(p);
2031                mg[e][idx] = m;
2032            }
2033        }}}
2034        (ph, mg, mask, truth, slab)
2035    }
2036
2037    fn median(mut v: Vec<f64>) -> f64 {
2038        v.sort_by(f64::total_cmp);
2039        v[v.len() / 2]
2040    }
2041
2042    fn mean_abs_err_demeaned(got: &[f64], want: &[f64], mask: &[u8]) -> f64 {
2043        // The direct path loses a global constant field (removed by referencing anyway).
2044        let d: Vec<f64> = got.iter().zip(want).map(|(a, b)| a - b).collect();
2045        let c = masked_mean(&d, mask);
2046        let (s, n) = d.iter().zip(mask).filter(|(_, &m)| m != 0)
2047            .fold((0.0, 0usize), |(s, n), (&x, _)| (s + (x - c).abs(), n + 1));
2048        s / n as f64
2049    }
2050
2051    #[test]
2052    fn default_estimation_is_the_phase_snr_weighted_average_and_reports_nothing() {
2053        let cfg = FieldMappingConfig::default();
2054        assert_eq!(cfg.b0_estimation, B0EstimationMethod::WeightedAvg { weighting: B0WeightType::PhaseSNR });
2055        let (ph, mg, mask, _, _) = robust_series(false);
2056        let meta = meta_n(&TES4);
2057        let r = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {}).unwrap();
2058        assert!(r.robust_fit.is_none());
2059        let lf = FieldMappingConfig {
2060            b0_estimation: B0EstimationMethod::LinearFit(LinearFitParams::default()),
2061            ..Default::default()
2062        };
2063        let r = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &lf, &mut |_, _| {}).unwrap();
2064        assert!(r.robust_fit.is_none());
2065    }
2066
2067    /// Selecting the robust fit in the config runs `multi_echo_robust_fit` on exactly the echoes
2068    /// each path would hand any other estimator — bit for bit, and with its report returned.
2069    #[test]
2070    fn robust_fit_is_the_library_fit_on_every_path() {
2071        let (ph, mg, mask, _, _) = robust_series(true);
2072        let meta = meta_n(&TES4);
2073        let grid = crate::Grid::new(N, N, N, 1.0, 1.0, 1.0);
2074        let params = RobustFitParams::default();
2075        let run = |cfg: &FieldMappingConfig| {
2076            run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, cfg, &mut |_, _| {}).unwrap()
2077        };
2078
2079        // Path A: ROMEO with phase-offset removal (the default unwrapping).
2080        let cfg = robust_cfg(UnwrappingAlgorithm::Romeo, true);
2081        let got = run(&cfg);
2082        let crate::utils::PhaseOffsetRemoval { phases: corrected, .. } = crate::utils::phase_offset_removal(
2083            &ph, &mg, &TES4, cfg.phase_offset_sigma_voxels((1.0, 1.0, 1.0)), [0, 1],
2084            crate::unwrap::UnwrapMethod::Romeo, &grid,
2085        );
2086        let uw = crate::unwrap::unwrap_romeo_multi_echo(&corrected, &refs(&mg), &TES4, &mask, &cfg.romeo_params, &grid);
2087        let want = crate::utils::multi_echo_robust_fit(&uw, &mg, &TES4, &mask, &params);
2088        assert_eq!(got.b0_field_ppm, hz_to_ppm(&crate::utils::field_to_hz(&want.fit.field), 3.0));
2089        let rep = got.robust_fit.expect("path A must return the robust-fit report");
2090        assert_eq!(rep.robust_weights, want.robust_weights);
2091        assert_eq!(rep.quality.flagged, want.quality.flagged);
2092        assert!(got.phase_offset.is_some());
2093
2094        // Path B: Laplacian (always direct): masked unwrap, demean, fit.
2095        let got = run(&robust_cfg(UnwrappingAlgorithm::Laplacian, true));
2096        let mut uw = masked_unwrap(&ph, &mask, LaplacianSolver::Dct);
2097        uw.iter_mut().for_each(|u| remove_masked_mean(u, &mask));
2098        let want = crate::utils::multi_echo_robust_fit(&uw, &mg, &TES4, &mask, &params);
2099        assert_eq!(got.b0_field_ppm, rads_to_ppm(&want.fit.field, 3.0));
2100        assert_eq!(got.robust_fit.expect("Laplacian path report").robust_weights, want.robust_weights);
2101
2102        // Path B: ROMEO without phase-offset removal: ROMEO.jl's multi-echo unwrap, wrap
2103        // alignment, fit.
2104        let cfg = robust_cfg(UnwrappingAlgorithm::Romeo, false);
2105        let got = run(&cfg);
2106        let mut uw = crate::unwrap::unwrap_romeo_multi_echo(&ph, &mg, &TES4, &mask, &cfg.romeo_params, &grid);
2107        crate::unwrap::correct_multi_echo_wraps(&mut uw, &TES4, &mask);
2108        let want = crate::utils::multi_echo_robust_fit(&uw, &mg, &TES4, &mask, &params);
2109        assert_eq!(got.b0_field_ppm, rads_to_ppm(&want.fit.field, 3.0));
2110        assert!(got.robust_fit.is_some() && got.phase_offset.is_none());
2111    }
2112
2113    /// The point of the option, end to end through `run_field_mapping`: one echo corrupted over a
2114    /// slab is named, is down-weighted where it is corrupted (with ROMEO, only there), and the field map
2115    /// is closer to the truth than either of the existing estimators manages — on every path.
2116    /// On the clean series nothing is flagged.
2117    #[test]
2118    fn robust_fit_downweights_a_corrupted_echo_end_to_end() {
2119        let meta = meta_n(&TES4);
2120        let (ph, mg, mask, truth, slab) = robust_series(true);
2121        let (cph, cmg, _, _, _) = robust_series(false);
2122        let outside: Vec<u8> = mask.iter().zip(&slab).map(|(&m, &s)| (m != 0 && s == 0) as u8).collect();
2123        let in_set = |w: &[f64], set: &[u8]| -> Vec<f64> {
2124            w.iter().zip(set).filter(|(_, &m)| m != 0).map(|(&x, _)| x).collect()
2125        };
2126
2127        for (alg, offset) in [
2128            (UnwrappingAlgorithm::Romeo, true),
2129            (UnwrappingAlgorithm::Romeo, false),
2130            (UnwrappingAlgorithm::Laplacian, true),
2131        ] {
2132            let with = |b0_estimation: B0EstimationMethod, ph: &[Vec<f64>], mg: &[Vec<f64>]| {
2133                let cfg = FieldMappingConfig {
2134                    unwrapping_algorithm: alg,
2135                    phase_offset_removal: offset,
2136                    b0_estimation,
2137                    ..Default::default()
2138                };
2139                run_field_mapping(&refs(ph), Some(&refs(mg)), &mask, &meta, &cfg, &mut |_, _| {}).unwrap()
2140            };
2141            let robust = with(B0EstimationMethod::RobustFit(RobustFitParams::default()), &ph, &mg);
2142            let linear = with(B0EstimationMethod::LinearFit(LinearFitParams::default()), &ph, &mg);
2143            let wavg = with(B0EstimationMethod::default(), &ph, &mg);
2144            let clean = with(B0EstimationMethod::RobustFit(RobustFitParams::default()), &cph, &cmg);
2145
2146            let rep = robust.robust_fit.as_ref().unwrap();
2147            let tag = format!("{alg:?}, offset removal {offset}");
2148            assert_eq!(rep.quality.flagged, vec![BAD], "{tag}: must name exactly the corrupted echo ({:?})", rep.quality.outlier_score);
2149            assert!(clean.robust_fit.as_ref().unwrap().quality.flagged.is_empty(),
2150                "{tag}: clean series flagged: {:?}", clean.robust_fit.as_ref().unwrap().quality.outlier_score);
2151
2152            let bad_in = median(in_set(&rep.robust_weights[BAD], &slab));
2153            let bad_out = median(in_set(&rep.robust_weights[BAD], &outside));
2154            let good_in = median(in_set(&rep.robust_weights[0], &slab));
2155            assert!(bad_in < 0.5, "{tag}: corrupted echo kept median weight {bad_in} in the slab");
2156            println!("{tag}: median robust weight, bad echo in slab {bad_in}, outside {bad_out}; good echo in slab {good_in}");
2157            // Laplacian unwrapping is a global Poisson solve, so a corrupted slab leaves that echo
2158            // wrong (smeared, then shifted by its demeaning) everywhere, and the fit is right to
2159            // drop it everywhere: it does, and gets the lowest error of the three paths. ROMEO
2160            // keeps the damage local, so there the weight must stay up outside the slab.
2161            let local = alg == UnwrappingAlgorithm::Romeo;
2162            assert!((bad_out > 0.9 || !local) && good_in > 0.9,
2163                "{tag}: weights moved where nothing is wrong: bad echo outside {bad_out}, good echo inside {good_in}");
2164
2165            let e_rob = mean_abs_err_demeaned(&robust.b0_field_ppm, &truth, &mask);
2166            let e_lin = mean_abs_err_demeaned(&linear.b0_field_ppm, &truth, &mask);
2167            let e_avg = mean_abs_err_demeaned(&wavg.b0_field_ppm, &truth, &mask);
2168            let e_cln = mean_abs_err_demeaned(&clean.b0_field_ppm, &truth, &mask);
2169            println!("{tag}: field error (ppm) robust {e_rob:.5}, linear {e_lin:.5}, weighted avg {e_avg:.5}, robust on clean {e_cln:.5}");
2170            assert!(e_rob < e_lin && e_rob < e_avg, "{tag}: robust {e_rob} vs linear {e_lin} / weighted avg {e_avg}");
2171        }
2172    }
2173
2174    #[test]
2175    fn robust_fit_refuses_a_series_too_short_to_judge() {
2176        let (ph, mg, mask, _, _) = robust_series(false);
2177        let run = |n: usize, estimate_offset: bool| {
2178            let cfg = FieldMappingConfig {
2179                b0_estimation: B0EstimationMethod::RobustFit(RobustFitParams { estimate_offset, ..Default::default() }),
2180                ..Default::default()
2181            };
2182            run_field_mapping(&refs(&ph[..n]), Some(&refs(&mg[..n])), &mask, &meta_n(&TES4[..n]), &cfg, &mut |_, _| {})
2183        };
2184        for (n, offset) in [(1, true), (2, true), (3, true), (1, false), (2, false)] {
2185            match run(n, offset) {
2186                Err(PipelineError::InvalidConfig(msg)) => assert!(msg.contains("at least"), "{msg}"),
2187                other => panic!("{n} echoes, estimate_offset {offset}: expected InvalidConfig, got {:?}", other.map(|r| r.robust_fit.is_some())),
2188            }
2189        }
2190        assert!(run(3, false).unwrap().robust_fit.is_some());
2191        assert!(run(4, true).unwrap().robust_fit.is_some());
2192    }
2193
2194    #[test]
2195    fn robust_fit_rejects_out_of_range_parameters() {
2196        let (ph, mg, mask, _, _) = robust_series(false);
2197        let d = RobustFitParams::default();
2198        assert_eq!(d.min_echoes(), 4);
2199        assert!(d.validate().is_ok());
2200        let bad = [
2201            RobustFitParams { tuning: Some(0.0), ..d.clone() },
2202            RobustFitParams { tuning: Some(-1.0), ..d.clone() },
2203            RobustFitParams { tuning: Some(f64::NAN), ..d.clone() },
2204            RobustFitParams { tuning: Some(f64::INFINITY), ..d.clone() },
2205            RobustFitParams { downweight_level: 0.0, ..d.clone() },
2206            RobustFitParams { downweight_level: 1.5, ..d.clone() },
2207            RobustFitParams { downweight_level: f64::NAN, ..d.clone() },
2208            RobustFitParams { flag_ratio: 0.5, ..d.clone() },
2209            RobustFitParams { flag_ratio: f64::NAN, ..d.clone() },
2210        ];
2211        for p in bad {
2212            let cfg = FieldMappingConfig { b0_estimation: B0EstimationMethod::RobustFit(p.clone()), ..Default::default() };
2213            let r = run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta_n(&TES4), &cfg, &mut |_, _| {});
2214            assert!(matches!(r, Err(PipelineError::InvalidConfig(_))), "{p:?} was accepted");
2215        }
2216        // The edges of every range are valid; iterations = 0 is the report-only mode.
2217        let edges = [
2218            RobustFitParams { tuning: Some(1e-3), ..d.clone() },
2219            RobustFitParams { downweight_level: 1.0, ..d.clone() },
2220            RobustFitParams { flag_ratio: 1.0, ..d.clone() },
2221            RobustFitParams { flag_ratio: f64::INFINITY, ..d.clone() },
2222            RobustFitParams { iterations: 0, ..d.clone() },
2223        ];
2224        for p in edges {
2225            assert!(p.validate().is_ok(), "{p:?} was refused");
2226        }
2227    }
2228
2229    /// `iterations: 0` leaves every robust weight at 1, so the field map is the plain linear fit
2230    /// (same estimate_offset) bit for bit, while the report is still produced and still names the
2231    /// corrupted echo.
2232    #[test]
2233    fn robust_fit_with_no_iterations_is_the_linear_fit_plus_a_report() {
2234        let (ph, mg, mask, _, _) = robust_series(true);
2235        let meta = meta_n(&TES4);
2236        let run = |b0_estimation| {
2237            let cfg = FieldMappingConfig { b0_estimation, ..Default::default() };
2238            run_field_mapping(&refs(&ph), Some(&refs(&mg)), &mask, &meta, &cfg, &mut |_, _| {}).unwrap()
2239        };
2240        let r = run(B0EstimationMethod::RobustFit(RobustFitParams { iterations: 0, ..Default::default() }));
2241        let l = run(B0EstimationMethod::LinearFit(LinearFitParams::default()));
2242        assert_eq!(r.b0_field_ppm, l.b0_field_ppm);
2243        let rep = r.robust_fit.unwrap();
2244        assert!(rep.robust_weights.iter().all(|w| w.iter().all(|&x| x == 1.0)));
2245        assert_eq!(rep.quality.flagged, vec![BAD]);
2246    }
2247}